Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Support for NavigationStackStore backport for iOS 14 and 15 #2588

agarrharr started this conversation in Ideas
Discussion options

I would like to be able to use NavigationStackStore, but I have to support iOS 15. @Alex293 made a fork of TCA that integrates navigation-stack-backport and adds a NavigationStackStoreBP. This seems to work really well, however it requires some private APIs from TCA. I would like to start a discussion to see if we can get the necessary APIs exposed to be able to paste something like NavigationStackStore into my project and get stack-based navigation working with TCA in iOS 15.

I cloned TCA and opened up the SyncUps example app to find out what APIs would need to be made public for it to work.

  • I lowered the target to iOS 15.0
  • I added a dependency to navigation-stack-backport
  • I pasted in NavigationStackStoreBP
  • (I also had to comment out some other things that are iOS 16+ features)

Here are the things that I needed to make public in order to get into building order.

  • ViewStore
  • StackState._dictionary
  • areOrderedSetsDuplicates(_:_ :)
  • callAsFunction()
  • indent(by:)
  • invalidate(_)
  • runtimeWarn(_:category)
  • typeName(_)

It also uses stackElementID, but that is already marked as @_spi(Internals).

Now, I don't think all of these necessarily need to be made public, but this is what I had to do for now.

For the sake of completion, here is the NavigationStackStore:

@_spi(Internals) import ComposableArchitecture
import OrderedCollections
import SwiftUI
import NavigationStackBackport

/// A navigation stack that is driven by a store.
///
/// This view can be used to drive stack-based navigation in the Composable Architecture when passed
/// a store that is focused on ``StackState`` and ``StackAction``.
///
/// See the dedicated article on <doc:Navigation> for more information on the library's navigation
/// tools, and in particular see <doc:StackBasedNavigation> for information on using this view.
@available(iOS 14, *)
public struct NavigationStackStoreBP<State, Action, Root: View, Destination: View>: View {
    private let root: Root
    private let destination: (Component<State>) -> Destination
    @StateObject private var viewStore: ViewStore<StackState<State>, StackAction<State, Action>>

    /// Creates a navigation stack with a store of stack state and actions.
    ///
    /// - Parameters:
    ///   - path: A store of stack state and actions to power this stack.
    ///   - root: The view to display when the stack is empty.
    ///   - destination: A view builder that defines a view to display when an element is appended to
    ///     the stack's state. The closure takes one argument, which is a store of the value to
    ///     present.
    public init(
        _ store: Store<StackState<State>, StackAction<State, Action>>,
        @ViewBuilder root: () -> Root,
        @ViewBuilder destination: @escaping (_ store: Store<State, Action>) -> Destination
    ) {
        self.root = root()
        self.destination = { component in
            var state = component.element
            return destination(
                store
                    .invalidate { !$0.ids.contains(component.id) }
                    .scope(
                        state: {
                            state = $0[id: component.id] ?? state
                            return state
                        },
                        action: { .element(id: component.id, action: $0) }
                    )
            )
        }
        self._viewStore = StateObject(
            wrappedValue: ViewStore(
                store,
                observe: { $0 },
                removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) }
            )
        )
    }

    /// Creates a navigation stack with a store of stack state and actions.
    ///
    /// - Parameters:
    ///   - path: A store of stack state and actions to power this stack.
    ///   - root: The view to display when the stack is empty.
    ///   - destination: A view builder that defines a view to display when an element is appended to
    ///     the stack's state. The closure takes one argument, which is the initial enum state to
    ///     present. You can switch over this value and use ``CaseLet`` views to handle each case.
    @_disfavoredOverload
    public init<D: View>(
        _ store: Store<StackState<State>, StackAction<State, Action>>,
        @ViewBuilder root: () -> Root,
        @ViewBuilder destination: @escaping (_ initialState: State) -> D
    ) where Destination == SwitchStore<State, Action, D> {
        self.root = root()
        self.destination = { component in
            var state = component.element
            return SwitchStore(
                store
                    .invalidate { !$0.ids.contains(component.id) }
                    .scope(
                        state: {
                            state = $0[id: component.id] ?? state
                            return state
                        },
                        action: { .element(id: component.id, action: $0) }
                    )
            ) { _ in
                destination(component.element)
            }
        }
        self._viewStore = StateObject(
            wrappedValue: ViewStore(
                store,
                observe: { $0 },
                removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) }
            )
        )
    }

    public var body: some View {
        NavigationStackBackport.NavigationStack(
            path: self.viewStore.binding(
                get: { $0.path },
                send: { newPath in
                    if newPath.count > self.viewStore.path.count, let component = newPath.last {
                        return .push(id: component.id, state: component.element)
                    } else {
                        return .popFrom(id: self.viewStore.path[newPath.count].id)
                    }
                }
            )
        ) {
            self.root
                .environment(\.navigationDestinationType, State.self)
                .backport.navigationDestination(for: Component<State>.self) { component in
                    NavigationDestinationView(component: component, destination: self.destination)
                }
        }
    }
}

