mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-24 12:14:25 +01:00
Navigation (#1945)
* wip
* fix
* wip
* wip
* move
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Fix
* wip
* wip
* Renamed action to onTap in NavigationLinkStore (#2043)
Renamed the `action` parameter to mirror other inits and differentiate itself from `action fromDestinationAction`
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Tie view identity to stack element identity
* Tie item identity to case
* wip
* wip
* cleanup
* fix
* fix
* Add warning to nav link
* wip
* wip
* Rename FullscreenCover.swift to FullScreenCover.swift (#2062)
* wip
* fix isDetailLink on non-iOS platforms
* Correct some comments in Effect.swift (#2081)
* add integration tests for showing alert/dialog from alert/dialog.
* copy StackElementIDGenerator dependency before running TestStore receive closure.
* Removed some unneeded delegate actions.
* wip
* clean up
* lots of clean up
* Converted voice memos back to identified array
* update deps
* update docs for DismissEffect
* wip
* Add Sendable conformance to PresentationState (#2086)
* wip
* swift-format
* wip
* wip
* docs
* wip
* wip
* Catch some typos in Articles (#2088)
* wip
* wip
* wip
* wip
* wip
* docs
* wip
* wip
* docs
* wip
* wip
* wip
* wip
* docs
* docs
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Fix invalid states count for 3 optionals and typos (#2094)
* wip
* wip
* more dismisseffect docs
* fixed some references
* navigation doc corrections
* more nav docs
* fix cancellation tests in release mode
* wrap some tests in #if DEBUG since they are testing expected failures
* update UUIDs in tests to use shorter initializer
* fixed a todo
* wip
* fix merge errors
* wip
* fix
* wip
* wip
* fixing a bunch of todos
* get rid of rawvalue in StackElementID
* more todos
* NavLinkStore docs
* fix swift 5.6 stuff
* fix some standups tests
* fix
* clean up
* docs fix
* fixes
* wip
* 5.6 fix
* wip
* wip
* dont parallelize tests
* updated demo readmes
* wip
* Use ObservedObject instead of StateObject for alert/dialog modifiers.
* integration tests for bad dismissal behavior
* check for runtime warnings in every integration test
* wip
* wip
* fix
* wip
* wip
* wip
* wip
* wip
* Drop a bunch of Hashables.
* some nav bug fixes
* wip
* wip
* wip
* fix
* fix
* wip
* wip
* Simplify recording test.
* add concurrent async test
* fix
* wip
* Refact how detail dismisses itself.
* fix
* 5.6 fix
* wip
* wip
* Add TestStore.assert.
* Revert "Add TestStore.assert."
This reverts commit a892cccc66.
* add Ukrainian Readme.md (#2121)
* Add TestStore.assert. (#2123)
* Add TestStore.assert.
* wip
* Update Sources/ComposableArchitecture/TestStore.swift
Co-authored-by: Stephen Celis <stephen@stephencelis.com>
* Update Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md
Co-authored-by: Stephen Celis <stephen@stephencelis.com>
* fix tests
---------
Co-authored-by: Stephen Celis <stephen@stephencelis.com>
* Run swift-format
* push for store.finish and presentation
* move docs around
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Add case subscripts
* wip
* wip
* 5.7-only
* wip
* wip
* wip
* wip
* revert store.finish task cancellation
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* add test for presentation scope
* wip
* wip
* wip
* wip
* wip
* cleanup
* updated presentation scope test
* sytnax update
* clean up
* fix test
* wip
* wip
* wip
* wip
* wip
---------
Co-authored-by: Brandon Williams <mbrandonw@hey.com>
Co-authored-by: Martin Václavík <mvaclavik96@icloud.com>
Co-authored-by: 유재호 <y73447jh@gmail.com>
Co-authored-by: Jackson Utsch <jutechs@gmail.com>
Co-authored-by: Dmytro <barabashdmyto@gmail.com>
Co-authored-by: Brandon Williams <135203+mbrandonw@users.noreply.github.com>
Co-authored-by: mbrandonw <mbrandonw@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
/// 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 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
public struct NavigationStackStore<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<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,
|
||||
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 (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,
|
||||
removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
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)
|
||||
.navigationDestination(for: Component<State>.self) { component in
|
||||
self.destination(component)
|
||||
.environment(\.navigationDestinationType, State.self)
|
||||
.id(component.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct _NavigationLinkStoreContent<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 16, macOS 13, tvOS 16, watchOS 9, *)
|
||||
extension NavigationLink where Destination == Never {
|
||||
/// 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 == _NavigationLinkStoreContent<P, L> {
|
||||
@Dependency(\.stackElementID) var stackElementID
|
||||
self.init(value: state.map { Component(id: stackElementID(), element: $0) }) {
|
||||
_NavigationLinkStoreContent<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 == _NavigationLinkStoreContent<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 == _NavigationLinkStoreContent<P, Text> {
|
||||
self.init(state: state, label: { Text(title) }, fileID: fileID, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user