Skip to content

k-o-d-e-n/ScreenUI

Repository files navigation

📲 ScreenUI

A multi-platform, multi-paradigm declarative routing framework for iOS/macOS and others, the replacement of Storyboard.

Supports UIKit, AppKit, SwiftUI.

Real world example

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

Table of contents

Main features

  • Complete view of your app’s flow in one place
  • Isolating of screen transitions
  • Deep transitions (deep-link)
  • Abstracted implementation of the transition and its unification
  • Screen constants
  • Cross-platform core
  • Fully strong-typed code

With ScreenUI you will forget about such methods, like func pushViewController(_:), func present(_:), about implementations are based on enums and reducers.

The best achievement of this framework is a combination of strictness (strong types, declarative style, transitions isolation) and flexibility (configurability of screens for each scenario, interchangeability of transitions, optional transitions).

Quick course

Just like in Storyboard, the framework works in terms of screens.

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

Typical screen content implementation:

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

All you need in the next step is to build a screen tree and show any screen from hierarchy:

transitionsMap.router[root: .default][case: \.0, ()].move(from: (), completion: nil)

Due to the specific interface of SwiftUI some things have small changes in API.

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

Deep dive

Screen

Every screen must implement the protocol below:

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

Screens that are responsible for performing transitions must implement the protocol ContentScreen.

Screen containers (like Navigation) must implement the protocol ScreenContainer where ScreenContainer.NestedScreen is a transition target.

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

Read more about screens.

Transition

Any transition must implement the protocol below:

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

Transitions between the content screens must implement ScreenTransition protocol. Every such transition should provide a back behavior by assign ScreenState.back property.

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

To make your screens more flexible, you can define type-erased transitions:

  • AnyScreenTransition - supports transitions where Context is equal to context of the target content screen.
  • PreciseTransition - supports transitions where Context is equal to context of the target container screen.

So, when you will building screen tree, you can set up in one scenario one transition, another transition in the another scenario for the same screen.

Read more about transitions.

Screen path

Router provides a subscript interface to build the path to the screen using Swift Key-path expressions:

///  [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)

You can omit the context value if you sure that screen is presented in hierarchy.

Content builders

Some screens can have dynamic content, for example Tabs. Therefore the framework provides ScreenBuilder protocol:

public protocol ScreenBuilder: PathProvider {
    associatedtype Content
    associatedtype Context

    func makeContent<From>(_ context: Context, router: Router<From>) -> Content where From: Screen, From.PathFrom == PathFrom
}

And of course for such instances is necessary Swift's result builder:

@resultBuilder
public struct ContentBuilder {}

Cross-platform

Framework API has cross-platform namespaces:

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
}

For convenience, the framework provides protocols that enable typealiases to the nested types: UIKitNamespace, AppKitNamespace, SwiftUINamespace. Apply one of them and you can write crossplatform code where:

Screens:

  • Window - a screen container that wraps a initial screen of your app.
  • Navigation - a screen container that creates navigation stack.
  • Tabs - a content screen that organize multiple screens to tab view interface.

Transitions

  • Push - a transition that pushes a new screen onto the navigation stack, with the corresponding Pop transition.
  • Present - a transition that presents a new screen, covering the current screen, with the corresponding Dismiss transition.

SwiftUI

Supported screens:

  • Window
  • Navigation
  • Tabs

Supported transitions:

  • Push/Pop
  • Present/Dismiss

UIKit

Supported screens:

  • Window
  • Navigation
  • Tabs

Supported transitions:

  • Push/Pop
  • Present/Dismiss

AppKit

Supported screens:

  • Window
  • Tabs

Supported transitions:

  • Present/Dismiss

Best Practices

Screen appearance You can define a protocol that will describe a screen appearance. So, you will create a single source of truth.
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)
    }
}
Universal transitions There is screens that should available everywhere. So, you can extend `ContentScreen` protocol.
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")

Author

Denis Koryttsev, @k-o-d-e-n, [email protected]

License

ScreenUI is available under the MIT license. See the LICENSE file for more info.