Files
Stephen Celis 149fdf9c20 Use SPI docs instead of self-hosted (#3791)
* Use SPI docs instead of self-hosted

Fixes #3785.

* wip
2025-10-08 09:28:11 -07:00

261 lines
9.8 KiB
Swift

@_spi(Reflection) import CasePaths
import SwiftUI
/// A view that observes when enum state held in a store changes cases, and provides stores to
/// ``CaseLet`` views.
///
/// An application may model parts of its state with enums. For example, app state may differ if a
/// user is logged-in or not:
///
/// ```swift
/// @Reducer
/// struct AppFeature {
/// enum State {
/// case loggedIn(LoggedInState)
/// case loggedOut(LoggedOutState)
/// }
/// // ...
/// }
/// ```
///
/// In the view layer, a store on this state can switch over each case using a ``SwitchStore`` and
/// a ``CaseLet`` view per case:
///
/// ```swift
/// struct AppView: View {
/// let store: StoreOf<AppFeature>
///
/// var body: some View {
/// SwitchStore(self.store) { state in
/// switch state {
/// case .loggedIn:
/// CaseLet(
/// /AppFeature.State.loggedIn, action: AppFeature.Action.loggedIn
/// ) { loggedInStore in
/// LoggedInView(store: loggedInStore)
/// }
/// case .loggedOut:
/// CaseLet(
/// /AppFeature.State.loggedOut, action: AppFeature.Action.loggedOut
/// ) { loggedOutStore in
/// LoggedOutView(store: loggedOutStore)
/// }
/// }
/// }
/// }
/// }
/// ```
///
/// > Important: The `SwitchStore` view builder is only evaluated when the case of state passed to
/// > it changes. As such, you should not rely on this value for anything other than checking the
/// > current case, _e.g._ by switching on it and routing to an appropriate `CaseLet`.
///
/// See ``Reducer/ifCaseLet(_:action:then:fileID:filePath:line:column:)-7sg8d`` and
/// ``Scope/init(state:action:child:fileID:filePath:line:column:)-9g44g`` for embedding reducers
/// that operate on each case of an enum in reducers that operate on the entire enum.
@available(
iOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
@available(
macOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
@available(
tvOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
@available(
watchOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
public struct SwitchStore<State, Action, Content: View>: View {
public let store: Store<State, Action>
public let content: (State) -> Content
public init(
_ store: Store<State, Action>,
@ViewBuilder content: @escaping (_ initialState: State) -> Content
) {
self.store = store
self.content = content
}
public var body: some View {
WithViewStore(
self.store, observe: { $0 }, removeDuplicates: { enumTag($0) == enumTag($1) }
) { viewStore in
self.content(viewStore.state)
.environmentObject(StoreObservableObject(store: self.store))
}
}
}
/// A view that handles a specific case of enum state in a ``SwitchStore``.
@available(
iOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
@available(
macOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
@available(
tvOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
@available(
watchOS, deprecated: 9999,
message:
"Use 'switch' with a store of observable state, instead. For more information, see the following article: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Replacing-SwitchStore-and-CaseLet-with-switch-and-case]"
)
public struct CaseLet<EnumState, EnumAction, CaseState, CaseAction, Content: View>: View {
public let toCaseState: (EnumState) -> CaseState?
public let fromCaseAction: (CaseAction) -> EnumAction
public let content: (Store<CaseState, CaseAction>) -> Content
private let fileID: StaticString
private let filePath: StaticString
private let line: UInt
private let column: UInt
@EnvironmentObject private var store: StoreObservableObject<EnumState, EnumAction>
/// Initializes a ``CaseLet`` view that computes content depending on if a store of enum state
/// matches a particular case.
///
/// - Parameters:
/// - toCaseState: A function that can extract a case of switch store state, which can be
/// specified using case path literal syntax, _e.g._ `/State.case`.
/// - fromCaseAction: A function that can embed a case action in a switch store action.
/// - content: A function that is given a store of the given case's state and returns a view
/// that is visible only when the switch store's state matches.
/// - fileID: The fileID.
/// - filePath: The filePath.
/// - line: The line.
/// - column: The column.
public init(
_ toCaseState: @escaping (EnumState) -> CaseState?,
action fromCaseAction: @escaping (CaseAction) -> EnumAction,
@ViewBuilder then content: @escaping (_ store: Store<CaseState, CaseAction>) -> Content,
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
column: UInt = #column
) {
self.toCaseState = toCaseState
self.fromCaseAction = fromCaseAction
self.content = content
self.fileID = fileID
self.filePath = filePath
self.line = line
self.column = column
}
public var body: some View {
IfLetStore(
self.store.wrappedValue._scope(state: self.toCaseState, action: self.fromCaseAction),
then: self.content,
else: {
_CaseLetMismatchView<EnumState, EnumAction>(
fileID: self.fileID,
filePath: self.filePath,
line: self.line,
column: self.column
)
}
)
}
}
extension CaseLet where EnumAction == CaseAction {
/// Initializes a ``CaseLet`` view that computes content depending on if a store of enum state
/// matches a particular case.
///
/// - Parameters:
/// - toCaseState: A function that can extract a case of switch store state, which can be
/// specified using case path literal syntax, _e.g._ `/State.case`.
/// - content: A function that is given a store of the given case's state and returns a view
/// that is visible only when the switch store's state matches.
public init(
state toCaseState: @escaping (EnumState) -> CaseState?,
@ViewBuilder then content: @escaping (_ store: Store<CaseState, CaseAction>) -> Content
) {
self.init(
toCaseState,
action: { $0 },
then: content
)
}
}
public struct _CaseLetMismatchView<State, Action>: View {
@EnvironmentObject private var store: StoreObservableObject<State, Action>
let fileID: StaticString
let filePath: StaticString
let line: UInt
let column: UInt
public var body: some View {
#if DEBUG
let message = """
Warning: A "CaseLet" at "\(self.fileID):\(self.line)" was encountered when state was set \
to another case:
\(debugCaseOutput(self.store.wrappedValue.withState { $0 }))
This usually happens when there is a mismatch between the case being switched on and the \
"CaseLet" view being rendered.
For example, if ".screenA" is being switched on, but the "CaseLet" view is pointed to \
".screenB":
case .screenA:
CaseLet(
/State.screenB, action: Action.screenB
) { /* ... */ }
Look out for typos to ensure that these two cases align.
"""
return VStack(spacing: 17) {
#if os(macOS)
Text("⚠️")
#else
Image(systemName: "exclamationmark.triangle.fill")
.font(.largeTitle)
#endif
Text(message)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.white)
.padding()
.background(Color.red.edgesIgnoringSafeArea(.all))
.onAppear {
reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column)
}
#else
return EmptyView()
#endif
}
}
private final class StoreObservableObject<State, Action>: ObservableObject {
let wrappedValue: Store<State, Action>
init(store: Store<State, Action>) {
self.wrappedValue = store
}
}
private func enumTag<Case>(_ `case`: Case) -> UInt32? {
EnumMetadata(Case.self)?.tag(of: `case`)
}