-
WWDC21 introduced the SwiftUI FocusState API in the session: Direct and reflect focus in SwiftUI. Before iOS 15, to direct focus I’d use To use the new iOS 15 focus API with TCA, my first thought was to create my own binding to provide to the enum Field {
case name
}
let _: FocusState<Field?>.Binding = .init()
// → 'FocusState<Field?>.Binding' cannot be constructed because it has no accessible initializers So, I’ve resorted to observing and replaying changes back and forth between the TCA Store and a vanilla SwiftUI
I used this approach to TCA-ify Apple’s
import Combine
import ComposableArchitecture
import SwiftUI
struct LoginFormState: Equatable {
var username: String = ""
var password: String = ""
var focusedField: Field?
enum Field: Hashable {
case username
case password
}
}
enum LoginFormAction: Equatable {
case usernameChanged(String)
case passwordChanged(String)
case focusedFieldChanged(LoginFormState.Field?)
case signInTapped
}
let reducer = Reducer<LoginFormState, LoginFormAction, Void> { state, action, _ in
switch action {
case .usernameChanged(let username):
state.username = username
return .none
case .passwordChanged(let password):
state.password = password
return .none
case .focusedFieldChanged(let focusedField):
state.focusedField = focusedField
return .none
case .signInTapped:
if state.username.isEmpty {
state.focusedField = .username
} else if state.password.isEmpty {
state.focusedField = .password
} else {
// handleLogin(username, password)
}
return .none
}
}
.debug()
struct FocusTestView: View {
let store: Store<LoginFormState, LoginFormAction>
@FocusState private var focusedField: LoginFormState.Field?
@State private var cancellables: [AnyCancellable] = []
var body: some View {
// iOS 15 Beta 1 bug: Placing FocusState-focusable views within a Form view, as in Apple's doc example,
// completely breaks FocusState. Assigning to the @FocusState property has no effect--it will always
// be nil. Wrapping in a VStack and/or TCA's WithViewStore works, tho.
WithViewStore(self.store) { viewStore in
VStack {
TextField(
"Username",
text: viewStore.binding(get: { $0.username }, send: LoginFormAction.usernameChanged)
)
.focused(self.$focusedField, equals: .username)
SecureField(
"Password",
text: viewStore.binding(get: { $0.password }, send: LoginFormAction.passwordChanged)
)
.focused(self.$focusedField, equals: .password)
Button("Sign In") {
viewStore.send(.signInTapped)
}
}
.onChange(of: self.focusedField) { newValue in
viewStore.send(.focusedFieldChanged(newValue))
}
.onAppear {
viewStore.publisher.focusedField
.sink { self.focusedField = $0 }
.store(in: &self.cancellables)
}
}
}
}
@main
struct TestFocusApp: App {
var body: some Scene {
WindowGroup {
FocusTestView(
store: .init(
initialState: LoginFormState(),
reducer: reducer,
environment: ()
)
)
}
}
}
This approach could be made more ergonomic by creating a custom modifier to bundle the Does anyone see any problems/opportunities for improvement? Or, does anyone have any ideas for an entirely different approach? |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 1 reply
-
Hi @gohanlon, thanks for starting this discussion! We've played with this API a bit and the solution we've come up with is essentially what you have. Listen for changes in each of And this is actually apart of a bigger pattern that affects not only TCA but even vanilla SwiftUI. If you wanted to capture focus state inside an We actually have some episodes coming in a few weeks where we explore this and other WWDC topics. |
Beta Was this translation helpful? Give feedback.
Hi @gohanlon, thanks for starting this discussion!
We've played with this API a bit and the solution we've come up with is essentially what you have. Listen for changes in each of
@FocusState
and TCA state in order to replay that change to the other. One small difference is that you can use another.onChange
forviewStore.focusedField
rather than subscribing to the publisher in.onAppear
.And this is actually apart of a bigger pattern that affects not only TCA but even vanilla SwiftUI. If you wanted to capture focus state inside an
ObservableObject
view model (in order to make it testable) you would need to do the same pattern of replaying changes to connect the VM world to the view world.…