public struct _NavigationLinkStoreContentBackport<State, Label: View>: View {
    let state: State?
    @ViewBuilder let label: Label
    let fileID: StaticString
    let line: UInt
    @Environment(\.navigationDestinationType) var navigationDestinationType

    public var body: some View {
#if DEBUG
        self.label.onAppear {
            if self.navigationDestinationType != State.self {
                runtimeWarn(
            """
            A navigation link at "\(self.fileID):\(self.line)" is unpresentable. …

              NavigationStackStore element type:
                \(self.navigationDestinationType.map(typeName) ?? "(None found in view hierarchy)")
              NavigationLink state type:
                \(typeName(State.self))
              NavigationLink state value:
              \(String(customDumping: self.state).indent(by: 2))
            """
                )
            }
        }
#else
        self.label
#endif
    }
}

@available(iOS 14, *)
extension NavigationStackBackport.NavigationLink {
    /// Creates a navigation link that presents the view corresponding to an element of
    /// ``StackState``.
    ///
    /// When someone activates the navigation link that this initializer creates, SwiftUI looks for a
    /// parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements that
    /// matches the type of this initializer's `state` input.
    ///
    /// See SwiftUI's documentation for `NavigationLink.init(value:label:)` for more.
    ///
    /// - Parameters:
    ///   - state: An optional value to present. When the user selects the link, SwiftUI stores a copy
    ///     of the value. Pass a `nil` value to disable the link.
    ///   - label: A label that describes the view that this link presents.
    public init<P, L: View>(
        state: P?,
        @ViewBuilder label: () -> L,
        fileID: StaticString = #fileID,
        line: UInt = #line
    )
    where Label == _NavigationLinkStoreContentBackport<P, L> {
        @Dependency(\.stackElementID) var stackElementID
        self.init(value: state.map { Component(id: stackElementID(), element: $0) }) {
            _NavigationLinkStoreContentBackport<P, L>(
                state: state, label: { label() }, fileID: fileID, line: line
            )
        }
    }

    /// Creates a navigation link that presents the view corresponding to an element of
    /// ``StackState``, with a text label that the link generates from a localized string key.
    ///
    /// When someone activates the navigation link that this initializer creates, SwiftUI looks for a
    /// parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements that
    /// matches the type of this initializer's `state` input.
    ///
    /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more.
    ///
    /// - Parameters:
    ///   - titleKey: A localized string that describes the view that this link
    ///     presents.
    ///   - state: An optional value to present. When the user selects the link, SwiftUI stores a copy
    ///     of the value. Pass a `nil` value to disable the link.
    public init<P>(
        _ titleKey: LocalizedStringKey, state: P?, fileID: StaticString = #fileID, line: UInt = #line
    )
    where Label == _NavigationLinkStoreContentBackport<P, Text> {
        self.init(state: state, label: { Text(titleKey) }, fileID: fileID, line: line)
    }

