tvOS Focus engine + TCA

Hi
I'm trying to create PoC for our tvOS app with the usage of TCA.
Unfortunately, I have issues with storing the information regarding the focus.
Is there a proper way to manage focus by the TCA?

In WWDC20 Apple introduced a way to set a focus for a given view by using .prefersDefaultFocus view modifier and @Environment(\.resetFocus) to reset the focus for a given view.
You can also get information if the view has a focus using @Environment(\.isFocused).
Honestly, I don't know how to approach that issue. Maybe it's because of the lack of understanding of the focus engine, SwiftUI, or the TCA itself.
For the custom views, you could use view modifier .focusable, but it does not work well with standard views and it only allows you to get current focus value and does not allow you to set it.

Should I just ignore the focus part and try capturing the @Environment(\.isFocused)?

struct ExampleState: Equatable {
	var isFocused: Bool = false
	var isOn: Bool = false
	var title: String = "Example"
}

enum ExampleAction: Equatable {
	case updateFocus(isFocused: Bool)
	case updateToggle(isOn: Bool)
}

struct ExampleEnvironment {}

let exampleReducer = Reducer<
	ExampleState,
	ExampleAction,
	ExampleEnvironment
> { state, action, _ in
	switch action {
	case .updateFocus(let isFocused):
		state.isFocused = isFocused
		return .none
	case .updateToggle(let value):
		state.isOn = value
		return .none
	}
}

struct ExampleView: View {
	let store: Store<ExampleState, ExampleAction>
	var body: some View {
		WithViewStore(store) { viewStore in
			Toggle(
				isOn: viewStore.binding(
					get: { $0.isOn },
					send: ExampleAction.updateToggle
				),
				label: {
					ExampleBodyView(
						title: viewStore.title,
						isFocused:  { viewStore.send(.updateFocus(isFocused: $0)) }
					)
				}
			)
		}
	}
}

struct ExampleBodyView: View  {
	let title: String
	var isFocused: (Bool) -> Void
	@Environment(\.isFocused) private var systemFocused: Bool
	var body: some View {
		isFocused(systemFocused)
		return Text(systemFocused ? title + " - focused"  : title)
	}
}

Hi @miko, good question!

We have an idea of how this could be done, but we're not very familiar with how focus works in SwiftUI+tvOS and we weren't able to get a working demo locally. Do you have a basic, vanilla SwiftUI example of how focus works? If so then we could build a case study to add to the repo.

Thanks!

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 ( https://developer.apple.com/documentation/uikit/focus-based_navigation/creating_custom_navigation_interactions ).
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

@miko awesome! This was very helpful. Now I get how focus works with SwiftUI.

The good news is that we definitely have a way to handle focus in TCA... the bad news is that it relies on the new onChange modifier in SwiftUI, which allows you to be notified when a state value changes. We can use this to determine when some state changes, which lets us know when to reset the focus.

Here's a simple example. It displays 3 buttons, and clicking the last button will cause one of the other two to become focused:

struct AppState: Equatable {
  var isButton1Focused = true
  var isButton2Focused = false
}

enum AppAction {
  case focusButtonClicked
}

let reducer = Reducer<AppState, AppAction, Void> { state, action, _ in
  switch action {
  case .focusButtonClicked:
    state.isButton1Focused.toggle()
    state.isButton2Focused.toggle()
    return .none
  }
}

struct ContentView: View {
  let store: Store<AppState, AppAction>

  @Environment(\.resetFocus) var resetFocus
  @Namespace private var namespace

  var body: some View {
    WithViewStore(self.store) { viewStore in
      VStack {
        Button("One") {}
          .onChange(of: viewStore.isButton1Focused) { isFocused in
            if isFocused {
              self.resetFocus(in: self.namespace)
            }
          }
          .prefersDefaultFocus(viewStore.isButton1Focused, in: self.namespace)

        Button("Two") {}
          .onChange(of: viewStore.isButton2Focused) { isFocused in
            if isFocused {
              self.resetFocus(in: self.namespace)
            }
          }
          .prefersDefaultFocus(viewStore.isButton2Focused, in: self.namespace)

        Button("Focus") { viewStore.send(.focusButtonClicked) }
      }
      .focusScope(self.namespace)
    }
  }
}

Hopefully this helps you!

Oh actually, the focus APIs is all iOS 14 anyway, so it's not a big deal that you have to use the .onChange API to interface with it.

Thank you very much! :slight_smile:
That was the part that I've been missing.

Terms of Service

Privacy Policy

Cookie Policy