Instantly share code, notes, and snippets.
Last active
April 1, 2023 18:38
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save kruperfone/96e5d15524d9d0eb52f44948d9724175 to your computer and use it in GitHub Desktop.
Experiment to adapt SwiftUI navigation stack
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
// Created by Sergei Nikolaev on 31/03/2023. | |
// | |
import SwiftUI | |
import UIKit | |
/* | |
# Navigation plan | |
## View 1 Routes | |
- Push: View 2 | |
## View 2 Routes | |
- Push: View A | |
- Push: View B | |
- Push: View C | |
## View A Routes | |
- Modal: View 1 (in navigation stack) | |
## View B Routes | |
- Modal: ViewModal (without stack) | |
## View C Routes | |
- Modal: UIKitController (fullScreen transition without stack) | |
*/ | |
@main | |
struct PlaygroundApp: App { | |
var body: some Scene { | |
WindowGroup { | |
NavigationBuilder(root: .view1) | |
} | |
} | |
} | |
// MARK: - Routes | |
enum Route: Hashable { | |
case view1 | |
case view2(String) | |
case viewA | |
case viewB | |
case viewC | |
case viewModal | |
case uikit | |
} | |
// MARK: - Navigation Builder | |
struct NavigationBuilder: View { | |
@StateObject var router = Router() | |
let root: Route | |
var body: some View { | |
NavigationStack(path: $router.stack) { | |
resolveView(for: root) | |
.navigationDestination(for: Route.self, destination: resolveView(for:)) | |
.sheet(item: router.modal(for: .sheet), content: resolveModalView(for:)) | |
.fullScreenCover(item: router.modal(for: .fullScreen), content: resolveModalView(for:)) | |
} | |
} | |
@ViewBuilder | |
func resolveModalView(for modal: Router.Modal) -> some View { | |
switch modal { | |
case .plain(let route): | |
resolveView(for: route) | |
case .stack(let route, let router): | |
NavigationBuilder(router: router, root: route) | |
} | |
} | |
@ViewBuilder | |
func resolveView(for route: Route) -> some View { | |
switch route { | |
case .view1: | |
View1( | |
viewModel: View1Model { | |
switch $0 { | |
case .view2(let string): | |
router.push(.view2(string)) | |
} | |
} | |
) | |
case .view2(let string): | |
View2( | |
viewModel: View2Model(value: string) { | |
switch $0 { | |
case .viewA: | |
router.push(.viewA) | |
case .viewB: | |
router.push(.viewB) | |
case .viewC: | |
router.push(.viewC) | |
} | |
} | |
) | |
case .viewA: | |
ViewA( | |
viewModel: ViewAModel { | |
switch $0 { | |
case .view1: | |
router.present(.view1, inStack: true) | |
} | |
} | |
) | |
case .viewB: | |
ViewB( | |
viewModel: ViewBModel { | |
switch $0 { | |
case .viewModal: | |
router.present(.viewModal, inStack: false) | |
} | |
} | |
) | |
case .viewC: | |
ViewC( | |
viewModel: ViewCModel() { | |
switch $0 { | |
case .uikit: | |
router.present(.uikit, inStack: false, transition: .fullScreen) | |
} | |
} | |
) | |
case .viewModal: | |
ViewModal() | |
case .uikit: | |
ViewController() { | |
switch $0 { | |
case .dismiss: | |
router.dismissPresented() | |
} | |
} | |
} | |
} | |
} | |
// MARK: - Router | |
struct RouterModal: Identifiable { | |
var id: Route { root } | |
let root: Route | |
let router: Router? | |
} | |
final class Router: ObservableObject { | |
let id = UUID() | |
@Published var stack: [Route] = [] | |
@Published var modal: (modal: Modal, transition: TransitionType)? | |
func modal(for requestedTransition: TransitionType) -> Binding<Modal?> { | |
Binding<Modal?>( | |
get: { | |
guard let (modal, transition) = self.modal, transition == requestedTransition else { return nil } | |
return modal | |
}, | |
set: { newValue in | |
if let value = newValue { | |
self.modal = (value, self.modal?.transition ?? .sheet) | |
} else { | |
self.modal = nil | |
} | |
} | |
) | |
} | |
func push(_ route: Route) { | |
stack.append(route) | |
} | |
func pop() { | |
_ = stack.popLast() | |
} | |
func reset() { | |
dismissPresented() | |
stack.removeAll() | |
} | |
func present(_ route: Route, inStack: Bool, transition: TransitionType = .sheet) { | |
if inStack { | |
modal = (.stack(route: route, router: Router()), transition) | |
} else { | |
modal = (.plain(route: route), transition) | |
} | |
} | |
func dismissPresented() { | |
modal = nil | |
} | |
} | |
extension Router { | |
enum TransitionType { | |
case sheet | |
case fullScreen | |
} | |
enum Modal: Identifiable { | |
case plain(route: Route) | |
case stack(route: Route, router: Router) | |
var id: String { | |
switch self { | |
case .plain(let route): return "Plain-\(route)" | |
case .stack(let route, let router): return "Stack-\(route)-\(router.id.uuidString)" | |
} | |
} | |
var route: Route { | |
switch self { | |
case .plain(let route): return route | |
case .stack(let route, _): return route | |
} | |
} | |
var router: Router? { | |
if case .stack(_, let router) = self { | |
return router | |
} else { | |
return nil | |
} | |
} | |
} | |
} | |
// MARK: - View 1 | |
struct View1<ViewModel: View1ModelProtocol>: View { | |
@StateObject var viewModel: ViewModel | |
var body: some View { | |
List(viewModel.rows, id: \.self) { string in | |
Button(string) { | |
viewModel.toView2(string) | |
} | |
} | |
.navigationTitle("View 1") | |
} | |
} | |
protocol View1ModelProtocol: ObservableObject { | |
var rows: [String] { get } | |
func toView2(_ string: String) | |
} | |
enum View1Route { | |
case view2(String) | |
} | |
final class View1Model: View1ModelProtocol { | |
let rows: [String] = (0..<100).map { "Line #\($0)" } | |
let coordinate: (View1Route) -> Void | |
init(coordinate: @escaping (View1Route) -> Void) { | |
self.coordinate = coordinate | |
} | |
func toView2(_ string: String) { | |
coordinate(.view2(string)) | |
} | |
} | |
// MARK: - View 2 | |
enum View2Route { | |
case viewA | |
case viewB | |
case viewC | |
} | |
struct View2<ViewModel: View2ModelProtocol>: View { | |
@StateObject var viewModel: ViewModel | |
var body: some View { | |
VStack { | |
Text("View 2") | |
.font(.title) | |
Text(viewModel.value) | |
Spacer() | |
.frame(height: 16) | |
Button("to View A", action: viewModel.toViewA) | |
.padding() | |
Button("to View B", action: viewModel.toViewB) | |
.padding() | |
Button("to View C", action: viewModel.toViewC) | |
.padding() | |
} | |
} | |
} | |
protocol View2ModelProtocol: ObservableObject { | |
var value: String { get } | |
func toViewA() | |
func toViewB() | |
func toViewC() | |
} | |
final class View2Model: View2ModelProtocol { | |
let value: String | |
let coordinate: (View2Route) -> Void | |
init(value: String, coordinate: @escaping (View2Route) -> Void) { | |
self.value = value | |
self.coordinate = coordinate | |
} | |
func toViewA() { | |
coordinate(.viewA) | |
} | |
func toViewB() { | |
coordinate(.viewB) | |
} | |
func toViewC() { | |
coordinate(.viewC) | |
} | |
} | |
// MARK: - View A | |
enum ViewARoute { | |
case view1 | |
} | |
struct ViewA<ViewModel: ViewAModelProtocol>: View { | |
@StateObject var viewModel: ViewModel | |
var body: some View { | |
VStack { | |
Text("View A") | |
.font(.title) | |
Button("to modal View 1") { | |
viewModel.toView1() | |
} | |
} | |
} | |
} | |
protocol ViewAModelProtocol: ObservableObject { | |
func toView1() | |
} | |
final class ViewAModel: ViewAModelProtocol { | |
let coordinate: (ViewARoute) -> Void | |
init(coordinate: @escaping (ViewARoute) -> Void) { | |
self.coordinate = coordinate | |
} | |
func toView1() { | |
coordinate(.view1) | |
} | |
} | |
// MARK: - View B | |
enum ViewBRoute { | |
case viewModal | |
} | |
struct ViewB<ViewModel: ViewBModelProtocol>: View { | |
@StateObject var viewModel: ViewModel | |
var body: some View { | |
VStack { | |
Text("View B") | |
.font(.title) | |
Button("to modal ViewModal") { | |
viewModel.toViewB() | |
} | |
} | |
} | |
} | |
protocol ViewBModelProtocol: ObservableObject { | |
func toViewB() | |
} | |
final class ViewBModel: ViewBModelProtocol { | |
let coordinate: (ViewBRoute) -> Void | |
init(coordinate: @escaping (ViewBRoute) -> Void) { | |
self.coordinate = coordinate | |
} | |
func toViewB() { | |
coordinate(.viewModal) | |
} | |
} | |
// MARK: - View C | |
enum ViewCRoute { | |
case uikit | |
} | |
struct ViewC<ViewModel: ViewCModelProtocol>: View { | |
@StateObject var viewModel: ViewModel | |
var body: some View { | |
VStack { | |
Text("View C") | |
.font(.title) | |
Button("to UIKit") { | |
viewModel.toUIKit() | |
} | |
} | |
} | |
} | |
protocol ViewCModelProtocol: ObservableObject { | |
func toUIKit() | |
} | |
final class ViewCModel: ViewCModelProtocol { | |
let coordinate: (ViewCRoute) -> Void | |
init(coordinate: @escaping (ViewCRoute) -> Void) { | |
self.coordinate = coordinate | |
} | |
func toUIKit() { | |
coordinate(.uikit) | |
} | |
} | |
// MARK: - View Modal | |
struct ViewModal: View { | |
var body: some View { | |
VStack { | |
Text("View Modal") | |
.font(.title) | |
} | |
} | |
} | |
protocol ViewModalModelProtocol: ObservableObject { | |
} | |
final class ViewModelModal: ViewModalModelProtocol { | |
} | |
// MARK: UIKit ViewController | |
enum UIKitViewControllerRoute { | |
case dismiss | |
} | |
final class UIKitViewController: UIViewController { | |
let coordinate: (UIKitViewControllerRoute) -> Void | |
init(coordinate: @escaping (UIKitViewControllerRoute) -> Void) { | |
self.coordinate = coordinate | |
super.init(nibName: nil, bundle: nil) | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
let label = UILabel() | |
label.text = "UIKit ViewController" | |
label.translatesAutoresizingMaskIntoConstraints = false | |
view.addSubview(label) | |
NSLayoutConstraint.activate([ | |
label.centerXAnchor.constraint(equalTo: view.centerXAnchor), | |
label.centerYAnchor.constraint(equalTo: view.centerYAnchor) | |
]) | |
let button = UIButton(type: .system) | |
button.setTitle("Dismiss", for: .normal) | |
button.translatesAutoresizingMaskIntoConstraints = false | |
view.addSubview(button) | |
NSLayoutConstraint.activate([ | |
button.centerXAnchor.constraint(equalTo: view.centerXAnchor), | |
button.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 16) | |
]) | |
button.addTarget(self, action: #selector(dismissSelf), for: .touchUpInside) | |
} | |
@objc func dismissSelf() { | |
coordinate(.dismiss) | |
} | |
} | |
struct ViewController: UIViewControllerRepresentable { | |
let coordinate: (UIKitViewControllerRoute) -> Void | |
func makeUIViewController(context: Context) -> some UIViewController { | |
UIKitViewController(coordinate: coordinate) | |
} | |
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { | |
// Do nothing | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment