一个多平台、多范式的声明式路由框架,适用于 iOS/macOS 等,是 Storyboard 的替代方案。
支持 UIKit
、AppKit
、SwiftUI
。
static let transitionsMap = AppScreen(
Delegate(
ConditionalWindow {
Launch()
Welcome(
login: Present(Navigation(Login())),
registration: Present(Navigation(Registration()))
)
Tabs {
Navigation(
Feed(
page: Push(Page()),
match: Push(Match()),
gameDay: Push(GameDay(match: Push(Match()))),
tournament: Push(Tournament(match: Push(Match())))
)
)
Navigation(
Search(
filter: AnySearchFilter()
.navigation()
.configContent({ $0.isToolbarHidden = false })
.present(),
user: Present(Navigation(Player(team: Push(Team())))),
team: Team(player: Player().push())
.navigation()
.present(),
league: Present(Navigation(League())),
match: Present(Navigation(Match()))
)
)
Navigation(
Dashboard(
edit: Flow(base: UserEdit(editable: true)).present(),
entities: .init(
user: Push(Player(team: Team().navigation().present())),
team: Push(Team(player: Player().navigation().present())),
league: Push(League(
team: Push(Team()),
tournament: Push(Tournament(match: Push(Match())))
))
)
)
)
Navigation(
Messages(
settings: Present(
Settings(
account: Push(AccountInfo()),
changePassword: Push(ChangePassword())
).navigation()
)
)
)
}
.configContent({ tabbar in
tabbar.prepareViewAppearance()
})
.with(((), (), (), ()))
}
)
)
使用 ScreenUI
,您将忘记诸如 func pushViewController(_:)
、func present(_:)
之类的方法,以及基于枚举和 reducers 的实现。
这个框架的最佳成就是严格性(强类型、声明式风格、转场隔离)和灵活性(为每种场景配置屏幕、转场的可互换性、可选转场)的结合。
就像 Storyboard 一样,该框架以屏幕为单位工作。
struct SomeScreen: ContentScreen {
typealias NestedScreen = Self
typealias Context = Void
typealias Content = SomeView
/// define constants (color, title, and so on)
/// define transitions
func makeContent(_ context: Context, router: Router<NestedScreen>) -> ContentResult<Self> {
/// initialize a content view
/// pass `context` and `router` to content (constants will be available through `router`)
/// return the result
}
}
典型的屏幕内容实现
class SomeView {
let router: Router<SomeScreen>
let context: SomeScreen.Context
let title: String
init(router: Router<SomeScreen>, context: SomeScreen.Context) {
self.router = router
self.context = context
self.title = router[next: \.title]
}
func didLoad() {
let textLabel = TextLabel(title: context.text)
...
}
func closeScreen() {
router.back(completion: nil)
}
func moveNext() {
let nextScreenContext = ...
router.move(\.nextScreen, from: self, with: nextScreenContext, completion: nil)
}
}
您下一步需要做的是构建一个 屏幕树 并显示层次结构中的任何屏幕
transitionsMap.router[root: .default][case: \.0, ()].move(from: (), completion: nil)
由于 SwiftUI 的特定界面,某些 API 略有更改。
struct DetailView: View {
let router: Router<DetailScreen>
let context: String
var body: some View {
VStack {
Text(context)
/// optional transition
if let view = router.move(
\.nextScreen,
context: "Subdetail text!!1",
action: Text("Next"),
completion: nil
) {
view
}
/// move back
Button("Back") { router.back() }
}
.navigationTitle(router[next: \.title])
}
}
每个屏幕都必须实现以下协议
public typealias ContentResult<S> = (contentWrapper: S.Content, screenContent: S.NestedScreen.Content) where S: Screen
public protocol Screen: PathProvider where PathFrom == NestedScreen.PathFrom {
/// Routing target
associatedtype NestedScreen: ContentScreen where NestedScreen.NestedScreen == NestedScreen
/// *UIViewController* subclass in UIKit, *NSViewController* subclass in AppKit, *View* in SwiftUI, or your custom screen representer
associatedtype Content
/// Required data that is passed to content
associatedtype Context = Void
func makeContent(_ context: Context, router: Router<NestedScreen>) -> ContentResult<Self>
}
负责执行转场的屏幕必须实现协议 ContentScreen
。
屏幕容器(如 *Navigation*)必须实现协议 ScreenContainer
,其中 ScreenContainer.NestedScreen
是转场目标。
public struct Navigation<Root>: ScreenContainer where Root: Screen, Root.Content: UIViewController {
public typealias Context = Root.Context
public typealias Content = UINavigationController
public typealias NestedScreen = Root.NestedScreen
let _root: Root
public func makeContent(_ context: Root.Context, router: Router<Root.NestedScreen>) -> ContentResult<Self> {
let (content1, content0) = _root.makeContent(context, router: router)
let content2 = UINavigationController(rootViewController: content1)
return (content2, content0)
}
}
阅读更多关于屏幕。
任何转场都必须实现以下协议
public typealias TransitionResult<From, To> = (state: TransitionState<From, To>, screenContent: To.NestedScreen.Content) where From: Screen, To: Screen
public protocol Transition: PathProvider where PathFrom == To.PathFrom {
associatedtype From: Screen
associatedtype To: Screen
associatedtype Context
func move(from screen: From.Content, state: ScreenState<From.NestedScreen>, with context: Context, completion: (() -> Void)?) -> TransitionResult<From, To>
}
内容屏幕之间的转场必须实现 ScreenTransition
协议。 每个这样的转场都应该通过分配 ScreenState.back
属性来提供后退行为。
public struct Present<From, To>: ScreenTransition {
/// ...
public func move(from content: From.Content, state: ScreenState<From.NestedScreen>, with context: Too.Context, completion: (() -> Void)?) -> TransitionResult<From, To> {
let nextState = TransitionState<From, To>()
nextState.back = .some(Dismiss(animated: animated))
let (content1, content0) = to.makeContent(context, router: Router(from: to, state: nextState))
surface.present(content1, animated: animated, completion: completion)
return (nextState, (content1, content0))
}
}
为了使您的屏幕更灵活,您可以定义类型擦除的转场
AnyScreenTransition
- 支持 Context
等于目标内容屏幕的 *context* 的转场。PreciseTransition
- 支持 Context
等于目标容器屏幕的 *context* 的转场。因此,当您构建 屏幕树 时,您可以为一个场景设置一个转场,在另一个场景中为同一个屏幕设置另一个转场。
阅读更多关于转场。
Router
提供了一个下标接口,使用 *Swift Key-path 表达式* 构建屏幕路径
/// [Initial screen] [Conditional screen] [Tab screen] [Some next screen in scenario] [Run chain from root screen content]
/// / / | / /
router[root: <%context%>][case: \.2, <%context%>][select: \.1][move: \.nextScreen, <%context%>].move(from: (), completion: nil)
/// or using dot syntax
router.root(<%context%>).case(<%context%>).select(\.1).move(\.nextScreen, <%context%>).move(from: (), completion: nil)
如果您确定屏幕显示在层次结构中,则可以忽略上下文值。
某些屏幕可以具有动态内容,例如 Tabs
。 因此,该框架提供了 ScreenBuilder
协议
public protocol ScreenBuilder: PathProvider {
associatedtype Content
associatedtype Context
func makeContent<From>(_ context: Context, router: Router<From>) -> Content where From: Screen, From.PathFrom == PathFrom
}
当然,对于此类实例,Swift 的 result builder 是必不可少的
@resultBuilder
public struct ContentBuilder {}
框架 API 具有跨平台命名空间
public enum Win {} /// Window implementations
public enum Nav {} /// Navigation implementations
public enum Tab {} /// Tabs implementations
extension Nav {
public enum Push { /// Push implementations
public enum Pop {} /// Pop implementations
}
}
public enum Presentation { /// Present implementations
public enum Dismiss {} /// Dismiss implementations
}
为方便起见,该框架提供了协议,这些协议可以将类型别名化为嵌套类型:UIKitNamespace
、AppKitNamespace
、SwiftUINamespace
。 应用其中之一,您可以编写跨平台代码,其中
屏幕
Window
- 一个屏幕容器,用于包装你的应用程序的初始屏幕。Navigation
- 一个屏幕容器,用于创建导航堆栈。Tabs
- 一个内容屏幕,将多个屏幕组织到选项卡视图界面。转场
Push
- 一个将新屏幕推送到导航堆栈的转场,具有相应的 Pop
转场。Present
- 一个呈现新屏幕,覆盖当前屏幕的转场,具有相应的 Dismiss
转场。支持的屏幕
Window
Navigation
Tabs
支持的转场
Push
/Pop
Present
/Dismiss
支持的屏幕
Window
Navigation
Tabs
支持的转场
Push
/Pop
Present
/Dismiss
支持的屏幕
Window
Tabs
支持的转场
Present
/Dismiss
protocol ScreenAppearance {
var title: String { get }
var tabImage: Image? { get }
...
}
extension ScreenAppearance {
var tabImage: Image? { nil }
...
}
extension ScreenAppearance where Self: ContentScreen {
func applyAppearance(_ content: Content) {
/// configure content
}
}
protocol ScreenContent {
associatedtype Route: Screen
var router: Router<Route> { get }
}
extension ScreenContent where Route.PathFrom: ScreenAppearance {
func prepareAppearance() {
router[next: \.self].applyAppearance(self)
}
}
struct Alert: ContentScreen {
/// alert screen implementation
}
extension ContentScreen {
var alert: Present<Self, Alert> { get }
}
/// now you can show alert from any screen
router.move(\.alert, from: self, with: "Hello world")
pod 'ScreenUI'
.package(url: "https://github.com/k-o-d-e-n/ScreenUI", from: "1.1.0")
Denis Koryttsev, @k-o-d-e-n, koden.u8800@gmail.com
ScreenUI 在 MIT 许可下可用。 有关更多信息,请参见 LICENSE 文件。