9

I currently have a UICollectionView using UICollectionViewCompositionalLayout. I would like to animate some views within the current visible cells while scrolling / scrolling stops.

Unfortunately it seems setting orthogonalScrollingBehavior on a section to anything but .none hijacks the UICollectionView accompanying UIScrollView delegate methods.

Was wondering if there're any current workaround for this? To get the paging behaviour and UIScrollView delegate?

Setup layout

  enum Section {
    case main
  }

  override func awakeFromNib() {
    super.awakeFromNib()
    collectionView.collectionViewLayout = createLayout()
    collectionView.delegate = self
  }

  func configure() {
    snapshot.appendSections([.main])
    snapshot.appendItems(Array(0..<10))
    dataSource.apply(snapshot, animatingDifferences: false)
  }

 private func createLayout() -> UICollectionViewLayout {
    let leadingItem = NSCollectionLayoutItem(
      layoutSize: NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .fractionalHeight(1.0))
    )

    leadingItem.contentInsets = .zero

    let containerGroup = NSCollectionLayoutGroup.horizontal(
      layoutSize: NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .fractionalHeight(1.0)
      ),
      subitems: [leadingItem])

    let section = NSCollectionLayoutSection(group: containerGroup)
    section.orthogonalScrollingBehavior = .groupPaging // WOULD LIKE PAGING & UISCROLLVIEW TO ALSO BE FIRED

    let config = UICollectionViewCompositionalLayoutConfiguration()
    config.scrollDirection = .horizontal

    let layout = UICollectionViewCompositionalLayout(section: section, configuration: config)
    return layout
  }

UICollectionViewDelegate

extension SlidingCardView: UICollectionViewDelegate {

  func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
    // THIS IS FIRED BUT UISCROLLVIEW METHODS NOT
  }

  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    print(111)
  }


  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    print("1111111")
  }
}
kye
  • 2,166
  • 3
  • 27
  • 41

4 Answers4

10

Setting orthogonalScrollingBehavior to a section, embeds an internal _UICollectionViewOrthogonalScrollerEmbeddedScrollView which handles the scrolling in a section. This internal scrollview is added as a subview to your collection view.

When you set yourself as a delegate to your collection view you should receive the scroll view delegate callbacks BUT ONLY for the main collection view, that scrolls between the sections and not the items in a section. Since the internal scrollviews (which may also be collectionViews, not sure) are completely different instances and you are not setting yourself as a delegate to them, you are not receiving their callbacks.

So as far as i know, there should not be an official way to receive these callbacks from the internal scrollviews that handle the scrolling in sections.

but if you are curious and you want to experiment with that you could use this 'hacked' collectionView class:

import UIKit

final class OrtogonalScrollingCollectionView: UICollectionView {

    override var delegate: UICollectionViewDelegate? {
        get { super.delegate }
        set {
            super.delegate = newValue
            subviews.forEach { (view) in
                guard String(describing: type(of: view)) == "_UICollectionViewOrthogonalScrollerEmbeddedScrollView" else { return }
                guard let scrollView = view as? UIScrollView else { return }
                scrollView.delegate = newValue
            }
        }
    }
}

that would set your delegate to all internal scrollview that come with the orthogonal sections. You should not be using this in production environment, because there is no guarantee that Apple will keep the inner workings of the collection views the same way so this hack may not work in the future, plus you might get rejected for using private APIs in UIKit when you submit a build for release.

Stoyan
  • 1,265
  • 11
  • 20
8

You may just want to use visibleItemsInvalidationHandler callback of your NSCollectionLayoutSection it acts like the UIScrollViewDelegate it will be invoked each time the section scrolls

let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPagingCentered

section.visibleItemsInvalidationHandler = { (visibleItems, point, env) -> Void in
   print(point)
}
Alaeddine
  • 6,104
  • 3
  • 28
  • 45
  • I was trying to set correct page control page and did this. section.visibleItemsInvalidationHandler = { (visibleItems, point, env) -> Void in self.pageControl.currentPage = visibleItems.last?.indexPath.row ?? 0 }. If scroll too fast, there are a few hitches, but otherwise, it correctly shows current page. – Thet Htun Aug 02 '20 at 08:39
  • @ThetHtun I'd try to put the code that adjusts the view into the main queue `DispatchQueue.main.async { /*update view here*/ }` That helps quite often with those delays that seem to happen when you update certain elements in the view. – Marco Boerner Aug 13 '20 at 13:17
  • 1
    Unfortunately this can't substitute `scrollViewDidEndDecelerating`. This closure apparently gets called every time the visible item changes while scrolling. I've also found it gets called multiple times, which can make it hard to deal with. It would have been nice if Apple didn't break this delegate functionality with compositional layouts. – Drew Oct 06 '21 at 18:59
1

Here is a solution for determining which cell is in the center of the screen:

section.visibleItemsInvalidationHandler = { [weak self] visibleItems, point, environment in
    guard let self = self else { return }

    for visibleCell in self.collectionView.visibleCells {
      let collectionViewCenterPoint = self.collectionView.center
      
      if let relativePoint = visibleCell.superview?.convert(collectionViewCenterPoint, from: nil),
          visibleCell.frame.contains(relativePoint)
      {
        // visibleCell is in the center of the view.
      } else {
        // visibleCell is outside the center of the view.
      }
    }
  }
Kwalker108
  • 472
  • 4
  • 9
0

Following @Stoyan answer, I fine tuned the class to be compatible with producition code by not looking for private APIs. Simply looking at all UIScrollView subclasses.

Also I think it's better to update the delegates during collection reload as you might not have the full view hierarchy yet when setting the delegate.

Finally, the class now recursively looks for UIScrollView so nothing is ever missed.

final class OrthogonalScrollingCollectionView: UICollectionView {
  override func reloadData() {
    super.reloadData()

    scrollViews(in: self).forEach { scrollView in
      scrollView.delegate = delegate
    }
  }

  override func reloadSections(_ sections: IndexSet) {
    super.reloadSections(sections)

    scrollViews(in: self).forEach { scrollView in
      scrollView.delegate = delegate
    }
  }

  fileprivate func scrollViews(in subview: UIView) -> [UIScrollView] {
    var scrollViews: [UIScrollView] = []
    subview.subviews.forEach { view in
      if let scrollView = view as? UIScrollView {
        scrollViews.append(scrollView)
      } else {
        scrollViews.append(contentsOf: self.scrollViews(in: view))
      }
    }
    return scrollViews
  }
}
apouche
  • 9,703
  • 6
  • 40
  • 45