How to manually select next focused index path in collection view

1.2k Views Asked by At

How can I manually decide the next focused index path for my collection view on tvOS?

My use case is that I have a collection view with dynamic cells that scrolls in all directions, and sometimes when a cell in one section spans across several cells in the next one, I want to be able to manually decide what cell gets focused next.

The default behaviour seems to be that the collection view will select the cell in the middle of the previous one.

For example, the green cell is currently focused. When I navigate down, the collection view wants to focus the red cell, but I want the blue cell to be focused next.

enter image description here

My initial idea was to implement the collectionView(_:shouldUpdateFocusIn:) delegate function, and return false + trigger a focus update, when the collection view selects the "wrong" index path.

However, for some reason, shouldUpdateFocusIn gets called several times when I return false from it and causes a visible lag.

func collectionView(_ collectionView: UICollectionView, shouldUpdateFocusIn context: UICollectionViewFocusUpdateContext) -> Bool {
    if let nextIndexPath = shouldFocusCell(context.focusHeading, (context.previouslyFocusedIndexPath, context.nextFocusedIndexPath)) {
        // The collection view did not select the index path of the cell we want to focus next.
        // Save the correct index path and trigger a focus update.
        lastFocusChange = (lastFocusChange.next, nextIndexPath)
        setNeedsFocusUpdate()
        // Using updateFocusIfNeeded() results in the following warning:
        // WARNING: Calling updateFocusIfNeeded while a focus update is in progress. This call will be ignored.
        return false
    }
    return true
}

Next idea was to do the same thing in collectionView(_:didUpdateFocusIn:with:), but in this case we only update the focus after it has already moved to the "wrong" cell, so it becomes apparent to the user that the focus moves from the wrong cell to the correct one.

Not ideal either.

I'm using my own subclass of UICollectionViewLayout and UICollectionView, but I don't see anything I can override to be able to manually decide what index path to focus next when navigating up/down/left/right before shouldUpdateFocusIn is called.

Is there any way I can achieve this?

2

There are 2 best solutions below

2
On

One possibility could be to use collectionView(_ collectionView:, canFocusItemAt:) to let your collectionView know if a given indexPath can receive the focus.

You can find here below a naive implementation of this concept. You will need to adjust the maths on it to your needs.

func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool {
    guard let currentlyFocusedCellLayoutAttributes = collectionView.layoutAttributesForItem(at: focusedIndexPath) else { return false }
    guard let cellAtGivenIndexPathLayoutAttributes = collectionView.layoutAttributesForItem(at: indexPath) else { return false }
        
    let currentlyFocusedCellOriginX = currentlyFocusedCellLayoutAttributes.frame.origin.x
    let currentlyFocusedCellOriginY = currentlyFocusedCellLayoutAttributes.frame.origin.y
    let currentlyFocusedCellWidth = currentlyFocusedCellLayoutAttributes.frame.width
        
    let cellAtGivenIndexPathOriginX = cellAtGivenIndexPathLayoutAttributes.frame.origin.x
    let cellAtGivenIndexPathOriginY = cellAtGivenIndexPathLayoutAttributes.frame.origin.y
    let cellAtGivenIndexPathWidth = cellAtGivenIndexPathLayoutAttributes.frame.width
            
    let offsetX = collectionView.contentOffset.x

    // Scrolling horizontally is always allowed
    if currentlyFocusedCellOriginY == cellAtGivenIndexPathOriginY {
        return true
    }
    
    // Scrolling vertically is only allowed to the first cell (blue cell in the screenshot)
    if cellAtGivenIndexPathOriginX <= offsetX {
        return true
    }
    
    return false
}
1
On

We can achieve by using UICollectionView delegate protocol method, pls find code snippt.

class ViewController: UIViewController {

@IBOutlet weak var sideMenuTableView: UITableView!
@IBOutlet weak var collectionView: UICollectionView!
let menueItem = ["Home", "Sports", "Movies", "Listen", "Play", "Game"]
let colors: [UIColor] = [.green, .blue, .purple, .orange, .yellow, .magenta, .brown, .black, .gray, .yellow, .green, .lightGray, .cyan, .magenta, .link, .blue, .yellow, .magenta, .brown, .black, .gray, .yellow, .green, .lightGray, .cyan, .magenta, .link, .blue]
var lastFocusedIndexPath: IndexPath?

override func viewDidLoad() {
    super.viewDidLoad()
    lastFocusedIndexPath = IndexPath(row: 2, section: 0)
    initialConfiguration()
}

override var preferredFocusEnvironments : [UIFocusEnvironment] {
    return [collectionView]
}

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    updateTableViewContentInset()
}
    
