Files
swift-composable-architectu…/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift
Stephen Celis c432a76b5b 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>
2023-05-30 12:22:00 -04:00

491 lines
12 KiB
Swift

import ComposableArchitecture
import SwiftUI
private let readMe = """
This screen demonstrates how to use `NavigationStack` with Composable Architecture applications.
"""
struct NavigationDemo: ReducerProtocol {
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action: Equatable {
case goBackToScreen(id: StackElementID)
case goToABCButtonTapped
case path(StackAction<Path.State, Path.Action>)
case popToRoot
}
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case let .goBackToScreen(id):
state.path.pop(to: id)
return .none
case .goToABCButtonTapped:
state.path.append(.screenA())
state.path.append(.screenB())
state.path.append(.screenC())
return .none
case let .path(action):
switch action {
case .element(id: _, action: .screenB(.screenAButtonTapped)):
state.path.append(.screenA())
return .none
case .element(id: _, action: .screenB(.screenBButtonTapped)):
state.path.append(.screenB())
return .none
case .element(id: _, action: .screenB(.screenCButtonTapped)):
state.path.append(.screenC())
return .none
default:
return .none
}
case .popToRoot:
state.path.removeAll()
return .none
}
}
.forEach(\.path, action: /Action.path) {
Path()
}
}
struct Path: ReducerProtocol {
enum State: Codable, Equatable, Hashable {
case screenA(ScreenA.State = .init())
case screenB(ScreenB.State = .init())
case screenC(ScreenC.State = .init())
}
enum Action: Equatable {
case screenA(ScreenA.Action)
case screenB(ScreenB.Action)
case screenC(ScreenC.Action)
}
var body: some ReducerProtocol<State, Action> {
Scope(state: /State.screenA, action: /Action.screenA) {
ScreenA()
}
Scope(state: /State.screenB, action: /Action.screenB) {
ScreenB()
}
Scope(state: /State.screenC, action: /Action.screenC) {
ScreenC()
}
}
}
}
struct NavigationDemoView: View {
let store: StoreOf<NavigationDemo>
var body: some View {
NavigationStackStore(
self.store.scope(state: \.path, action: NavigationDemo.Action.path)
) {
Form {
Section { Text(template: readMe) }
Section {
NavigationLink(
"Go to screen A",
state: NavigationDemo.Path.State.screenA()
)
NavigationLink(
"Go to screen B",
state: NavigationDemo.Path.State.screenB()
)
NavigationLink(
"Go to screen C",
state: NavigationDemo.Path.State.screenC()
)
}
Section {
Button("Go to A → B → C") {
ViewStore(self.store.stateless).send(.goToABCButtonTapped)
}
}
}
.navigationTitle("Root")
} destination: {
switch $0 {
case .screenA:
CaseLet(
state: /NavigationDemo.Path.State.screenA,
action: NavigationDemo.Path.Action.screenA,
then: ScreenAView.init(store:)
)
case .screenB:
CaseLet(
state: /NavigationDemo.Path.State.screenB,
action: NavigationDemo.Path.Action.screenB,
then: ScreenBView.init(store:)
)
case .screenC:
CaseLet(
state: /NavigationDemo.Path.State.screenC,
action: NavigationDemo.Path.Action.screenC,
then: ScreenCView.init(store:)
)
}
}
.safeAreaInset(edge: .bottom) {
FloatingMenuView(store: self.store)
}
.navigationTitle("Navigation Stack")
}
}
// MARK: - Floating menu
struct FloatingMenuView: View {
let store: StoreOf<NavigationDemo>
struct ViewState: Equatable {
struct Screen: Equatable, Identifiable {
let id: StackElementID
let name: String
}
var currentStack: [Screen]
var total: Int
init(state: NavigationDemo.State) {
self.total = 0
self.currentStack = []
for (id, element) in zip(state.path.ids, state.path) {
switch element {
case let .screenA(screenAState):
self.total += screenAState.count
self.currentStack.insert(Screen(id: id, name: "Screen A"), at: 0)
case .screenB:
self.currentStack.insert(Screen(id: id, name: "Screen B"), at: 0)
case let .screenC(screenBState):
self.total += screenBState.count
self.currentStack.insert(Screen(id: id, name: "Screen C"), at: 0)
}
}
}
}
var body: some View {
WithViewStore(self.store, observe: ViewState.init) { viewStore in
if viewStore.currentStack.count > 0 {
VStack(alignment: .center) {
Text("Total count: \(viewStore.total)")
Button("Pop to root") {
viewStore.send(.popToRoot, animation: .default)
}
Menu("Current stack") {
ForEach(viewStore.currentStack) { screen in
Button("\(String(describing: screen.id))) \(screen.name)") {
viewStore.send(.goBackToScreen(id: screen.id))
}
.disabled(screen == viewStore.currentStack.first)
}
Button("Root") {
viewStore.send(.popToRoot, animation: .default)
}
}
}
.padding()
.background(Color(.systemBackground))
.padding(.bottom, 1)
.transition(.opacity.animation(.default))
.clipped()
.shadow(color: .black.opacity(0.2), radius: 5, y: 5)
}
}
}
}
// MARK: - Screen A
struct ScreenA: ReducerProtocol {
struct State: Codable, Equatable, Hashable {
var count = 0
var fact: String?
var isLoading = false
}
enum Action: Equatable {
case decrementButtonTapped
case dismissButtonTapped
case incrementButtonTapped
case factButtonTapped
case factResponse(TaskResult<String>)
}
@Dependency(\.dismiss) var dismiss
@Dependency(\.factClient) var factClient
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .dismissButtonTapped:
return .fireAndForget {
await self.dismiss()
}
case .incrementButtonTapped:
state.count += 1
return .none
case .factButtonTapped:
state.isLoading = true
return .task { [count = state.count] in
await .factResponse(.init { try await self.factClient.fetch(count) })
}
case let .factResponse(.success(fact)):
state.isLoading = false
state.fact = fact
return .none
case .factResponse(.failure):
state.isLoading = false
state.fact = nil
return .none
}
}
}
struct ScreenAView: View {
let store: StoreOf<ScreenA>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Form {
Text(
"""
This screen demonstrates a basic feature hosted in a navigation stack.
You can also have the child feature dismiss itself, which will communicate back to the \
root stack view to pop the feature off the stack.
"""
)
Section {
HStack {
Text("\(viewStore.count)")
Spacer()
Button {
viewStore.send(.decrementButtonTapped)
} label: {
Image(systemName: "minus")
}
Button {
viewStore.send(.incrementButtonTapped)
} label: {
Image(systemName: "plus")
}
}
.buttonStyle(.borderless)
Button {
viewStore.send(.factButtonTapped)
} label: {
HStack {
Text("Get fact")
if viewStore.isLoading {
Spacer()
ProgressView()
}
}
}
if let fact = viewStore.fact {
Text(fact)
}
}
Section {
Button("Dismiss") {
viewStore.send(.dismissButtonTapped)
}
}
Section {
NavigationLink(
"Go to screen A",
state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count))
)
NavigationLink(
"Go to screen B",
state: NavigationDemo.Path.State.screenB()
)
NavigationLink(
"Go to screen C",
state: NavigationDemo.Path.State.screenC(.init(count: viewStore.count))
)
}
}
}
.navigationTitle("Screen A")
}
}
// MARK: - Screen B
struct ScreenB: ReducerProtocol {
struct State: Codable, Equatable, Hashable {}
enum Action: Equatable {
case screenAButtonTapped
case screenBButtonTapped
case screenCButtonTapped
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .screenAButtonTapped:
return .none
case .screenBButtonTapped:
return .none
case .screenCButtonTapped:
return .none
}
}
}
struct ScreenBView: View {
let store: StoreOf<ScreenB>
var body: some View {
WithViewStore(self.store) { viewStore in
Form {
Section {
Text(
"""
This screen demonstrates how to navigate to other screens without needing to compile \
any symbols from those screens. You can send an action into the system, and allow the \
root feature to intercept that action and push the next feature onto the stack.
"""
)
}
Button("Decoupled navigation to screen A") {
viewStore.send(.screenAButtonTapped)
}
Button("Decoupled navigation to screen B") {
viewStore.send(.screenBButtonTapped)
}
Button("Decoupled navigation to screen C") {
viewStore.send(.screenCButtonTapped)
}
}
.navigationTitle("Screen B")
}
}
}
// MARK: - Screen C
struct ScreenC: ReducerProtocol {
struct State: Codable, Equatable, Hashable {
var count = 0
var isTimerRunning = false
}
enum Action: Equatable {
case startButtonTapped
case stopButtonTapped
case timerTick
}
@Dependency(\.mainQueue) var mainQueue
enum CancelID { case timer }
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .startButtonTapped:
state.isTimerRunning = true
return .run { send in
for await _ in self.mainQueue.timer(interval: 1) {
await send(.timerTick)
}
}
.cancellable(id: CancelID.timer)
.concatenate(with: .init(value: .stopButtonTapped))
case .stopButtonTapped:
state.isTimerRunning = false
return .cancel(id: CancelID.timer)
case .timerTick:
state.count += 1
return .none
}
}
}
struct ScreenCView: View {
let store: StoreOf<ScreenC>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Form {
Text(
"""
This screen demonstrates that if you start a long-living effects in a stack, then it \
will automatically be torn down when the screen is dismissed.
"""
)
Section {
Text("\(viewStore.count)")
if viewStore.isTimerRunning {
Button("Stop timer") { viewStore.send(.stopButtonTapped) }
} else {
Button("Start timer") { viewStore.send(.startButtonTapped) }
}
}
Section {
NavigationLink(
"Go to screen A",
state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count))
)
NavigationLink(
"Go to screen B",
state: NavigationDemo.Path.State.screenB()
)
NavigationLink(
"Go to screen C",
state: NavigationDemo.Path.State.screenC()
)
}
}
.navigationTitle("Screen C")
}
}
}
// MARK: - Previews
struct NavigationStack_Previews: PreviewProvider {
static var previews: some View {
NavigationDemoView(
store: Store(
initialState: NavigationDemo.State(
path: StackState([
.screenA(ScreenA.State())
])
)
) {
NavigationDemo()
}
)
}
}