3

I want to implement paging on a welcome screen in iOS app (iOS 13, swift 5.2, xcode 11.5).

For this purpose, I use a UICollectionView and UIPageControl. Now I need to bind pageControl to Collection view.

At first, I tried to use UIScrollViewDelegate but soon found out that it does not work with the compositional layout. Then I discovered visibleItemsInvalidationHandler which is available for compositional layout sections. I tried different options like this:

section.visibleItemsInvalidationHandler = { (visibleItems, point, env) -> Void in
   self.pageControl.currentPage = visibleItems.last?.indexPath.row ?? 0 
}

and like this:

section.visibleItemsInvalidationHandler = { items, contentOffset, environment in
    let currentPage = Int(max(0, round(contentOffset.x / environment.container.contentSize.width)))
    self.pageControl.currentPage = currentPage
}

but nothing works...

Seems like that callback is not triggering at all. If I place a print statement inside of it it is not executed.

Please find below the whole code:

import Foundation
import UIKit

class WelcomeVC: UIViewController, UICollectionViewDelegate {
    
    //MARK: - PROPERTIES
    var cardsDataSource: UICollectionViewDiffableDataSource<Int, WalkthroughCard>! = nil
    var cardsSnapshot = NSDiffableDataSourceSnapshot<Int, WalkthroughCard>()
    var cards: [WalkthroughCard]!
    var currentPage = 0
    
    
    //MARK: - OUTLETS
    @IBOutlet weak var signInButton: PrimaryButton!
    
    @IBOutlet weak var walkthroughCollectionView: UICollectionView!
    
    @IBOutlet weak var pageControl: UIPageControl!
    
    //MARK: - VIEW DID LOAD
    override func viewDidLoad() {
        super.viewDidLoad()
        walkthroughCollectionView.isScrollEnabled = true
        walkthroughCollectionView.delegate = self
        setupCards()
        pageControl.numberOfPages = cards.count
        configureCardsDataSource()
        configureCardsLayout()
    }
   
    //MARK: - SETUP CARDS
    
    func setupCards() {
        cards = [
            WalkthroughCard(title: "Welcome to abc", image: "Hello", description: "abc is an assistant to your xyz account at asdf"),
            WalkthroughCard(title: "Have all asdf projects at your fingertips", image: "Graphs", description: "Enjoy all project related data whithin a few taps. Even offline")
        ]
    }
    
    //MARK: - COLLECTION VIEW DIFFABLE DATA SOURCE
    
    private func configureCardsDataSource() {
        cardsDataSource = UICollectionViewDiffableDataSource<Int, WalkthroughCard>(collectionView: walkthroughCollectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, card: WalkthroughCard) -> UICollectionViewCell? in
            // Create cell
            guard let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: "WalkthroughCollectionViewCell",
                for: indexPath) as? WalkthroughCollectionViewCell else { fatalError("Cannot create new cell") }
            //cell.layer.cornerRadius = 15
            cell.walkthroughImage.image = UIImage(named: card.image)
            cell.walkthroughTitle.text = card.title
            cell.walkthroughDescription.text = card.description
            return cell
        }
        setupCardsSnapshot()
    }
    
    private func setupCardsSnapshot() {
        cardsSnapshot = NSDiffableDataSourceSnapshot<Int, WalkthroughCard>()
        cardsSnapshot.appendSections([0])
        cardsSnapshot.appendItems(cards)
        cardsDataSource.apply(self.cardsSnapshot, animatingDifferences: true)
    }
    
    
    //MARK: - CONFIGURE COLLECTION VIEW LAYOUT
    func configureCardsLayout() {
        walkthroughCollectionView.collectionViewLayout = generateCardsLayout()
    }
    
    func generateCardsLayout() -> UICollectionViewLayout {
        
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1.0))
        let fullPhotoItem = NSCollectionLayoutItem(layoutSize: itemSize)

        
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1.0))
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: groupSize,
            subitem: fullPhotoItem,
            count: 1
        )
        
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPagingCentered
        let layout = UICollectionViewCompositionalLayout(section: section)
        
        //setup pageControl
        section.visibleItemsInvalidationHandler = { (items, offset, env) -> Void in
            self.pageControl.currentPage = items.last?.indexPath.row ?? 0
        }
        
        return layout
    }
    
 
}
Oleg Titov
  • 51
  • 1
  • 5
  • Is there any reason you are not using UIPageViewController? – Feridun Erbaş Sep 27 '20 at 13:13
  • @FeridunErbaş thanks for asking. I am confident using collection views, and UIPageViewController is a new thing for me. I spent a lot of time watching tutorials and reading articles on it, but they all seem outdated and the whole code looks cumbersome comparing to modern collection views. So I guess I don't use it since I haven't seen any good and Swifty way of using UIPageViewController. – Oleg Titov Sep 27 '20 at 14:29
  • For me it did work with visibleItemsInvalidationHandler but the result also weren't satisfying. So I'm also very curious about how this works. – Cronay Oct 29 '20 at 23:33