    /// Creates a navigation link that presents the view corresponding to an element of
    /// ``StackState``, with a text label that the link generates from a title string.
    ///
    /// When someone activates the navigation link that this initializer creates, SwiftUI looks for a
    /// parent ``NavigationStackStore`` view with a store of ``StackState`` containing elements that
    /// matches the type of this initializer's `state` input.
    ///
    /// See SwiftUI's documentation for `NavigationLink.init(_:value:)` for more.
    ///
    /// - Parameters:
    ///   - title: A string that describes the view that this link presents.
    ///   - state: An optional value to present. When the user selects the link, SwiftUI stores a copy
    ///     of the value. Pass a `nil` value to disable the link.
    @_disfavoredOverload
    public init<S: StringProtocol, P>(
        _ title: S, state: P?, fileID: StaticString = #fileID, line: UInt = #line
    )
    where Label == _NavigationLinkStoreContentBackport<P, Text> {
        self.init(state: state, label: { Text(title) }, fileID: fileID, line: line)
    }
}

private struct NavigationDestinationView<State, Destination: View>: View {
    let component: Component<State>
    let destination: (Component<State>) -> Destination
    var body: some View {
        self.destination(self.component)
            .environment(\.navigationDestinationType, State.self)
            .id(self.component.id)
    }
}

private struct Component<Element>: Hashable {
    let id: StackElementID
    var element: Element

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(self.id)
    }
}

extension StackState {
    fileprivate var path: PathView {
        _read { yield PathView(base: self) }
        _modify {
            var path = PathView(base: self)
            yield &path
            self = path.base
        }
        set { self = newValue.base }
    }

    fileprivate struct PathView: MutableCollection, RandomAccessCollection,
                                 RangeReplaceableCollection
    {
        var base: StackState

        var startIndex: Int { self.base.startIndex }
        var endIndex: Int { self.base.endIndex }
        func index(after i: Int) -> Int { self.base.index(after: i) }
        func index(before i: Int) -> Int { self.base.index(before: i) }

        subscript(position: Int) -> Component<Element> {
            _read {
                yield Component(id: self.base.ids[position], element: self.base[position])
            }
            _modify {
                let id = self.base.ids[position]
                var component = Component(id: id, element: self.base[position])
                yield &component
                self.base[id: id] = component.element
            }
            set {
                self.base[id: newValue.id] = newValue.element
            }
        }

        init(base: StackState) {
            self.base = base
        }

        init() {
            self.init(base: StackState())
        }

        mutating func replaceSubrange<C: Collection>(
            _ subrange: Range<Int>, with newElements: C
        ) where C.Element == Component<Element> {
            for id in self.base.ids[subrange] {
                self.base[id: id] = nil
            }
            for component in newElements.reversed() {
                self.base._dictionary
                    .updateValue(component.element, forKey: component.id, insertingAt: subrange.lowerBound)
            }
        }
    }
}

private struct NavigationDestinationTypeKey: EnvironmentKey {
    static var defaultValue: Any.Type? { nil }
}

extension EnvironmentValues {
    fileprivate var navigationDestinationType: Any.Type? {
        get { self[NavigationDestinationTypeKey.self] }
        set { self[NavigationDestinationTypeKey.self] = newValue }
    }
}
You must be logged in to vote

Replies: 2 comments · 18 replies

Comment options

Actually, with the 1.6 observation beta, I don't think this will be necessary, because you no longer need to use NavigationStackStore and instead can use the vanilla SwiftUI NavigationStack. That means I can use NavigationStackBackport from https://github.com/lm/navigation-stack-backport

You must be logged in to vote
4 replies
@Alex293
Comment options

That’s great news I hadn’t time to check the observation update but I can’t wait

@stephencelis
Comment options

The vanilla NavigationStack initializer is still an overload provided by the library for stack state, but we can simply use an initializer now.

You may want to check its implementation and make sure you can still build your own! If not we can try to provide the tools.

@agarrharr
Comment options

Ok, thanks. I'll look into it when I get some time.

@agarrharr
Comment options

