Skip to content

Instantly share code, notes, and snippets.

@Thomvis
Last active December 18, 2023 02:33
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Thomvis/acd3c398eb2be1f30576835b8c1383b4 to your computer and use it in GitHub Desktop.
Save Thomvis/acd3c398eb2be1f30576835b8c1383b4 to your computer and use it in GitHub Desktop.
//
// 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
}
)
}
}
@gregcotten
Copy link

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!

@tarbayev
Copy link

tarbayev commented Feb 7, 2022

@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()

https://stackoverflow.com/a/71019728/4012494

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment