swiftuicollectionviewfocustvos

UICollectionView resets focus when reloading data after presenting another VC


In certain conditions, on tvOS, a collection view will reset its focus on reloadData(). Below is the smallest amount of code that reproduces the problem.

import SwiftUI

@main
struct DemosApp_tvOS: App {
    var body: some Scene {
        WindowGroup {
            HomeView()
        }
    }
}


public struct HomeView: UIViewControllerRepresentable {
    public func makeUIViewController(context: Context) -> HomeViewController { .init() }

    public func updateUIViewController(_ uiViewController: HomeViewController, context: Context) { }
}

public final class HomeViewController: UIViewController {
    public override func viewDidAppear(_ animated: Bool) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.present(CollectionViewController(collectionViewLayout: UICollectionViewFlowLayout()), animated: true)
        }
    }
}

public final class CollectionViewController: UICollectionViewController {
    public override func viewDidLoad() {
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
    }

    public override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
        super.pressesEnded(presses, with: event)
        if presses.contains(where: { $0.type == .playPause }) {
            collectionView.reloadData()
        }
    }

    public override func numberOfSections(in collectionView: UICollectionView) -> Int { 1 }

    public override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 7 }

    public override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
        cell.backgroundColor = .gray
        cell.layer.borderColor = UIColor.blue.cgColor
        return cell
    }

    public override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        present(UIViewController(), animated: true)
    }

    public override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
        context.previouslyFocusedView?.layer.borderWidth = 0
        context.nextFocusedView?.layer.borderWidth = 4
    }
}

Basically, the following conditions need to be fulfilled:

Before the reloadData() call occurs, the focus correctly remains on the selected item.

However, as soon as the reloadData() call happens, the focus resets to the first cell. And this happens only the first time after returning from the details VC.

Subsequent data reloads don't reset the focus, the only problematic call is that first reload after the details VC is dismissed.

Also, if the collection view VC is not presented modally over another VC, then the reload issue doesn't happen.

Does anybody know a solution, or a workaround to this problem? One that doesn't involve re-designing the UI hierarchy.


P.S. I tried toying with the shouldUpdateFocus(in:), and indexPathForPreferredFocusedView(in:) delegate methods, but with no success.


Solution

  • I filed an Apple report on this (FB13262879), meanwhile I searched for a workaround, and found one.

    The workaround involves in setting collectionView.remembersLastFocusedIndexPath = false, and "manually" managing the collection view focus:

    private var lastFocusedIndexPath: IndexPath?
    public func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
        lastFocusedIndexPath = context.nextFocusedIndexPath
    }
    
    public func indexPathForPreferredFocusedView(in collectionView: UICollectionView) -> IndexPath? {
        return lastFocusedIndexPath
    }
    

    The last piece of the puzzle is to force the collection view to restore the focus after it incorrectly resets it:

    let indexPathToRestoreFocus = lastFocusedIndexPath
    collectionView.reloadData()
    CATransaction.setCompletionBlock { [self] in
        lastFocusedIndexPath = indexPathToRestoreFocus
        collectionView.setNeedsFocusUpdate()
        collectionView.updateFocusIfNeeded()
    }
    

    Not a nice workaround, and might break in the future, hopefully Apple fixes the UIKit bug by then.