If I paste your new initializer into my code I get a few errors (annotated in the code with comments):

  1. Referencing subscript 'subscript(dynamicMember:)' on 'Store' requires that 'StackState<State>' conform to 'ObservableState'
  2. Value of type 'Store<StackState<State>, StackAction<State, Action>>' has no dynamic member 'observableState' using key path from root type 'StackState<State>'
  3. 'element' is inaccessible due to 'internal' protection level
  4. 'id' is inaccessible due to 'internal' protection level
  5. '_NavigationDestinationViewModifier<State, Action, Destination>' initializer is inaccessible due to 'fileprivate' protection level
extension NavigationStack {
    public init<State, Action, Destination: View, R>(
        path: Binding<Store<StackState<State>, StackAction<State, Action>>>,
        root: () -> R,
        @ViewBuilder destination: @escaping (Store<State, Action>) -> Destination
    )
    where
    Data == StackState<State>.PathView,
    Root == ModifiedContent<R, _NavigationDestinationViewModifier<State, Action, Destination>>
    {
        self.init(
            path: Binding(
                get: { path.wrappedValue.observableState.path }, // Errors 1 and 2
                set: { pathView, transaction in
                    if pathView.count > path.wrappedValue.withState({ $0 }).count,
                       let component = pathView.last
                    {
                        path.transaction(transaction).wrappedValue.send(
                            .push(id: component.id, state: component.element) // Errors 3 and 4
                        )
                    } else {
                        path.transaction(transaction).wrappedValue.send(
                            .popFrom(id: path.wrappedValue.withState { $0 }.ids[pathView.count])
                        )
                    }
                }
            )
        ) {
            root()
                .modifier(
                    _NavigationDestinationViewModifier(store: path.wrappedValue, destination: destination) // Error 5
                )
        }
    }
}

I'm not sure how to fix this. I tried by copying some of the code that it was referencing, but that code had even more errors.

Once I can get this to compile, I can change it to use the backported version:

extension NavigationStackBackport.NavigationStack {
    ...
}
Comment options

I pulled TCA and was able to add the backport directly into the library. I imported https://github.com/lm/navigation-stack-backport in Package@swift-5.9.swift and in NavigationStack+Observation, I added:

@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
extension NavigationStackBackport.NavigationStack {
    /// Drives a navigation stack with a store.
    ///
    /// See the dedicated article on <doc:Navigation> for more information on the library's navigation
    /// tools, and in particular see <doc:StackBasedNavigation> for information on using this view.
    public init<State, Action, Destination: View, R>(
        path: Binding<Store<StackState<State>, StackAction<State, Action>>>,
        root: () -> R,
        @ViewBuilder destination: @escaping (Store<State, Action>) -> Destination
    )
    where
    Data == StackState<State>.PathView,
    Root == ModifiedContent<R, _NavigationDestinationViewModifierBackport<State, Action, Destination>>
    {
        self.init(
            path: Binding(
                get: { path.wrappedValue.currentState.path }, // <------- Errors 1 and 2
                set: { pathView, transaction in
                    if pathView.count > path.wrappedValue.withState({ $0 }).count,
                       let component = pathView.last
                    {
                        path.transaction(transaction).wrappedValue.send(
                            .push(id: component.id, state: component.element)  // <------- Errors 3 and 4
                        )
                    } else {
                        path.transaction(transaction).wrappedValue.send(
                            .popFrom(id: path.wrappedValue.withState { $0 }.ids[pathView.count])
                        )
                    }
                }
            )
        ) {
            root()
                .modifier(
                    _NavigationDestinationViewModifierBackport(store: path.wrappedValue, destination: destination)
                )
        }
    }
}

@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
public struct _NavigationDestinationViewModifierBackport<
    State: ObservableState, Action, Destination: View
