Thanks for replying!
From the top of my head, I can find three cases that need some custom behavior.
I've tried to prepare both the pure SwiftUI cases and the TCA examples with mixed results.
I'm aware that they are in some ways hacky and definitely far from perfect, but I couldn't find many resources around that topic, so I improvised a lot.
I hope that the poor quality of code does not interfere in the discussion regarding the application of TCA.
If someone knows how to make code samples better, you are very welcome to correct me and I would really appreciate it.
Every example uses new APIs from tvOS 14.
The easiest way to run those examples is just to copy them to the newly created tvOS app and uncomment one of the examples.
You could also create a simple navigation for every one of those, but that's was not the goal of this post.
Unfortunately, I think you need to run the app to check the focus behavior. I was unable to test this in the preview.
The sample of how your app may look like.
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
// Case1Example()
// Case1View(
// store: .init(
// initialState: .init(),
// reducer: case1Reducer,
// environment: Case1Environment()
// )
// )
// Case2Example()
// Case2View(
// store: .init(
// initialState: .init(),
// reducer: case2Reducer,
// environment: Case2Environment()
// )
// )
// Case3AExample()
// Case3AView(
// store: .init(
// initialState: .init(),
// reducer: case3AReducer,
// environment: Case3AEnvironment()
// )
// )
// Case3BExample()
// Case3BView(
// store: .init(
// initialState: .init(),
// reducer: case3BReducer,
// environment: Case3BEnvironment()
// )
// )
}
}
}
This is a common model for a list of elements. It's used across all TCA Examples
/// Common model
struct Element: Equatable, Identifiable {
let index: Int
var title: String {
String(index) + " title"
}
var description: String {
String(index) + " description"
}
var id: Int {
index
}
var focused: Bool = false
static let mocks: [Self] = Array(0..<10).map { Element(index: $0) }
}
The first example that I've prepared is the most basic one.
I'm trying to focus on the given element after showing the view.
It's very similar to the example that apple provided on WWDC 20
talk Build SwiftUI apps for tvOS
.
I'm using .prefersDefaultFocus
to determine which element should be focused.
1 SwiftUI Example
struct Case1Example: View {
private static let numberOfItems = 10
private static let range = 0..<numberOfItems
@Namespace private var namespace
@State private var indexToSelect = range.randomElement()!
var body: some View {
HStack {
ForEach(Self.range) { index in
Button(String(index), action: {})
.prefersDefaultFocus(indexToSelect == index, in: namespace)
}
}
.focusScope(namespace)
}
}
1 SwiftUI TCA Example
struct Case1State: Equatable {
var elements: [Element] = Element.mocks
}
enum Case1Action {
case onAppear
}
struct Case1Environment {}
let case1Reducer = Reducer<
Case1State,
Case1Action,
Case1Environment
> { state, action, _ in
switch action {
case .onAppear:
let index = state.elements.randomElement()!.index
state.elements[index].focused = true
return .none
}
}
struct Case1View: View {
let store: Store<Case1State, Case1Action>
@Namespace private var namespace
var body: some View {
WithViewStore(store) { viewStore in
HStack {
ForEach(viewStore.elements) { element in
Button(element.title, action: {})
.prefersDefaultFocus(element.focused, in: namespace)
}
}
.focusScope(namespace)
.onAppear {
viewStore.send(.onAppear)
}
}
}
}
The second case is based on forcing focus to be set on a given view, but instead of doing it when the view loads we are doing it on demand.
This could be applied to change focus after performing some action/effect or to replace missing focus layout guide ( Apple Developer Documentation ).
In prepared example, I've created simple carousel-like behavior.
2 SwiftUI Example
struct Case2Example: View {
@Environment(\.resetFocus) private var resetFocus
@Namespace private var namespace
private static let numberOfItems = 10
private static let range = 0..<numberOfItems
@State var indexToSelect = range.randomElement()!
var body: some View {
HStack {
LayoutGuideView()
.focusable(true) { focused in
indexToSelect = Self.range.last!
resetFocus(in: namespace)
}
ForEach(Self.range) { index in
Button(String(index), action: {})
.prefersDefaultFocus(indexToSelect == index, in: namespace)
}
LayoutGuideView()
.focusable(true) { focused in
indexToSelect = Self.range.first!
resetFocus(in: namespace)
}
}
.focusScope(namespace)
}
}
2 SwiftUI TCA Example
struct Case2State: Equatable {
var elements: [Element] = Element.mocks
}
enum Case2Action {
case onAppear
case leftRectangleSelected
case rightRectangleSelected
}
struct Case2Environment {}
let case2Reducer = Reducer<
Case2State,
Case2Action,
Case2Environment
> { state, action, _ in
switch action {
case .onAppear:
let index = state.elements.randomElement()!.index
state.elements[index].focused = true
return .none
case .leftRectangleSelected:
let lastIndex = state.elements.last!.index
for index in state.elements.indices {
state.elements[index].focused = false
}
state.elements[lastIndex].focused = true
return .none
case .rightRectangleSelected:
let firstIndex = state.elements.first!.index
for index in state.elements.indices {
state.elements[index].focused = false
}
state.elements[firstIndex].focused = true
return .none
}
}
struct Case2View: View {
@Environment(\.resetFocus) private var resetFocus
@Namespace private var namespace
let store: Store<Case2State, Case2Action>
var body: some View {
WithViewStore(store) { viewStore in
HStack {
LayoutGuideView()
.focusable(true) { focused in
viewStore.send(.leftRectangleSelected)
// This should probably be moved to reducer, but the namespace is the view's variable.
// If I make `@Environment(\.resetFocus)` and `@Namespace` static I receive following warning:
// Reading a Namespace property outside View.body. This will result in identifiers that never match any other identifier.
resetFocus(in: namespace)
}
ForEach(viewStore.elements) { element in
Button(element.title, action: {})
.prefersDefaultFocus(element.focused, in: namespace)
}
LayoutGuideView()
.focusable(true) { focused in
viewStore.send(.rightRectangleSelected)
resetFocus(in: namespace)
}
}
.focusScope(namespace)
.onAppear {
viewStore.send(.onAppear)
}
}
}
}
Unfortunately, my lack of knowledge shows up on the third example that's why I've prepared two versions of it.
Both of them do not work properly and are broken in some way or use hacks that should probably be avoided in production apps, but I think they are good enough for the investigation of the possibilities/limitations of TCA.
In the third example, we are displaying the description based on the currently focused button/model.
This kind of behavior is common for changing background images of the selected model or changing of title/description of the selected episode in the TV series.
For the A approach I'm using the Environment(.isFocused) as I want to use SwiftUI default button/toggle behavior. To use Environment(.isFocused) we need to add this to the child of the focused view ( in this case button's child ). Then we need to pass this boolean to the top of the hierarchy ( I could not find the way of passing the @environment value to the parent so I've used some hacky approach) and changed the description based on the selected button.
There probably should be a better way of doing that but unfortunately, I could not find one.
3A SwiftUI Example
struct Case3AExample: View {
private static let numberOfItems = 10
private static let range = 0..<numberOfItems
private static let descriptions = [
"q","w","e","r","t","y","u","i","o","p"
]
var helperObject = HelperObject()
@State var selectedText: String?
var body: some View {
HStack {
VStack {
ForEach(Self.range) { index in
ButtonWrapper(
index: index,
action: {}
)
}
}
Spacer()
if let val = selectedText {
Text(val)
}
if selectedText == nil {
Rectangle()
.foregroundColor(.clear)
}
Spacer()
}
.environmentObject(helperObject)
.onReceive(helperObject.$indexSelected) { val in
let newValue = Self.descriptions[val]
selectedText = newValue
}
}
}
class HelperObject: ObservableObject {
@Published var indexSelected = 0
}
struct ButtonWrapper: View {
let index: Int
let action: () -> Void
var body: some View {
Button(
action: action,
label: {
ButtonBody(
index: index
)
}
)
}
}
struct ButtonBody: View {
@EnvironmentObject var helperObject: HelperObject
@Environment(\.isFocused) var systemFocused
let index: Int
private var title: String { String(index) }
var body: some View {
// The dot here allows tracking `@Environment(\.isFocused)` environment variable.
// Unfornutelly I do not know any better way of doing that
if systemFocused, helperObject.indexSelected != index {
helperObject.indexSelected = index
}
return Text(systemFocused ? title + "." : title)
}
}
3A SwiftUI TCA Example
struct Case3AState: Equatable {
var elements: [Element] = Element.mocks
var selectedText: String?
}
enum Case3AAction {
case changedFocus(isFocused: Bool, atIndex: Int)
case updateDescription(description: String?)
case tappedButton(atIndex: Int)
}
struct Case3AEnvironment {}
let case3AReducer = Reducer<
Case3AState,
Case3AAction,
Case3AEnvironment
> { state, action, _ in
switch action {
case .changedFocus(let focused, let index):
state.elements[index].focused = focused
return focused ? .init(value: .updateDescription(description: state.elements[index].description)) : .none
case .updateDescription(let description):
state.selectedText = description
return .none
case .tappedButton:
return .none
}
}
struct Case3AView: View {
let store: Store<Case3AState, Case3AAction>
@Namespace private var namespace
var body: some View {
WithViewStore(store) { viewStore in
HStack {
VStack {
// This Could be replaced by ForEachStore
ForEach(viewStore.elements) { element in
Button {
viewStore.send(.tappedButton(atIndex: element.index))
} label: {
Case3AButtonBody(title: element.title) { focused in
viewStore.send(.changedFocus(isFocused: focused, atIndex: element.index))
}
}
}
}
Spacer()
if let val = viewStore.selectedText {
Text(val)
}
if viewStore.selectedText == nil {
Rectangle()
.foregroundColor(.clear)
}
Spacer()
}
}
}
}
struct Case3AButtonBody: View {
let title: String
var isFocused: (Bool) -> Void
@Environment(\.isFocused) private var systemFocused: Bool
var body: some View {
isFocused(systemFocused)
// The dot here allows tracking isFocued environment variable.
// Unfornutelly I do not know any better way of doing that
return Text(systemFocused ? title + "." : title)
}
}
Case B is the same concept, instead of using @Environment(\.isFocused)
I've used .focusable
view modifier. This approach also have a downside, because it breaks default button behavior ( buttons are not scaling and changing their colors), but we could create custom views and use this approach to control the focus easier.
3B SwiftUI Example
struct Case3BExample: View {
private static let numberOfItems = 10
private static let range = 0..<numberOfItems
private static let descriptions = [
"q","w","e","r","t","y","u","i","o","p"
]
@State var selectedText: String?
var body: some View {
HStack {
VStack {
ForEach(Self.range) { index in
Button {}
label: {
Text(String(index))
}
.focusable(true) { focused in
if focused {
let newValue = Self.descriptions[index]
selectedText = newValue
}
}
}
}
Spacer()
if let val = selectedText {
Text(val)
}
if selectedText == nil {
Rectangle()
.foregroundColor(.clear)
}
Spacer()
}
}
}
3B SwiftUI TCA Example
struct Case3BState: Equatable {
var elements: [Element] = Element.mocks
var selectedText: String?
}
enum Case3BAction {
case changedFocus(isFocused: Bool, atIndex: Int)
case updateDescription(description: String?)
case tappedButton(atIndex: Int)
}
struct Case3BEnvironment {}
let case3BReducer = Reducer<
Case3BState,
Case3BAction,
Case3BEnvironment
> { state, action, _ in
switch action {
case .changedFocus(let focused, let index):
state.elements[index].focused = focused
return focused ? .init(value: .updateDescription(description: state.elements[index].description)) : .none
case .updateDescription(let description):
state.selectedText = description
return .none
case .tappedButton:
return .none
}
}
struct Case3BView: View {
let store: Store<Case3BState, Case3BAction>
@Namespace private var namespace
var body: some View {
WithViewStore(store) { viewStore in
HStack {
VStack {
// This Could be replaced by ForEachStore
ForEach(viewStore.elements) { element in
Button {
viewStore.send(.tappedButton(atIndex: element.index))
}
label: {
Text(element.title)
}
.focusable(true) { focused in
viewStore.send(.changedFocus(isFocused: focused, atIndex: element.index))
}
}
}
Spacer()
if let val = viewStore.selectedText {
Text(val)
}
if viewStore.selectedText == nil {
Rectangle()
.foregroundColor(.clear)
}
Spacer()
}
}
}
}
For the two first examples field focused
in the example model is used more like "should be focused on the next refresh of the focus engine, and in 3rd it indicates which model is currently focused.
I hope I made any sense with all of this.
I just wanted to emphasize that prepared all code samples and especially the TCA code samples definitely can be done better, but I could not find many resources regarding this topic and I've tried my best to present some pain points with the current state of focus engine and the issues that some people may stumble upon when trying to use this amazing architecture.
Thanks for your help, I really appreciate it.
Regards,
miko