tvOS 14手动处理焦点状态:@FocusState与.focusable的替代方案
在tvOS 14中手动处理焦点状态的替代方案
由于@FocusState属性包装器和.focusable修饰器仅在tvOS 15及以上版本可用,tvOS 14中需要通过结合UIKit原生焦点机制、通知监听或组件封装的方式手动管理焦点状态,以下是几种可行方案及最佳实践:
1. 监听系统焦点通知(最小改动方案)
tvOS会在焦点变化时发送UIFocusDidUpdateNotification通知,我们可以在SwiftUI视图中监听该通知,手动判断当前视图是否获得焦点。
修改你提供的MyView代码如下:
struct MyView: View, Equatable { // 用@State手动管理焦点状态,替代@FocusState @State var isFocused: Bool = false @State var screenSize: CGSize @Binding var selectedfield: MenuFields var menuAction: ((MenuFields) -> Void) @State var isMenufocused: Bool = false var body: some View { ZStack { HStack { Spacer().frame(width: Constants.spacerWidth) MenuStackView(isMenufocused: $isMenufocused, selectedfield: $selectedfield, menuAction: menuAction) .frame(width: Constants.menStackViewWidth) .onAppear { // 注册焦点更新通知 NotificationCenter.default.addObserver( forName: UIFocusDidUpdateNotification, object: nil, queue: .main ) { notification in guard let focusedView = notification.userInfo?[UIFocusUpdatedNextFocusedViewKey] as? UIView else { isFocused = false return } // 判断焦点视图是否属于当前View的层级 let hostingView = UIHostingController(rootView: self).view isFocused = focusedView.isDescendant(of: hostingView!) isMenufocused = isFocused } } .onDisappear { // 移除通知监听,避免内存泄漏 NotificationCenter.default.removeObserver(self, name: UIFocusDidUpdateNotification, object: nil) } Spacer() } } .focusSection() } }
2. 自定义PreferenceKey追踪焦点
通过自定义PreferenceKey标记可聚焦视图,在父视图中统一监听焦点归属变化,适合多组件的复杂布局场景。
步骤1:定义PreferenceKey
struct FocusPreferenceKey: PreferenceKey { static var defaultValue: String? = nil static func reduce(value: inout String?, nextValue: () -> String?) { value = nextValue() ?? value } }
步骤2:在子视图中标记焦点标识
修改MenuStackView,为内部可聚焦元素添加偏好标记:
struct MenuStackView: View { @Binding var isMenufocused: Bool @Binding var selectedfield: MenuFields var menuAction: ((MenuFields) -> Void) var body: some View { // 示例:可聚焦的菜单按钮 Button(action: { menuAction(selectedfield) }) { Text("菜单选项") } .preference(key: FocusPreferenceKey.self, value: "menuStackView") .onReceive(NotificationCenter.default.publisher(for: UIFocusDidUpdateNotification)) { _ in // 检查自身是否获得焦点 let focusedView = UIApplication.shared.keyWindow?.focusedView let hostingView = UIHostingController(rootView: self).view isMenufocused = focusedView?.isDescendant(of: hostingView!) ?? false } } }
步骤3:在父视图中监听偏好变化
struct MyView: View, Equatable { @State var screenSize: CGSize @Binding var selectedfield: MenuFields var menuAction: ((MenuFields) -> Void) @State var isMenufocused: Bool = false var body: some View { ZStack { HStack { Spacer().frame(width: Constants.spacerWidth) MenuStackView(isMenufocused: $isMenufocused, selectedfield: $selectedfield, menuAction: menuAction) .frame(width: Constants.menStackViewWidth) Spacer() } } .focusSection() .onPreferenceChange(FocusPreferenceKey.self) { key in isMenufocused = key == "menuStackView" } } }
3. 封装UIKit组件(复杂场景首选)
对于菜单、列表这类复杂交互的视图,直接封装UIKit的UICollectionView或UITableView是更可靠的方案——tvOS 14中UIKit的焦点管理机制已经非常成熟,通过UIViewRepresentable桥接到SwiftUI即可:
示例:封装UICollectionView
struct MenuCollectionView: UIViewRepresentable { @Binding var selectedfield: MenuFields var menuAction: ((MenuFields) -> Void) func makeUIView(context: Context) -> UICollectionView { let layout = UICollectionViewFlowLayout() layout.itemSize = CGSize(width: 200, height: 80) layout.scrollDirection = .vertical let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = context.coordinator collectionView.dataSource = context.coordinator collectionView.register(MenuCell.self, forCellWithReuseIdentifier: "MenuCell") return collectionView } func updateUIView(_ uiView: UICollectionView, context: Context) { // 根据selectedfield更新选中状态 if let index = MenuFields.allCases.firstIndex(of: selectedfield) { uiView.selectItem(at: IndexPath(item: index, section: 0), animated: true, scrollPosition: .centeredVertically) } } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UICollectionViewDelegate, UICollectionViewDataSource { var parent: MenuCollectionView init(_ parent: MenuCollectionView) { self.parent = parent } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { MenuFields.allCases.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MenuCell", for: indexPath) as! MenuCell let field = MenuFields.allCases[indexPath.item] cell.textLabel.text = field.rawValue cell.backgroundColor = collectionView.indexPathForFocusedItem == indexPath ? .blue : .gray return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let field = MenuFields.allCases[indexPath.item] parent.menuAction(field) } func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { // 处理焦点切换动画 if let nextIndex = context.nextFocusedIndexPath, let cell = collectionView.cellForItem(at: nextIndex) as? MenuCell { coordinator.addCoordinatedAnimations({ cell.backgroundColor = .blue }, completion: nil) let field = MenuFields.allCases[nextIndex.item] parent.selectedfield = field } if let prevIndex = context.previouslyFocusedIndexPath, let cell = collectionView.cellForItem(at: prevIndex) as? MenuCell { coordinator.addCoordinatedAnimations({ cell.backgroundColor = .gray }, completion: nil) } } } } class MenuCell: UICollectionViewCell { let textLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) setupLayout() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupLayout() { textLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(textLabel) NSLayoutConstraint.activate([ textLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), textLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) ]) textLabel.textColor = .white } }
在MyView中使用封装组件
struct MyView: View, Equatable { @State var screenSize: CGSize @Binding var selectedfield: MenuFields var menuAction: ((MenuFields) -> Void) var body: some View { ZStack { HStack { Spacer().frame(width: Constants.spacerWidth) MenuCollectionView(selectedfield: $selectedfield, menuAction: menuAction) .frame(width: Constants.menStackViewWidth) Spacer() } } .focusSection() } }
最佳实践
- 简单视图优先使用系统通知监听方案,代码改动量小,适配成本低
- 复杂列表/菜单场景建议直接封装UIKit组件,焦点切换逻辑更稳定,动画过渡更自然
- 避免在多层嵌套视图中分散管理焦点状态,尽量在父视图或协调器中集中处理
- 测试时需覆盖遥控器上下左右全方向导航,确保焦点切换无卡顿、无丢失
内容的提问来源于stack exchange,提问作者Abhinav Jha
相关产品推荐
相关产品推荐