5 Answers5

3

It should be working if you set up the invalidation handler for the section before passing the section to the layout:

let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPagingCentered
section.visibleItemsInvalidationHandler = { (items, offset, env) -> Void in
    self.pageControl.currentPage = items.last?.indexPath.row ?? 0
}

return UICollectionViewCompositionalLayout(section: section)
Dimitri
  • 176
  • 1
  • 8
  • In my case this `visibleItemsInvalidationHandler`-thing is getting called multiple times per pagign-event. Very annoying. – nayooti Mar 02 '21 at 16:13
  • Also it's very slow, it is waiting for invalidate before get real value :) – iTux Jun 05 '21 at 18:52
1

Maybe this solution might helps.

It's pretty simple and it worked smoothly for me

section.visibleItemsInvalidationHandler = { [weak self] _, offset, environment in
    guard let self else { return }
    
    let pageWidth = environment.container.contentSize.width
    let currentPage = Int((offset.x / pageWidth).rounded())
    self.pageControl.currentPage = currentPage
}
0

I think you should give a chance to UIPageViewController. I'm attaching its implementation so that you can easily integrate with your code.

You can design your slider items in a separate UIViewController and use wherever area you want within your main view for UIPageViewController.

Cheers!

import UIKit
import SnapKit

class WelcomeViewController: BaseViewController<WelcomeViewModel> {
        
    @IBOutlet weak var pageControl: UIPageControl!
    @IBOutlet weak var viewForPageController: UIView!

    private var pageViewController: UIPageViewController!
    private var introductionSliderViewControllers: [UIViewController] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.welcomeSlidersLoaded = { [weak self] sliders in
            self?.loadSliders(sliders: sliders)
        }
     
    }
    
    private func loadSliders(sliders: [IntroductionSliderViewModel]) {
        guard sliders.count > 0 else { return }
        self.introductionSliderViewControllers = sliders.map{
            IntroductionSliderViewController(viewModel: $0)
        }
        self.initializePageViewController()
    }
    
    private func initializePageViewController(){
        
        self.pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
        self.viewForPageController.addSubview(self.pageViewController.view)
        self.addChild(pageViewController)
        self.pageViewController.view.snp.makeConstraints { (make) in
            make.edges.equalToSuperview()
        }
        self.pageViewController.delegate = self
        self.pageViewController.dataSource = self
        
        self.pageViewController.setViewControllers([introductionSliderViewControllers[0]], direction: .forward, animated: false, completion: nil)
        
        self.pageControl.numberOfPages = introductionSliderViewControllers.count
        self.pageControl.currentPage = 0
        
    }

}

extension WelcomeViewController: UIPageViewControllerDataSource{
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let index = self.introductionSliderViewControllers.firstIndex(of: viewController),
            index > 0 else{return nil}
        return self.introductionSliderViewControllers[index - 1]
        
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let index = self.introductionSliderViewControllers.firstIndex(of: viewController),
            index < self.introductionSliderViewControllers.count - 1 else{return nil}
        return self.introductionSliderViewControllers[index + 1]
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if let vc = pageViewController.viewControllers?.first, let index = self.introductionSliderViewControllers.firstIndex(of: vc){
            pageControl.currentPage = index
        }
    }
}
Feridun Erbaş
  • 573
  • 1
  • 6
  • 19
0

This should work

section.visibleItemsInvalidationHandler = { items, contentOffset, environment in
      let currentPage = Int(max(0, round(contentOffset.x / environment.container.contentSize.width)))
      self.pageControl.currentPage = currentPage
}
phitsch
  • 823
  • 13
  • 12
0

I know this question is old, but I think it can help someone:

section.visibleItemsInvalidationHandler = { [weak self] (items, offset, env) -> Void in
            guard let self = self,
                  let itemWidth = items.last?.bounds.width else { return }
            
            // This offset is different from a scrollView. It increases by the item width + the spacing between items.
            // So we need to divide the offset by the sum of them.
            let page = round(offset.x / (itemWidth + section.interGroupSpacing))
            
            self.didChangeCollectionViewPage(to: Int(page))
        }

As I commented in the code snippet, the offset here is different, it sums the item width and the section spacing, so instead of dividing the offset by the content width, you need to divide it by the item width and the intergroup spacing.

It may not help you if you have different item widths, but I'm my case, where all the items have the same width, it works.