[Experiment] @StateAction
, a property wrapper to model UI actions as state values.
#1683
Replies: 3 comments 1 reply
-
With help of @tgrapperon, I just used this to access Two things worth mentioning:
Here's my final import ComposableArchitecture
import SwiftUI
/// A property wrapper type that can designate properties of an app state that can be expressed as signals in SwiftUI views.
@propertyWrapper
public struct StateValue<ValueType> {
struct ProjectedAction: Equatable {
let value: ValueType
let token: UUID
// Note: Because this type is only used internally and on the UI side, using
// `@Dependency(\.uuid) instead of `UUID.init` doesn't really improve testability and
// furthermore forces the user to provide some `\.uuid` implementation when testing values with
// `@StateValue`.
init(_ value: ValueType, token: UUID = UUID()) {
self.value = value
self.token = token
}
static func == (lhs: ProjectedAction, rhs: ProjectedAction) -> Bool {
lhs.token == rhs.token
}
}
var projectedAction: ProjectedAction?
var _wrappedValue: ValueType?
public var wrappedValue: ValueType? {
get { _wrappedValue }
set {
_wrappedValue = newValue
if let newValue = newValue {
projectedAction = ProjectedAction(newValue)
} else {
projectedAction = nil
}
}
}
public var projectedValue: Self {
get { self }
set { self = newValue }
}
public init(wrappedValue: ValueType? = nil) {
self.wrappedValue = wrappedValue
}
}
extension StateValue: Sendable where ValueType: Sendable {}
extension StateValue: Equatable where ValueType: Equatable {
public static func == (lhs: StateValue<ValueType>, rhs: StateValue<ValueType>) -> Bool {
lhs._wrappedValue == rhs._wrappedValue
}
}
extension StateValue: Hashable where ValueType: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(_wrappedValue)
}
}
extension StateValue: CustomDumpReflectable {
public var customDumpMirror: Mirror {
Mirror(
self,
children: [
"value": self._wrappedValue as Any
],
displayStyle: .enum
)
}
}
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
extension View {
/// A view modifier that performs the provided closure when a `StateValue` is assigned to the
/// store's state by the reducer.
///
/// Assigning the same value that was assigned on a previous reducer run produces a new signal.
/// However, only the last signal assigned in a reducer's run is effectively expressed.
///
/// - Parameters:
/// - store: the ``Store`` to observe.
/// - stateAction: a function from the store's state to a `StateValue` value, typically a
/// `KeyPath` from `State` to the `projectedValue` hosting the `StateValue`.
/// - perform: some action to perform when a new value is assigned to the `StateValue`.
public func onChange<StoreState, StoreAction, ValueType>(
of stateAction: @escaping (StoreState) -> StateValue<ValueType>,
in store: Store<StoreState, StoreAction>,
perform: @escaping (ValueType) -> Void
) -> some View {
self.modifier(StateValueModifier(store: store, stateAction: stateAction, perform: perform))
}
}
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
struct StateValueModifier<StoreState, ValueType>: ViewModifier {
let perform: (ValueType) -> Void
@StateObject var viewStore: ViewStore<StateValue<ValueType>.ProjectedAction?, Never>
init<StoreAction>(
store: Store<StoreState, StoreAction>,
stateAction: @escaping (StoreState) -> StateValue<ValueType>,
perform: @escaping (ValueType) -> Void
) {
self._viewStore = StateObject(
wrappedValue: ViewStore(store.actionless, observe: { stateAction($0).projectedAction })
)
self.perform = perform
}
func body(content: Content) -> some View {
content
.onAppear {
guard let value = viewStore.state?.value else { return }
perform(value)
}
.onChange(of: viewStore.state) { projectedAction in
guard let projectedAction = projectedAction else { return }
perform(projectedAction.value)
}
}
} And this is a simplified version of how I used it: // View:
@Environment(\.openWindow)
var openWindow
var body: some Scene { // <-- also works for `some View`
Window("...", id: "one") {
SomeView()
.onChange(of: \.$windowToOpen, in: self.store) { window in
self.openWindow(id: window.id)
}
}
Window("...", id: "two") { /* ... */ }
}
// State:
@StateValue
var windowToOpen: String?
// Reducer:
case .showWindowTwo:
state.windowToOpen = "two" This worked and when I'm also considering |
Beta Was this translation helpful? Give feedback.
-
After a bit more thought, I changed the name of the property wrapper to import ComposableArchitecture
import SwiftUI
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
struct OnChangeModifier<State, Value>: ViewModifier {
let perform: (Value) -> Void
@StateObject
var viewStore: ViewStore<OnChange<Value>.ProjectedAction?, Never>
init<Action>(
store: Store<State, Action>,
stateToOnChangeValue: @escaping (State) -> OnChange<Value>,
perform: @escaping (Value) -> Void
) {
self._viewStore = StateObject(wrappedValue: ViewStore(store.actionless, observe: { stateToOnChangeValue($0).projectedAction }))
self.perform = perform
}
func body(content: Content) -> some View {
content
.onAppear {
guard let value = viewStore.state?.value else { return }
perform(value)
}
.onChange(of: viewStore.state) { projectedAction in
guard let projectedAction = projectedAction else { return }
perform(projectedAction.value)
}
}
}
/// A property wrapper type that can designate properties of an app state that can be expressed as signals in SwiftUI views.
@propertyWrapper
public struct OnChange<Value> {
struct ProjectedAction: Equatable {
let value: Value
let token: UUID
// Note: Because this type is only used internally and on the UI side, using
// `@Dependency(\.uuid) instead of `UUID.init` doesn't really improve testability and
// furthermore forces the user to provide some `\.uuid` implementation when testing values with `@OnChange`.
init(_ value: Value, token: UUID = UUID()) {
self.value = value
self.token = token
}
static func == (lhs: ProjectedAction, rhs: ProjectedAction) -> Bool {
lhs.token == rhs.token
}
}
var projectedAction: ProjectedAction?
var _wrappedValue: Value? // swiftlint:disable:this identifier_name
public var wrappedValue: Value? {
get { _wrappedValue }
set {
_wrappedValue = newValue
if let newValue = newValue {
projectedAction = ProjectedAction(newValue)
} else {
projectedAction = nil
}
}
}
public var projectedValue: Self {
get { self }
set { self = newValue }
}
public init(wrappedValue: Value? = nil) {
self.wrappedValue = wrappedValue
}
}
extension OnChange: Sendable where Value: Sendable {}
extension OnChange.ProjectedAction: Sendable where Value: Sendable {}
extension OnChange: Equatable where Value: Equatable {
public static func == (lhs: OnChange<Value>, rhs: OnChange<Value>) -> Bool {
lhs._wrappedValue == rhs._wrappedValue
}
}
extension OnChange: Hashable where Value: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(_wrappedValue)
}
}
extension OnChange: CustomDumpReflectable {
public var customDumpMirror: Mirror {
Mirror(self, children: ["value": self._wrappedValue as Any], displayStyle: .enum)
}
}
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
extension View {
/// A view modifier that performs the provided closure when a `OnChange` is assigned to the
/// store's state by the reducer.
///
/// Assigning the same value that was assigned on a previous reducer run produces a new signal.
/// However, only the last signal assigned in a reducer's run is effectively expressed.
///
/// - Parameters:
/// - stateToOnChangeValue: a function from the store's state to a `OnChange` value, typically a
/// `KeyPath` from `State` to the `projectedValue` hosting the `OnChange`.
/// - store: the ``Store`` to observe.
/// - perform: some action to perform when a new value is assigned to the `OnChange`.
@MainActor
public func onChange<State, Action, Value>(
of stateToOnChangeValue: @escaping (State) -> OnChange<Value>,
store: Store<State, Action>,
perform: @escaping (Value) -> Void
) -> some View {
self.modifier(OnChangeModifier(store: store, stateToOnChangeValue: stateToOnChangeValue, perform: perform))
}
} Usage example: // View:
@Environment(\.openWindow)
var openWindow
var body: some Scene { // <-- also works for `some View`
Window("...", id: "one") {
SomeView()
.onChange(of: \.$openWindow, in: self.store) { window in
self.openWindow(id: window.id)
}
}
Window("...", id: "two") { /* ... */ }
}
// State:
@OnChange
var openWindow: String?
// Reducer:
case .showWindowTwo:
state.openWindow = "two" |
Beta Was this translation helpful? Give feedback.
-
Thanks for sharing! I am glad to have come upon this discussion; I'd been having this issue, but wasn't exactly sure how to articulate my query! I like the term signal. My use case is the new @Environment(\.webAuthenticationSession) private var webAuthenticationSession with the instance method: func authenticate(
using url: URL,
callbackURLScheme: String,
preferredBrowserSession: WebAuthenticationSession.BrowserSession? = nil
) async throws -> URL The documentation shows calling this closure from a task spun up in a button's action closure. My problem is: I need to execute an effect in my reducer before calling this authentication closure. I briefly experimented with passing the environment value closure into my reducer as the associated value of an action, but that wrecked the equatability and testability of my action enum, and so felt wrong. Being able to signal out to the view feels much more correct. If Apple continues to make more of their APIs available as SwiftUI environment values, those become a class of effect trapped in the view layer, and a mechanism to signal out to them may continue to be useful. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Hello!
When building apps with TCA and SwiftUI, we frequently need to produce an action on the UI's side in reaction to a change in
State
. This is for example what happens with binding synchronization of@FocusState
. In the general picture, we're often forced to create ad-hoc abstractions based ononChange
that are often lacking in a few areas, like testability or debugging.The problem happened to me again a few hours ago, so I decided to take the time to try to formalize something that could be reusable and testable.
TLDR, there is a branch here (potentially a PR), with an additional
StateAction
case study.The prototype case is a list of 50 integers. The only action makes the reducer pick a random element and scroll the list, so the corresponding row is centered on the screen. Of course, there are simpler ways to implement this using
onChange
, but the approach I'm proposing should also work in the general picture.The reducer's domain contains a
Signal
(freely named) enum with one case:The reducer's state contains a
Signal
StateAction
:You don't have to provide any value to initialize this value, like a
@Dependency
.When the reducer receives the
userDidTapRandomButton
action, it picks a random number, saves it instate
, and writes the correspondingStateAction
1:In the view, a
ViewModifier
directly observes the store and performs the corresponding action:That's it!
You can produce the same action twice (which is not convenient to model with
onChange
), and use very complex and potentially non-equatable payloads.When the "UI actions" you send are
Equatable
, you can also test them usingTestStore
if you also provide an(EDIT: Providing an\.uuid
implementation. One can imagine using a dedicated dependency to model successive actions emission instead of this dependency though.\.uuid
implementation for testing is not required anymore with the latest commits)I decided to base the modifier onto the
Store
rather than aViewStore
because this action's observation is mostly internal, and it would not make a lot of sense to expose it in aViewStore
.This is a very rough initial draft, but I feel there's some potential to it. Please let me know what you think!
Footnotes
I'm not using an RNG in this post to keep things lean. The branch uses a dependency. ↩
Beta Was this translation helpful? Give feedback.
All reactions