📲 ScreenUI

一个多平台、多范式的声明式路由框架,适用于 iOS/macOS 等,是 Storyboard 的替代方案。

支持 UIKitAppKitSwiftUI

真实世界示例

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 略有更改。

SwiftUI 示例
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])
    }
}

深入了解

屏幕(Screen)

每个屏幕都必须实现以下协议

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)
    }
}

阅读更多关于屏幕。

转场(Transition)

任何转场都必须实现以下协议

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))
    }
}

为了使您的屏幕更灵活,您可以定义类型擦除的转场

因此,当您构建 屏幕树 时,您可以为一个场景设置一个转场,在另一个场景中为同一个屏幕设置另一个转场。

阅读更多关于转场。

屏幕路径(Screen Path)

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)

如果您确定屏幕显示在层次结构中,则可以忽略上下文值。

内容构建器(Content Builders)

某些屏幕可以具有动态内容,例如 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 {}

跨平台(Cross-platform)

框架 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
}

为方便起见,该框架提供了协议,这些协议可以将类型别名化为嵌套类型:UIKitNamespaceAppKitNamespaceSwiftUINamespace。 应用其中之一,您可以编写跨平台代码,其中

屏幕

转场

SwiftUI

支持的屏幕

支持的转场

UIKit

支持的屏幕

支持的转场

AppKit

支持的屏幕

支持的转场

最佳实践

屏幕外观您可以定义一个协议来描述屏幕外观。 这样,你将创建一个单一的事实来源。
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)
    }
}
通用转场有些屏幕应该随处可用。 因此,您可以扩展 `ContentScreen` 协议。
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")

安装(Installation)

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 文件。