func updateTableViewContentInset() {
    self.sideMenuTableView.contentInset = UIEdgeInsets(top: 0, left: -40, bottom:  0, right: 0)
}

func initialConfiguration() {
    sideMenuTableView.register(UINib.init(nibName: "SideMenuTVCell", bundle: nil), forCellReuseIdentifier: "SideMenuTVCell")
    sideMenuTableView.delegate = self
    sideMenuTableView.dataSource = self
    sideMenuTableView.backgroundColor = .yellow
    sideMenuTableView.isHidden = false
    
    collectionView.register(UINib.init(nibName: "ColorCVCell", bundle: nil), forCellWithReuseIdentifier: "ColorCVCell")
    collectionView.delegate = self
    collectionView.dataSource = self
    collectionView.backgroundColor = .white
}

}

extension ViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return menueItem.count }

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "SideMenuTVCell", for: indexPath) as! SideMenuTVCell
    cell.configureCell(string: menueItem[indexPath.row])
    return cell
}

}

extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return colors.count }

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ColorCVCell", for: indexPath) as! ColorCVCell
    cell.backgroundColor = colors[indexPath.row]
    cell.configureCell(color: colors[indexPath.row])
    return cell
}


func collectionView(_ collectionView: UICollectionView, didUpdateFocusIn context: UICollectionViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
    if let previousIndexPath = context.previouslyFocusedIndexPath,
       let cell = collectionView.cellForItem(at: previousIndexPath) {
        cell.contentView.layer.borderWidth = 0.0
        cell.contentView.layer.shadowRadius = 0.0
        cell.contentView.layer.shadowOpacity = 0
    }

    if let indexPath = context.nextFocusedIndexPath,
       let cell = collectionView.cellForItem(at: indexPath) {
        cell.contentView.layer.borderWidth = 8.0
        cell.contentView.layer.borderColor = UIColor.black.cgColor
        cell.contentView.layer.shadowColor = UIColor.black.cgColor
        cell.contentView.layer.shadowRadius = 10.0
        cell.contentView.layer.shadowOpacity = 0.9
        cell.contentView.layer.shadowOffset = CGSize(width: 0, height: 0)
    }
            
    if let indexPath = context.previouslyFocusedIndexPath, let cell = collectionView.cellForItem(at: indexPath) {
        UIView.animate(withDuration: 0.3) { () -> Void in
            cell.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
        }
    }

    if let indexPath = context.nextFocusedIndexPath, let cell = collectionView.cellForItem(at: indexPath) {
        UIView.animate(withDuration: 0.3) { () -> Void in
            cell.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
        }
    }
}

func collectionView(_ collectionView: UICollectionView, shouldUpdateFocusIn context: UICollectionViewFocusUpdateContext) -> Bool {
    if let previouslyFocusedIndexPath = context.previouslyFocusedIndexPath, let cell = collectionView.cellForItem(at: previouslyFocusedIndexPath) {
        let collectionViewWidth = collectionView.frame.width
        let cellWidth = cell.frame.width
        let rowCount = Int(ceil(collectionViewWidth / cellWidth))
        let remender = previouslyFocusedIndexPath.row % rowCount
        let nextIndex = previouslyFocusedIndexPath.row - remender + rowCount
        if let nextFocusedInndexPath = context.nextFocusedIndexPath {
            if context.focusHeading == .down {
                moveFocus(to: IndexPath(row: nextIndex, section: 0))
                return true
            }
        }
    }
    return true
}

private func moveFocus(to indexPath: IndexPath) {
    lastFocusedIndexPath = indexPath
    print(collectionView.indexPathsForVisibleItems)
    DispatchQueue.main.async {
        self.setNeedsFocusUpdate()
        self.updateFocusIfNeeded()
    }
}

func indexPathForPreferredFocusedView(in collectionView: UICollectionView) -> IndexPath? {
    return lastFocusedIndexPath
}

}