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

I stumbled across this when trying to figure out why the UIHostingController's navigationItem title (and other things) don't get set by .navigationTitle(...) etc. until AFTER the view has animated onto the UINavigationController's stack. Any chance you figured out how to get the UIHostingController to (render once?!) and propagate those view modifiers to the navigationItem before the view is shown?

@Thomvis
Copy link
Author

Thomvis commented Mar 11, 2021

@gregcotten no, I have not. Improvements to NavigationView in SwiftUI 2 have made it possible to move away from this custom approach.

@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