Skip to content

Instantly share code, notes, and snippets.

@kruperfone
Last active April 1, 2023 18:38
Show Gist options
  • Save kruperfone/96e5d15524d9d0eb52f44948d9724175 to your computer and use it in GitHub Desktop.
Save kruperfone/96e5d15524d9d0eb52f44948d9724175 to your computer and use it in GitHub Desktop.
Experiment to adapt SwiftUI navigation stack
// 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