You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.23 15:40:12