>:
    ViewModifier
{
    @SwiftUI.State var store: Store<StackState<State>, StackAction<State, Action>>
    fileprivate let destination: (Store<State, Action>) -> Destination

    public func body(content: Content) -> some View {
        content
            .environment(\.navigationDestinationType, State.self)
            .backport.navigationDestination(for: StackState<State>.Component.self) { component in
                var element = component.element
                self
                    .destination(
                        self.store.scope(
                            id: self.store.id(state: \.[id:component.id], action: \.[id:component.id]),
                            state: ToState { // <------- Error 5
                                element = $0[id: component.id] ?? element
                                return element
                            },
                            action: { .element(id: component.id, action: $0) },
                            isInvalid: { !$0.ids.contains(component.id) }
                        )
                    )
                    .environment(\.navigationDestinationType, State.self)
            }
    }
}

Of course, if I try to paste that into my own project I get several errors

  1. Referencing subscript 'subscript(dynamicMember:)' on 'Store' requires that 'StackState' conform to 'ObservableState'
  2. Value of type 'Store<StackState, StackAction<State, Action>>' has no dynamic member 'observableState' using key path from root type 'StackState'
  3. 'element' is inaccessible due to 'internal' protection level
  4. 'id' is inaccessible due to 'internal' protection level
  5. Cannot find 'ToState' in scope
You must be logged in to vote
14 replies
@Alex293
Comment options

With the library + the PR linked you can use TCA tools on iOS 15 as if you were using SwiftUI native tools. Now for a library built on top like TCACoordinators I think you would need to fork it and make changes to replace SwiftUI tools by the backport ones.

@aba-bakri
Comment options

This PR #3657 right ? I should add this code in some File ? or somewhere special ?

@Alex293
Comment options

It looks like :

var body: some View {
    WithPerceptionTracking {
        NavigationStackBackport.NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
            RootScreen()
         } destination: { stepStore in
            WithPerceptionTracking {
                switch stepStore.case {
                case let .a(store):
                    ScreenA(store: store)
                case let .b(store):
                    ScreenB(store: store)
                case let .c(store):
                    ScreenC(store: store)
                }
            }
        }
    }
}
@Alex293
Comment options

If you want to do the same thing you need to fork TCA like #3657 then in you project add https://github.com/lm/navigation-stack-backport and then write the init like:

import SwiftUI
import NavigationStackBackport
@_spi(Internals) import ComposableArchitecture

@available(iOS 15, *)
extension NavigationStackBackport.NavigationStack {
    public init<State, Action, Destination: View, R>(
      path: Binding<Store<StackState<State>, StackAction<State, Action>>>,
      @ViewBuilder root: () -> R,
      @ViewBuilder destination: @escaping (Store<State, Action>) -> Destination,
      fileID: StaticString = #fileID,
      filePath: StaticString = #filePath,
      line: UInt = #line,
      column: UInt = #column
    )
    where
      Data == StackState<State>.PathView,
      Root == ModifiedContent<R, _NavigationDestinationViewModifier2<State, Action, Destination>>
    {
      self.init(
        path: path[
          fileID: _HashableStaticString(rawValue: fileID),
          filePath: _HashableStaticString(rawValue: filePath),
          line: line,
          column: column
        ]
      ) {
        root()
          .modifier(
            _NavigationDestinationViewModifier2(
              store: path.wrappedValue,
              destination: destination,
              fileID: fileID,
              filePath: filePath,
              line: line,
              column: column
            )
          )
      }
    }
}

public struct _NavigationDestinationViewModifier2<
  State: ObservableState, Action, Destination: View
>:
  ViewModifier
{
  @SwiftUI.State var store: Store<StackState<State>, StackAction<State, Action>>
  fileprivate let destination: (Store<State, Action>) -> Destination
  fileprivate let fileID: StaticString
  fileprivate let filePath: StaticString
  fileprivate let line: UInt
  fileprivate let column: UInt

  public func body(content: Content) -> some View {
    content
      .environment(\.navigationDestinationType, State.self)
      .backport.navigationDestination(for: StackState<State>.Component.self) { component in
          destination(store.scope(component: component))
          .environment(\.navigationDestinationType, State.self)
      }
  }
}
@Alex293
Comment options

Please note that this is a quick draft, you would need to pass down fileID & co down too. Depending on your needs you would need to change the availability. The modifier don't need to be public too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
💡
Ideas
Labels
None yet
5 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.