Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
import SwiftUI
/// Edit: I completely refactored the code. The previous implemenation is accessible through
/// revisions. Now:
/// ```
/// let path = NavigationPath()
/// let inspectable: NavigationPath.Inspectable = path.inspectable //or
/// let typedInspectable: NavigationPath.Inspectable.Of<Component>
/// = path.inspectable(of: Component.self)
/// ```
/// Both types are random-access and range replaceable collections. The first one of Any?, the
/// second of `Component`.
/// Both expose a `.navigationPath` property, so it is easy to construct a Binding like
/// ```
/// @State var path = NavigationPath().inspectable
/// NavigationStack(path: $path.navigationPath) { … }
/// ```
/// All of the following is enabled by our capacity to extract the last path component. It is maybe
/// possible to defer this function to SwiftUI by observing what's popping up in
/// `.navigationDestination`, but it is unlikely that we'll be able to make this work without
/// glitches/side-effects.
/// So for now, we are restricted to `NavigationPath`with `Codable` components only.
///
/// As an aside, I find very interesting that once you have `var count: Int`, `var last: Element?`,
/// `append(Element)` and `removeLast()` operations on a set of elements, you can turn it into a
/// mutable random access collection.
// MARK: - Common Helpers -
extension NavigationPath { // RandomAccessCollection-like
var _startIndex: Int { 0 }
var _endIndex: Int { count }
/// We opt in for throwing functions instead of subscripts. This also makes room for an
/// hypothetical `inout` cache argument.
func get(at position: Int) throws -> Any {
var copy = self
copy.removeLast(count - (position + 1))
return try copy.lastComponent!
}
mutating func set(_ newValue: Any, at position: Int) throws {
// Auto-register the mangled type name
registerValueForNavigationPathComponent(newValue)
// We preserve the tail (position+1)...
var tail = [Any]()
while count > position + 1 {
// Because `lastComponent == nil <=> isEmpty`, we can force-unwrap:
tail.append(try lastComponent!)
removeLast()
}
// Discard the one that will be replaced:
if !isEmpty {
removeLast()
}
// Double parenthesis are required by the current version of Swift
// See https://github.com/apple/swift/issues/59985
append((newValue as! any (Hashable & Codable)))
// Restore the tail that was preserved:
for preserved in tail.reversed() {
append((preserved as! any (Hashable & Codable)))
}
}
}
extension NavigationPath { // RangeReplaceableCollection+MutableCollection-like
mutating func _replaceSubrange<C>(_ subrange: Range<Int>, with newElements: C) throws
where C : Collection, Any == C.Element {
// Auto-register the mangled type name
if let first = newElements.first {
registerValueForNavigationPathComponent(first)
}
// We apply the same trick than for the index setter.
var tail = [Any]()
while count > subrange.upperBound {
tail.append(try lastComponent!)
removeLast()
}
// We don't need to preserve this part which will be replaced:
while count > subrange.lowerBound {
removeLast()
}
// Insert the new elements:
for newValue in newElements {
append((newValue as! any (Hashable & Codable)))
}
// Restore the preserved tail:
for preserved in tail.reversed() {
append((preserved as! any (Hashable & Codable)))
}
}
}
extension NavigationPath {
public struct Inspectable: RandomAccessCollection, RangeReplaceableCollection, MutableCollection {
public var navigationPath: NavigationPath
public init(_ navigationPath: NavigationPath) {
self.navigationPath = navigationPath
}
public init() {
self.navigationPath = .init()
}
public var startIndex: Int { navigationPath._startIndex }
public var endIndex: Int { navigationPath._endIndex }
public subscript(position: Int) -> Any {
get {
do {
return try navigationPath.get(at: position)
} catch {
NavigationPath.printExtractionError(error)
}
}
set {
do {
try navigationPath.set(newValue, at: position)
} catch {
NavigationPath.printExtractionError(error)
}
}
}
public mutating func replaceSubrange<C>(_ subrange: Range<Int>, with newElements: C)
where C : Collection, Any == C.Element {
do {
try navigationPath._replaceSubrange(subrange, with: newElements)
} catch {
NavigationPath.printExtractionError(error)
}
}
/// A throwing version of `last`
public var lastComponent: Any? {
get throws { try navigationPath.lastComponent }
}
}
}
extension NavigationPath {
/// Generates an inspectable representation of the current path.
public var inspectable: Inspectable { .init(self) }
}
extension NavigationPath.Inspectable {
public struct Of<Component>: RandomAccessCollection, RangeReplaceableCollection, MutableCollection
where Component: Hashable, Component: Codable {
public var navigationPath: NavigationPath
public init(_ navigationPath: NavigationPath) {
registerTypeForNavigationPathComponent(Component.self)
self.navigationPath = navigationPath
}
public init() {
registerTypeForNavigationPathComponent(Component.self)
self.navigationPath = .init()
}
public var startIndex: Int { navigationPath._startIndex }
public var endIndex: Int { navigationPath._endIndex }
public subscript(position: Int) -> Component {
get {
do {
return try navigationPath.get(at: position) as! Component
} catch {
NavigationPath.printExtractionError(error)
}
}
set {
do {
try navigationPath.set(newValue, at: position)
} catch {
NavigationPath.printExtractionError(error)
}
}
}
public mutating func replaceSubrange<C>(_ subrange: Range<Int>, with newElements: C)
where C : Collection, Component == C.Element {
do {
try navigationPath._replaceSubrange(subrange, with: newElements.map{ $0 as Any })
} catch {
NavigationPath.printExtractionError(error)
}
}
/// A throwing version of `last`
public var lastComponent: Component? {
get throws { try navigationPath.lastComponent as? Component }
}
}
}
extension NavigationPath {
/// Generates a typed inspectable representation of the current path.
public func inspectable<Component>(of type: Component.Type)
-> NavigationPath.Inspectable.Of<Component> {
.init(self)
}
}
// MARK: - Utilities
extension NavigationPath {
public enum Error: Swift.Error {
case nonInspectablePath
case unableToFindMangledName(String)
}
/// This is not super efficient, but at least always in sync.
var lastComponent: Any? {
get throws {
guard !isEmpty else { return nil }
guard let codable else {
throw Error.nonInspectablePath
}
return try JSONDecoder()
.decode(_LastElementDecoder.self, from: JSONEncoder().encode(codable)).value
}
}
static func printExtractionError(_ error: Swift.Error) -> Never {
fatalError("Failed to extract `NavigationPath component: \(error)")
}
/// We use this type to decode the two first encoded components.
private struct _LastElementDecoder: Decodable {
var value: Any
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
let typeName = try container.decode(String.self)
typesRegisterLock.lock()
let mangledTypeName = typeNameToMangled[typeName, default: typeName]
typesRegisterLock.unlock()
guard let type = _typeByName(mangledTypeName) as? (any Decodable.Type)
else {
typesRegisterLock.lock()
defer { typesRegisterLock.unlock() }
if typeNameToMangled[typeName] == nil {
throw Error.unableToFindMangledName(typeName)
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "\(typeName) is not decodable."
)
}
let encodedValue = try container.decode(String.self)
self.value = try JSONDecoder().decode(type, from: Data(encodedValue.utf8))
}
}
}
/// `NavigationPath` codable representation is using `_typeName` instead of mangled names, likely
/// because it is intented to be serialized. But we need mangled names to respawn types using
/// `_typeByName`.
/// I don't know a way to find the mangled name from the type name. If one could generate a list
/// of mangled symbols, we can probably lookup. In the meantime, clients of `Inspectable` should
/// register types they intend to use as path components. This step is realized automatically for
/// `NavigationPath.Inspectable.Of<Component>`, and also automatically when editing the
/// `NavigationPath` using the inspector, but it needs to be performed manually if some
/// `NavigationPath` is deserialized.
///
/// In other words, registering is only required when deserializing an heterogenous
/// `NavigationPath` or an homogenous one with untyped inspection.
/// Register a type for inspection
public func registerTypeForNavigationPathComponent<T>(_ type: T.Type) {
typesRegisterLock.lock()
typeNameToMangled[_typeName(T.self)] = _mangledTypeName(T.self)
typesRegisterLock.unlock()
}
// Register a type for inspection from any value of it
public func registerValueForNavigationPathComponent(_ value: Any) {
let type = type(of: value)
typesRegisterLock.lock()
typeNameToMangled[_typeName(type)] = _mangledTypeName(type)
typesRegisterLock.unlock()
}
private let typesRegisterLock = NSRecursiveLock()
private var typeNameToMangled = [String: String]()
// MARK: - Tests
func runPseudoTests() {
do {
// Check extracting the last component
let path = NavigationPath([0,1,2,3,4,5,6,7,8,9])
assert(path.inspectable.last as? Int == 9)
}
do {
// Check extracting the nth component
let path = NavigationPath([0,1,2,3,4,5,6,7,8,9])
assert(path.inspectable[4] as? Int == 4)
}
do {
// Check setting the nth component
var path = NavigationPath([0,1,2,3,4,5,6,7,8,9]).inspectable
path[4] = -1
let expected = NavigationPath([0,1,2,3,-1,5,6,7,8,9])
assert(path.navigationPath == expected)
}
do {
// Check joining two paths
let path = NavigationPath([0,1,2,3,4,5,6,7,8,9])
let p1 = NavigationPath([0,1,2,3,4])
let p2 = NavigationPath([5,6,7,8,9])
let joinedPath = (p1.inspectable + p2.inspectable).navigationPath
assert(path == joinedPath)
}
do {
// Check editing a path "in the belly".
var inspectable = NavigationPath([0,1,2,3,4,5,6,7,8,9]).inspectable
inspectable.replaceSubrange(3..<6, with: [-1, -2])
let expected = NavigationPath([0,1,2,-1,-2,6,7,8,9])
assert(expected == inspectable.navigationPath)
}
}
extension View {
// Use this method in place of `navigationDestination` to automatically
// register component types.
func inspectableNavigationDestination<D: Hashable, Content: View>(for value: D.Type, destination: @escaping (D) -> Content) -> some View {
registerTypeForNavigationPathComponent(D.self)
return self.navigationDestination(for: value, destination: destination)
}
}
// MARK: -
// Example: Navigation with two destination types and `NavigationPath`
// inpection and manipulation.
struct Destination: Hashable, Codable {
var id: Int
var title: String
}
struct AlternativeDestination: Hashable, Codable {
var id: Int
var title: String
}
struct ContentView: View {
@State var path = NavigationPath().inspectable // A `NavigationPath.Inspectable` value
@State var isModalPresented: Bool = false
var body: some View {
NavigationStack(path: $path.navigationPath) { // We can derive a "mapped" binding from @State
VStack {
Button {
path.append(
Destination(id: 2, title: "Screen #\(2)")
)
} label: {
Label("Navigate to next", systemImage: "arrow.forward")
}
Button {
let destinations = (2...5).map {
Destination(id: $0, title: "Screen #\($0)")
}
path.append(contentsOf: destinations)
} label: {
Label("Navigate to \"5\"", systemImage: "arrow.forward")
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("NavigationPath inspection")
.inspectableNavigationDestination(for: Destination.self) {
DestinationView(destination: $0, path: $path)
}
.inspectableNavigationDestination(for: AlternativeDestination.self) {
AlternativeDestinationView(destination: $0, path: $path)
}
}
.buttonStyle(.borderedProminent)
.safeAreaInset(edge: .bottom) {
lastComponentOverlay
}
.task {
runPseudoTests()
}
}
var lastComponentOverlay: some View {
// We observe the current last element of the path, extracted from the inspectable path
VStack(spacing: 8) {
Text("Last element of path")
.textCase(.uppercase)
.foregroundStyle(.secondary)
Text(path.last.map(String.init(describing:)) ?? "nil")
.font(.footnote.monospaced()).fontWeight(.semibold)
.frame(maxWidth: .infinity, alignment: .leading)
if !path.isEmpty {
Button {
isModalPresented = true
} label: {
Text("Show NavigationPath")
}
.buttonStyle(.bordered)
}
}
.font(.footnote)
.frame(maxWidth: .infinity)
.padding()
.background(
.ultraThinMaterial.shadow(.drop(radius: 6)),
in: RoundedRectangle(cornerRadius: 11))
.padding(.horizontal)
.animation(.spring(dampingFraction: 0.7), value: (path.last as? Destination)?.id)
.sheet(isPresented: $isModalPresented) {
if path.isEmpty {
VStack {
Text("The path is empty")
Button("Close") { isModalPresented = false }
}
.presentationDetents([.medium])
} else {
NavigationStack {
List {
ForEach(Array(zip(0..., path)), id: \.0) { index, value in
HStack {
Text("\(index)")
Text(String(describing: value))
}
}
.onDelete { offsets in
path.remove(atOffsets: offsets)
}
// This is glitchy in SwifUI Previews
.onMove { source, destination in
path.move(fromOffsets: source, toOffset: destination)
}
}
.safeAreaInset(edge: .bottom) {
if path.count > 1 {
Button {
// Not animating unfortunately, likely by design for deep-linking
withAnimation {
path.shuffle()
}
} label: {
Label("Shuffle", systemImage: "dice")
.frame(maxWidth: .infinity)
.frame(minHeight: 33)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle("NavigationPath")
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium, .large])
}
}
}
}
struct DestinationView: View {
var destination: Destination
@Binding var path: NavigationPath.Inspectable
var body: some View {
let nextDestination = Destination(
id: destination.id + 1,
title: "Screen #\(destination.id + 1)"
)
let nextAlternativeDestination = AlternativeDestination(
id: destination.id + 1,
title: "Alternative Screen #\(destination.id + 1)"
)
List {
NavigationLink("Navigate to \(destination.id + 1)",
value: nextDestination)
NavigationLink("Alternative destination \(destination.id + 1)",
value: nextAlternativeDestination)
}
.safeAreaInset(edge: .top) {
HStack {
Button {
path.append(nextDestination)
} label: {
Label("Navigate to \(destination.id + 1)", systemImage: "arrow.forward")
}
if path.count > 1 {
Button {
withAnimation {
path.shuffle()
}
} label: {
Label("Shuffle", systemImage: "dice")
}
}
}
}
.navigationTitle(destination.title)
}
}
struct AlternativeDestinationView: View {
var destination: AlternativeDestination
@Binding var path: NavigationPath.Inspectable
var body: some View {
let nextDestination = Destination(
id: destination.id + 1,
title: "Screen #\(destination.id + 1)"
)
let nextAlternativeDestination = AlternativeDestination(
id: destination.id + 1,
title: "Alternative Screen #\(destination.id + 1)"
)
List {
NavigationLink("Navigate to \(destination.id + 1)",
value: nextDestination)
NavigationLink("Alternative destination \(destination.id + 1)",
value: nextAlternativeDestination)
}
.scrollContentBackground(Color.yellow)
.safeAreaInset(edge: .top) {
HStack {
Button {
path.append(nextDestination)
} label: {
Label("Navigate to \(destination.id + 1)", systemImage: "arrow.forward")
}
if path.count > 1 {
Button {
withAnimation {
path.shuffle()
}
} label: {
Label("Shuffle", systemImage: "dice")
}
}
}
}
.navigationTitle(destination.title)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment