Last active
February 11, 2025 01:02
-
-
Save danielt1263/69312eb1c2fdcfda0e9b47ab9581bde3 to your computer and use it in GitHub Desktop.
A nice little Coordinator class that allows you to design the view hierarchy of your app outside of the views themselves. It also can handle runtime updating of the hierarchy for e.g. from a network request.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// Coordinator.swift | |
// | |
// Created on 1 Feb 2025. | |
// Copyright © 2025 Daniel Tartaglia. MIT License. | |
// | |
import SwiftUI | |
@Observable | |
final class Coordinator { | |
/// Add or update a transition rule. This establishes the parent/child relationship for all screens. | |
/// - Parameters: | |
/// - parent: The parent screen that the child will transition from. | |
/// - tag: A specifier for when a parent can transition to more than one child screen. | |
/// - child: The child screen that the parent will transition to. | |
func updateRule(parent: Screen, tag: String = "", child: Screen) { | |
precondition(parent.id != "", "Parent screen must have a non-empty ID") | |
let composedRule = Rule(screenID: parent.id, tag: tag) | |
if rules[composedRule]?.id != child.id { | |
rules[composedRule] = child | |
} | |
} | |
/// Cause a transition to occur. Note that the parent view needs know nothing about what child view will be presented. | |
/// - Parameters: | |
/// - screen: The parent screen that is doing the transition. | |
/// - tag: A specifier for when a parent can transition to more than one child screen. | |
func show(from screen: Screen, tag: String = "") { | |
let composedRule = Rule(screenID: screen.id, tag: tag) | |
currentViewings[screen.id] = rules[composedRule] ?? missingScreen | |
} | |
/// Use this, for e.g., as the `isPresented` parameter for a sheet. | |
/// - Parameter screen: The parent screen that is presenting. | |
/// - Returns: A Binding that will track wether the parent is currently preenting a screen. | |
func isPresented(from screen: Screen) -> Binding<Bool> { | |
Binding( | |
get: { [weak self] in self?.isShowing(screen) ?? false }, | |
set: { [weak self] in self?.update(isShowing: $0, screen) } | |
) | |
} | |
/// Use this, for e.g., as the `content` paramater for a sheet. | |
/// - Parameter screen: The parent screen that is presenting | |
/// - Returns: A closure that will provide the current screen being presented from the parent screen. | |
func content(from screen: Screen) -> () -> AnyView { | |
{ [weak self] in self?.currentViewings[screen.id]?.create() ?? self?.ruleView ?? AnyView(EmptyView()) } | |
} | |
/// Use this, for e.g., as the `destination` parameter in a `NavigationLink`. | |
/// - Parameters: | |
/// - screen: The parent screen of the navigation. | |
/// - tag: A specifier for when a parent can transition to more than one child screen. | |
/// - Returns: A closure that will provide the screen that the navigation link should transition to. | |
func navigationLink(from screen: Screen, tag: String = "") -> () -> AnyView { | |
{ [weak self] in | |
let composedRule = Rule(screenID: screen.id, tag: tag) | |
return self?.rules[composedRule]?.create() ?? self?.ruleView ?? AnyView(EmptyView()) | |
} | |
} | |
private struct Rule: Hashable { | |
let screenID: Screen.ID | |
let tag: String | |
} | |
private var currentViewings = [Screen.ID: Screen]() | |
private var rules = [Rule: Screen]() | |
private func isShowing(_ screen: Screen) -> Bool { | |
currentViewings.keys.contains(screen.id) | |
} | |
private func update(isShowing: Bool, _ screen: Screen) { | |
if !isShowing { | |
currentViewings.removeValue(forKey: screen.id) | |
} | |
} | |
private var missingScreen: Screen { | |
Screen(id: "") { [weak self] in self?.ruleView ?? AnyView(EmptyView()) } | |
} | |
private var ruleView: AnyView { | |
func template(parent: Screen.ID, tag: String, child: Screen) -> String { | |
"from \(parent) \(tag.isEmpty ? "" : "with tag \(tag) ")to \(child.id)" | |
} | |
let tagTexts = rules.map { template(parent: $0.key.screenID, tag: $0.key.tag, child: $0.value) } | |
return AnyView( | |
VStack { | |
Text("current rules:") | |
List(tagTexts, id: \.self) { text in | |
Text(text) | |
} | |
} | |
) | |
} | |
} | |
/** | |
A `Screen` value provides a transition ID for a screen. The create closure should return a new instance of the screen. | |
This can be as simple as: | |
``` | |
extension Screen { | |
static let content = Screen(id: "Content", create: { AnyView(ContentView()) }) | |
} | |
``` | |
If the screen needs parameters, the solution will look more like: | |
``` | |
extension Screen { | |
static func myScreen(parameter: Value) -> Self { | |
Screen(id: "MyScreen", create: { AnyView(MyScreen(parameter: parameter)) }) | |
} | |
} | |
``` | |
*/ | |
struct Screen: Identifiable { | |
let id: String | |
let create: () -> AnyView | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Testing | |
import SwiftUI | |
struct CoordinatorTests { | |
@Test func isPresented() { | |
let parentScreen = Screen(id: "Parent", create: { AnyView(EmptyView()) }) | |
let childScreen = Screen(id: "Child", create: { AnyView(EmptyView()) }) | |
let coordinator = Coordinator() | |
let binding = coordinator.isPresented(from: parentScreen) | |
coordinator.updateRule(parent: parentScreen, child: childScreen) | |
#expect(binding.wrappedValue == false, "precondition") | |
coordinator.show(from: parentScreen) | |
#expect(binding.wrappedValue == true) | |
binding.wrappedValue = false | |
#expect(binding.wrappedValue == false) | |
} | |
@Test func content() { | |
var builtChild = false | |
let parentScreen = Screen(id: "Parent", create: { AnyView(EmptyView()) }) | |
let childScreen = Screen(id: "Child", create: { | |
builtChild = true | |
return AnyView(EmptyView()) | |
}) | |
let coordinator = Coordinator() | |
let buildChildView = coordinator.content(from: parentScreen) | |
coordinator.updateRule(parent: parentScreen, child: childScreen) | |
_ = buildChildView() | |
#expect(!builtChild) | |
coordinator.show(from: parentScreen) | |
#expect(!builtChild) | |
_ = buildChildView() | |
#expect(builtChild) | |
} | |
@Test func navigationLink() { | |
var builtChild = false | |
let parentScreen = Screen(id: "Parent", create: { AnyView(EmptyView()) }) | |
let childScreen = Screen(id: "Child", create: { | |
builtChild = true | |
return AnyView(EmptyView()) | |
}) | |
let coordinator = Coordinator() | |
let buildChildView = coordinator.navigationLink(from: parentScreen) | |
_ = buildChildView() | |
#expect(!builtChild) | |
coordinator.updateRule(parent: parentScreen, child: childScreen) | |
_ = buildChildView() | |
#expect(builtChild) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment