Instantly share code, notes, and snippets.
Last active
January 24, 2025 08:57
-
Star
5
(5)
You must be signed in to star a gist -
Fork
1
(1)
You must be signed in to fork a gist
-
Save tgrapperon/430f33023f82cf0d9580e67fd98e1bcc to your computer and use it in GitHub Desktop.
This gist demonstrates how to achieve a "Load and Navigate" navigation style using `NavigationStack` on iOS 16.
This file contains 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
// This file is self contained and can be copy/pasted in place of the `ContentView.swift` in a default iOS 16/macOS 13 app. | |
import SwiftUI | |
struct ContentView: View { | |
@State var path: NavigationPath = .init() | |
@State var isLoading1: Bool = false | |
@State var isLoading2: Bool = false | |
@State var isLoading3: Bool = false | |
var body: some View { | |
// Binding the `$path` is only required for the example #1, as we append to it manually. Other examples can work with implicit `path` on iOS. | |
NavigationStack(path: $path) { | |
List { | |
// #1 - Simple `.listNavigation` use. Would work with a simple `.navigationDestination. | |
NavigationLink(value: 4) { | |
Button { | |
Task { | |
guard !isLoading1 else { return } | |
self.isLoading1 = true | |
defer { self.isLoading1 = false } | |
try await Task.sleep(for: .milliseconds(1000)) | |
self.path.append(4) | |
} | |
} label: { | |
LabeledContent("Load and Navigate to 4") { | |
if self.isLoading1 { | |
ProgressView() | |
} | |
} | |
} | |
.buttonStyle(.listNavigation) | |
} | |
// #2 - Deferred navigation. Will use `.deferredNavigationDestination. | |
// Note that we don't have to provide the destination value like for the `NavigationLink` | |
// I'm using a random value to exerce this. | |
DeferredNavigationLink(for: Int.self) { // (for: T.Type) because Swift can't infer from the closure | |
self.isLoading2 = true | |
defer { self.isLoading2 = false } | |
try await Task.sleep(for: .milliseconds(1000)) | |
return Int.random(in: 5..<100) | |
} label: { | |
LabeledContent("Load and Navigate to a random number >= 5") { | |
if self.isLoading2 { | |
ProgressView() | |
} | |
} | |
} | |
DeferredNavigationLink("Load an navigate to 100", for: Int.self) { | |
self.isLoading3 = true | |
defer { self.isLoading3 = false } | |
try await Task.sleep(for: .milliseconds(1000)) | |
return 100 | |
} | |
} | |
.toolbar { | |
if self.isLoading3 { | |
ToolbarItem(placement: .navigationBarTrailing) { | |
ProgressView() | |
} | |
} | |
} | |
.navigationTitle("Load & Navigate") | |
// This registers both navigations for `Int`'s, from `NavigationLink` or `DeferredNavigationLink`. | |
.deferredNavigationDestination(for: Int.self) { int in | |
Text("\(int)") | |
} | |
// Not required in Lists on iOS | |
// .navigationPath($path) | |
} | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} | |
// --- End of example --- | |
extension PrimitiveButtonStyle where Self == _ListNavigationButtonStyle { | |
/// A button style suited for "Load then navigate" navigation style. | |
/// | |
/// You typically assign this style to a `Button` positioned as the `Label` of a `NavigationLink` | |
/// When a `Button` is styled with `.listNavigation`, the row will highlight on press, but | |
/// releasing the button will perform the action and not trigger the `NavigationLink` as it would | |
/// happen for an unstyled `Button`. | |
public static var listNavigation: some PrimitiveButtonStyle { _ListNavigationButtonStyle() } | |
} | |
public struct _ListNavigationButtonStyle: PrimitiveButtonStyle { | |
// TODO: Check with accessibility and focusing | |
public func makeBody(configuration: Configuration) -> some View { | |
configuration.label | |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) | |
.contentShape(Rectangle()) | |
.onTapGesture { | |
configuration.trigger() | |
} | |
.onLongPressGesture(minimumDuration: 0, maximumDistance: .infinity) { | |
// This prevents non-tap (or failed tap) gestures to activate the parent `NavigationLink`. | |
} | |
} | |
} | |
extension View { | |
/// Declare a navigation destination for a value generated from a ``DeferredNavigationLink`` or | |
/// a `NavigationLink`. | |
public func deferredNavigationDestination<Value: Hashable, Destination: View>( | |
for: Value.Type, | |
@ViewBuilder destination: @escaping (Value) -> Destination | |
) -> some View { | |
// We register both cases, sync and async. | |
self | |
.navigationDestination(for: Value.self, destination: destination) | |
.modifier( | |
DeferredNavigationDestinationModifier<Value, Destination>(destination: destination) | |
) | |
} | |
} | |
// Internal `navigationDestination` wrapper. Mostly to hide `Deferred<Value>` | |
struct DeferredNavigationDestinationModifier<Value: Hashable, Destination: View>: ViewModifier { | |
let destination: (Value) -> Destination | |
func body(content: Content) -> some View { | |
content | |
.navigationDestination(for: Deferred<Value>.self) { deferred in | |
if let value = deferred.wrappedValue { | |
destination(value) | |
} | |
} | |
} | |
} | |
// Internally used to provide a placeholder to `NavigationLink(value:…)`. The property wrapping is | |
// not used yet. | |
@propertyWrapper | |
struct Deferred<Value: Hashable>: Hashable { | |
init() {} | |
var wrappedValue: Value? | |
var projectedValue: Self { self } | |
} | |
// An internal abstraction for a navigation path sent through the environment | |
struct CurrentNavigationPath: EnvironmentKey { | |
static var defaultValue: CurrentNavigationPath? { nil } | |
var _append: (any Hashable) -> Void = { _ in () } | |
init() {} | |
init(path: Binding<NavigationPath>) { | |
self._append = { path.wrappedValue.append($0) } | |
} | |
init<Collection: RangeReplaceableCollection>(path: Binding<Collection>) { | |
self._append = { | |
guard let element = $0 as? Collection.Element | |
else { return } // TODO: Print message? | |
path.wrappedValue.append(element) | |
} | |
} | |
func append<Value: Hashable>(_ value: Value) { | |
_append(value) | |
} | |
} | |
extension EnvironmentValues { | |
var currentNavigationPath: CurrentNavigationPath? { | |
get { self[CurrentNavigationPath.self] } | |
set { self[CurrentNavigationPath.self] = newValue } | |
} | |
} | |
/// A view that controls a navigation presentation using an async value | |
public struct DeferredNavigationLink<P: Hashable, Label: View>: View { | |
let action: () async throws -> P | |
let label: Label | |
@State private var value: Deferred<P> = .init() | |
@State private var onTapRequest: Int = 0 | |
@Environment(\.currentNavigationPath) var currentNavigationPath | |
/// Creates a navigation link that presents the view corresponding to a value that is generated | |
/// asynchronously | |
/// - Parameters: | |
/// - for: The type of the `P` value that is generated. | |
/// - action: An asynchronous closure that returns a `P` value. | |
/// - label: A label that describes the view that this link presents. | |
public init( | |
for: P.Type = P.self, | |
action: @escaping () async throws -> P, | |
@ViewBuilder label: () -> Label | |
) { | |
self.action = action | |
self.label = label() | |
} | |
/// Creates a navigation link that presents the view corresponding to a value that is generated | |
/// asynchronously | |
/// - Parameters: | |
/// - titleKey: A localized string that describes the view that this link presents. | |
/// - for: The type of the `P` value that is generated. | |
/// - action: An asynchronous closure that returns a `P` value. | |
public init( | |
_ titleKey: LocalizedStringKey, | |
for: P.Type = P.self, | |
action: @escaping () async throws -> P | |
) where Label == Text { | |
self.action = action | |
self.label = Text(titleKey) | |
} | |
/// Creates a navigation link that presents the view corresponding to a value that is generated | |
/// asynchronously | |
/// - Parameters: | |
/// - title: A string that describes the view that this link presents. | |
/// - for: The type of the `P` value that is generated. | |
/// - action: An asynchronous closure that returns a `P` value. | |
@_disfavoredOverload | |
public init<S: StringProtocol>( | |
_ title: S, | |
for: P.Type = P.self, | |
action: @escaping () async throws -> P | |
) where Label == Text { | |
self.action = action | |
self.label = Text(title) | |
} | |
public var body: some View { | |
NavigationLink(value: value) { | |
Button { | |
Task { | |
self.value.wrappedValue = try await action() | |
if let path = self.currentNavigationPath { | |
path.append(self.value.wrappedValue!) | |
} else { | |
self.onTapRequest += 1 | |
} | |
} | |
} label: { | |
label | |
} | |
.buttonStyle(.listNavigation) | |
.background { | |
CollectionViewInteractor(onTapRequest: onTapRequest) | |
} | |
} | |
} | |
struct CollectionViewInteractor: UIViewRepresentable { | |
let onTapRequest: Int | |
func makeUIView(context: Context) -> CollectionViewFinder { | |
CollectionViewFinder() | |
} | |
func updateUIView(_ uiView: CollectionViewFinder, context: Context) { | |
uiView.onTapRequest = onTapRequest | |
} | |
final class CollectionViewFinder: UIView { | |
var onTapRequest: Int = 0 { | |
didSet { | |
if onTapRequest != oldValue { | |
simulateCollectionViewCellTap() | |
} | |
} | |
} | |
@discardableResult | |
func simulateCollectionViewCellTap() -> Bool { | |
guard | |
let cell = self.collectionViewCell(from: self), | |
let collectionView = self.collectionView(from: cell), | |
let indexPath = collectionView.indexPath(for: cell) | |
else { return false } | |
collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath) | |
return true | |
} | |
func collectionViewCell(from view: UIView?) -> UICollectionViewCell? { | |
(view as? UICollectionViewCell) ?? self.collectionViewCell(from: view?.superview) | |
} | |
func collectionView(from view: UIView?) -> UICollectionView? { | |
(view as? UICollectionView) ?? self.collectionView(from: view?.superview) | |
} | |
} | |
} | |
} | |
// Optional on iOS, required on other platforms for now. | |
extension View { | |
/// Injects a navigation path through the environment, so children can append values to it to | |
/// navigate programmatically. | |
/// - Parameter path: A binding to a `NavigationPath` | |
public func navigationPath(_ path: Binding<NavigationPath>) -> some View { | |
self.environment(\.currentNavigationPath, .init(path: path)) | |
} | |
/// Injects a navigation path through the environment, so children can append values to it to | |
/// navigate programmatically. | |
/// - Parameter path: A binding to a collection that is bound as the path of a `NavigationStack` | |
/// or `NavigationSplitView`. | |
public func navigationPath<Data: RangeReplaceableCollection>(_ path: Binding<Data>) | |
-> some View | |
{ | |
self.environment(\.currentNavigationPath, .init(path: path)) | |
} | |
} | |
// MIT License | |
// | |
// Copyright (c) 2022 Thomas Grapperon | |
// | |
// Permission is hereby granted, free of charge, to any person obtaining a copy | |
// of this software and associated documentation files (the "Software"), to deal | |
// in the Software without restriction, including without limitation the rights | |
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
// copies of the Software, and to permit persons to whom the Software is | |
// furnished to do so, subject to the following conditions: | |
// | |
// The above copyright notice and this permission notice shall be included in all | |
// copies or substantial portions of the Software. | |
// | |
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thank you @tgrapperon. I found this very helpful. I will note that on Xcode 16.1 in iOS 18.1 when testing the 2nd and 3rd entries (the
DeferredNavigationLink
format) I had to uncomment line 73 (.navigationPath($path)
). Otherwise, the first tap would always navigate to an empty screen.