Skip to content

Instantly share code, notes, and snippets.

@andreyz
Forked from tgrapperon/LoadAndNavigateStyle.swift
Created November 22, 2022 22:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save andreyz/5c84c78bd3471b6f41904c5d2b55add9 to your computer and use it in GitHub Desktop.
Save andreyz/5c84c78bd3471b6f41904c5d2b55add9 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 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