Instantly share code, notes, and snippets.
Last active
December 18, 2023 02:33
-
Star
9
(9)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save Thomvis/acd3c398eb2be1f30576835b8c1383b4 to your computer and use it in GitHub Desktop.
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
// | |
// ContentView.swift | |
// NavigationViewTest | |
// | |
// Created by Thomas Visser on 04/11/2019. | |
// Copyright © 2019 Thomas Visser. All rights reserved. | |
// | |
import SwiftUI | |
// The core of this approach is a custom wrapper around UINavigationController that works on | |
// an array of views. If a view is added to the array, a new VC is pushed. | |
// If a view is removed, that VC is popped. | |
// Step 1 | |
private final class StateDrivenNavigationController<V>: NSObject, UIViewControllerRepresentable, UIDocumentPickerDelegate where V: Hashable { | |
let stack: [V] | |
let content: (V) -> NavigationDestinationView | |
let onStackChanged: ([V]) -> Void | |
init(stack: [V], onStackChanged: @escaping ([V]) -> Void, content: @escaping (V) -> NavigationDestinationView) { | |
self.stack = stack | |
self.content = content | |
self.onStackChanged = onStackChanged | |
} | |
func makeUIViewController(context: UIViewControllerRepresentableContext<StateDrivenNavigationController>) -> UINavigationController { | |
let vc = UINavigationController() | |
vc.navigationBar.prefersLargeTitles = true | |
return vc | |
} | |
func updateUIViewController(_ uiViewController: UINavigationController, context: UIViewControllerRepresentableContext<StateDrivenNavigationController>) { | |
uiViewController.delegate = context.coordinator | |
context.coordinator.update(navigationController: uiViewController, stack: stack, content: content, onStackChanged: self.onStackChanged, context: context) | |
} | |
func makeCoordinator() -> Coordinator { | |
return Coordinator() | |
} | |
} | |
extension StateDrivenNavigationController { | |
class Coordinator: NSObject, UINavigationControllerDelegate { | |
private var hostingControllers: [V: Weak<UIHostingController<AnyView>>] = [:] | |
var onStackChanged: (([V]) -> Void)? = nil | |
func update(navigationController: UINavigationController, stack: [V], content: (V) -> NavigationDestinationView, onStackChanged: @escaping ([V]) -> Void, context: UIViewControllerRepresentableContext<StateDrivenNavigationController>) { | |
self.onStackChanged = onStackChanged | |
let newViewControllers = stack.map { e -> UIViewController in | |
let destination = content(e) | |
if let existingViewController = hostingControllers[e]?.value { | |
existingViewController.rootView = destination.view | |
return existingViewController | |
} else { | |
let vc = UIHostingController(rootView: destination.view) | |
hostingControllers[e] = Weak(value: vc) | |
// make sure the title is set before the animation starts | |
// the value from .navigationBarTitle hasn't been set on the | |
// vc.navigationItem yet (it will later) so we have to use our own | |
if stack.last == e { | |
if let title = destination.title { | |
vc.navigationItem.title = title | |
} | |
if let titleDisplayMode = destination.displayMode { | |
switch titleDisplayMode { | |
case .inline: vc.navigationItem.largeTitleDisplayMode = .never | |
case .large: vc.navigationItem.largeTitleDisplayMode = .always | |
case .automatic: vc.navigationItem.largeTitleDisplayMode = .automatic | |
@unknown default: break | |
} | |
} | |
} | |
return vc | |
} | |
} | |
if newViewControllers != navigationController.viewControllers { | |
navigationController.setViewControllers(newViewControllers, animated: !context.transaction.disablesAnimations) | |
} | |
} | |
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { | |
let hashableStack = navigationController.viewControllers.compactMap { vc in | |
hostingControllers.first(where: { $1.value == vc })?.key | |
} | |
onStackChanged?(hashableStack) | |
// fixme/todo: cancelling an interactive back navigation | |
} | |
} | |
class Weak<Value> where Value: AnyObject { | |
weak var value: Value? | |
init(value: Value) { | |
self.value = value | |
} | |
} | |
} | |
// We need a tiny wrapper around View because we need early access to the title | |
// and display mode (.navigationBarTitle values are set too late on the vc.navigaitonItem) | |
// and we need a hashable id for solution step 2. | |
struct NavigationDestinationView { | |
let id: AnyHashable | |
let view: AnyView | |
let title: String? | |
let displayMode: NavigationBarItem.TitleDisplayMode? | |
} | |
// Example for step 1 (uncomment this example and comment the one below to see this intermediate step) | |
//struct ContentView: View { | |
// | |
// @State var tab = 0 | |
// @State var selections: [Int: Int] = [0:0] | |
// | |
// var body: some View { | |
// ZStack { | |
// TabView { | |
// StateDrivenNavigationController(stack: selections.keys.sorted(), onStackChanged: { stack in | |
// self.selections = self.selections.filter { stack.contains($0.key) } | |
// }) { n in | |
// NavigationDestinationView( | |
// id: "", | |
// view: AnyView(PageView(n: n, selections: self.$selections)), | |
// title: "Page \(n)", | |
// displayMode: .automatic | |
// ) | |
// } | |
// .tabItem { Text("Pages") } | |
// .tag(0) | |
// | |
// Text("Second tab") | |
// .tabItem { Text("Second tab") } | |
// .tag(1) | |
// } | |
// | |
// VStack { | |
// Text("Selections: \(selections.description)") | |
// HStack { | |
// Button(action: { | |
// if let last = self.selections.keys.sorted().last { | |
// self.selections.removeValue(forKey: last) | |
// } | |
// }) { | |
// Text("Pop") | |
// } | |
// | |
// Button(action: { | |
// if let last = self.selections.keys.sorted().last { | |
// self.selections[last+1] = Int.random(in: 0..<10) | |
// } else { | |
// self.selections[0] = 0 | |
// } | |
// }) { | |
// Text("Push") | |
// } | |
// } | |
// } | |
// .padding(8) | |
// .background(Color(UIColor.systemGray3).cornerRadius(8)) | |
// .frame(maxHeight: .infinity, alignment: .bottom) | |
// .padding(.bottom, 100) | |
// } | |
// } | |
//} | |
// | |
//struct PageView: View { | |
// | |
// let n: Int | |
// var selections: Binding<[Int: Int]> | |
// | |
// var sel: Binding<Int?> { | |
// Binding(get: { | |
// return self.selections.wrappedValue[self.n+1] | |
// }, set: { | |
// self.selections.wrappedValue[self.n+1] = $0 | |
// }) | |
// } | |
// | |
// var body: some View { | |
// List(0..<10) { i in | |
// Button(action: { | |
// self.sel.wrappedValue = i | |
// }) { | |
// HStack { | |
// Text("\(i)") | |
// Spacer() | |
// Image(systemName: "chevron.right").font(Font.body.weight(.semibold)).foregroundColor(Color(UIColor.systemFill)) | |
// } | |
// } | |
// } | |
// .navigationBarTitle("Page \(n)") | |
// } | |
//} | |
// The limitation of the solution we've arrived at with step 1 is that it | |
// doesn't work well for situations where you want each view | |
// to be in control of pushing the next one (like NavigationLink) allows us. | |
// | |
// To address this, we can wrap the StateDrivenNavigationController in a view | |
// that listens to a custom preference of its children. When a child sets a | |
// value on that preference, a next view will be pushed. If the preference | |
// is set to nil, the pushed view will be popped again. | |
private let rootId = AnyHashable("ROOT") | |
struct StateDrivenNavigationView<Content>: View where Content: View { | |
let rootView: Content | |
// contains all except the root | |
@State var _stack: [NavigationStackItem] = [] | |
init(@ViewBuilder content: () -> Content) { | |
self.rootView = content() | |
} | |
func destination(for id: AnyHashable) -> NavigationDestinationView { | |
if id == rootId { | |
return NavigationDestinationView(id: rootId, view: AnyView(rootView), title: nil, displayMode: nil) | |
} else { | |
return _stack.first(where: { $0.id == id })?.destination | |
?? NavigationDestinationView(id: AnyHashable("empty"), view: AnyView(EmptyView()), title: nil, displayMode: nil) | |
} | |
} | |
var body: some View { | |
let stack = [NavigationStackItem(destination: NavigationDestinationView(id: rootId, view: AnyView(rootView), title: nil, displayMode: nil), onPop: { })] + _stack | |
let setEffectiveStack: ([NavigationStackItem]) -> Void = { | |
self._stack = Array($0.dropFirst()) | |
} | |
return StateDrivenNavigationController(stack: stack.map { $0.id }, onStackChanged: { newStack in | |
let common = zip(stack, newStack).prefix(while: { $0.id == $1}) | |
if common.count < stack.count { // at least one VC disappeared | |
stack[common.count].onPop() | |
} | |
}) { stackItemId -> NavigationDestinationView in | |
let destination = self.destination(for: stackItemId) | |
return NavigationDestinationView( | |
id: destination.id, | |
view: AnyView(destination.view | |
.onPreferenceChange(StateDrivenNavigationPushKey.self) { view in | |
if let parentIdx = stack.firstIndex(where: { $0.id == stackItemId }) { | |
if let view = view { // push | |
let identifiedView = NavigationStackItem( | |
destination: NavigationDestinationView( | |
id: "\(stackItemId).next:\(view.id)", | |
view: view.destination.view, | |
title: view.destination.title, | |
displayMode: view.destination.displayMode | |
), | |
onPop: view.onPop | |
) | |
setEffectiveStack(stack.prefix(through: parentIdx) + [identifiedView]) | |
} else if parentIdx < stack.count - 1 { // pop | |
setEffectiveStack(Array(stack.prefix(through: parentIdx))) | |
} | |
} | |
}), | |
title: destination.title, | |
displayMode: destination.displayMode | |
) | |
} | |
} | |
} | |
// Wraps the destination view and closure that is called when that view is be popped | |
// due to user interaction. The implementation of that closure should update the | |
// state accordingly. | |
struct NavigationStackItem: Equatable { | |
let destination: NavigationDestinationView | |
let onPop: () -> Void | |
var id: AnyHashable { destination.id } | |
static func == (lhs: NavigationStackItem, rhs: NavigationStackItem) -> Bool { | |
return lhs.id == rhs.id | |
} | |
} | |
// We use a custom preference to inform the navigation view of the next view to be pushed | |
struct StateDrivenNavigationPushKey: PreferenceKey { | |
static var defaultValue: NavigationStackItem? { nil } | |
static func reduce(value: inout NavigationStackItem?, nextValue: () -> NavigationStackItem?) { | |
value = nextValue() ?? value | |
} | |
} | |
extension View { | |
func stateDrivenNavigationPush(destination: NavigationDestinationView?, onPop: @escaping () -> Void) -> some View { | |
// We set the preference on a background view to prevent SwiftUI from overwriting values when | |
// `stateDrivenNavigationPush` is declared multiple times in a row. This forces SwiftUI to use the | |
// PreferenceKey's reduce function | |
if let destination = destination { | |
return self.background(EmptyView().preference( | |
key: StateDrivenNavigationPushKey.self, | |
value: NavigationStackItem( | |
destination: destination, | |
onPop: onPop | |
) | |
)) | |
} else { | |
return self.background(EmptyView().preference(key: StateDrivenNavigationPushKey.self, value: nil)) | |
} | |
} | |
} | |
// Example for step 2 | |
struct ContentView: View { | |
@State var tab = 0 | |
var body: some View { | |
ZStack { | |
TabView { | |
StateDrivenNavigationView { | |
PageView(n: 0) | |
} | |
.tabItem { Text("Pages") } | |
.tag(0) | |
Text("Second tab") | |
.tabItem { Text("Second tab") } | |
.tag(1) | |
} | |
} | |
} | |
} | |
struct PageView: View { | |
let n: Int | |
@State var selection: Int? = nil | |
var body: some View { | |
List(0..<10) { i in | |
Button(action: { | |
self.selection = i | |
}) { | |
HStack { | |
Text("\(i)") | |
Spacer() | |
Image(systemName: "chevron.right").font(Font.body.weight(.semibold)).foregroundColor(Color(UIColor.systemFill)) | |
} | |
} | |
} | |
.navigationBarTitle("Page \(n)") | |
.stateDrivenNavigationPush( | |
destination: selection.map { i in | |
NavigationDestinationView(id: n*100 + i, view: AnyView(PageView(n: i)), title: "Page \(i)", displayMode: .automatic) | |
}, onPop: { | |
self.selection = nil | |
} | |
) | |
} | |
} |
Oh really? I attempted to use a recursive NavigationLink style approach, which works unless you need to hide and unhide the navigation bar at will, which is still just so broken!
@gregcotten To force UIHostingController
to set up its navigation item we can pre-render it in a separate window:
let window = UIWindow(frame: .zero)
window.rootViewController = UINavigationController(rootViewController: hostingController)
window.isHidden = false
window.layoutIfNeeded()
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@gregcotten no, I have not. Improvements to NavigationView in SwiftUI 2 have made it possible to move away from this custom approach.