Search code examples
iosswiftuipageviewcontrolleruipagecontrol

Reliably track Page Index in a UIPageViewController (Swift)


The problem:

I have a master UIPageViewController (MainPageVC) with three imbedded page views (A, B, & C) that are accessible both with swipe gestures and by pressing the appropriate locations in a custom page indicator* in the MainPageVC (*not a true UIPageControl but comprised of three ToggleButtons - a simple reimplementation of UIButton to become a toggle-button). My setup is as follows:

Schematic of my view hierarchy

Previous reading: Reliable way to track Page Index in a UIPageViewController - Swift, A reliable way to get UIPageViewController current index, and UIPageViewController: return the current visible view indicated that the best way to do this was with didFinishAnimating calls, and manually keep track of the current page index, but I'm finding that this does not deal with certain edge cases.

I have been trying to produce a safe way of keeping track of the current page index (with didFinishAnimating and willTransitionTo methods) but am having trouble with the edge case where a user is in view A, and then swipes all the way across to C (without lifting up their finger), and then beyond C, and then releasing their finger... in this instance didFinishAnimating isn't called and the app still believes it is in A (i.e. A toggle button is still pressed and pageIndex is not updated correctly by the viewControllerBefore and viewControllerAfter methods).

My code:

@IBOutlet weak var pagerView: UIView!
@IBOutlet weak var aButton: ToggleButton!
@IBOutlet weak var bButton: ToggleButton!
@IBOutlet weak var cButton: ToggleButton!

let viewControllerNames = ["aVC", "bVC", "cVC"]
lazy var buttonsArray = {
    [aButton, bButton, cButton]
}()
var previousPage = "aVC"

var pageVC: UIPageViewController?

func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
    print("TESTING - will transition to")

    let currentViewControllerClass = String(describing: pageViewController.viewControllers![0].classForCoder);
    let viewControllerIndex = viewControllerNames.index(of: currentViewControllerClass);

    if currentViewControllerClass == previousPage {
        return
    }

    let pastIndex = viewControllerNames.index(of: previousPage)
    if buttonsArray[pastIndex!]?.isOn == true {
        buttonsArray[pastIndex!]?.buttonPressed()
    }

    if let newPageButton = buttonsArray[viewControllerIndex!] {
        newPageButton.buttonPressed()
    }

    self.previousPage = currentViewControllerClass
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
    print("TESTING - did finish animating")

    let currentViewControllerClass = String(describing: pageViewController.viewControllers![0].classForCoder)
    let viewControllerIndex = viewControllerNames.index(of: currentViewControllerClass)

    if currentViewControllerClass == previousPage {
        return
    }

    let pastIndex = viewControllerNames.index(of: previousPage)
    if buttonsArray[pastIndex!]?.isOn == true {
        buttonsArray[pastIndex!]?.buttonPressed()
    }

    if let newPageButton = buttonsArray[viewControllerIndex!] {
        newPageButton.buttonPressed()
    }

    self.previousPage = currentViewControllerClass
}

func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
    let onboardingViewControllerClass = String(describing: viewController.classForCoder)
    let viewControllerIndex = viewControllerNames.index(of: onboardingViewControllerClass)
    let newViewControllerIndex = viewControllerIndex! - 1
    if(newViewControllerIndex < 0) {
        return nil
    } else {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let vc = storyboard.instantiateViewController(withIdentifier: viewControllerNames[newViewControllerIndex])
        if let vc = vc as? BaseTabVC {
            vc.mainPageVC = self
            vc.intendedCollectionViewHeight = pagerViewHeight
        }
        return vc
    }
}

func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
    let onboardingViewControllerClass = String(describing: viewController.classForCoder)
    let viewControllerIndex = viewControllerNames.index(of: onboardingViewControllerClass)
    let newViewControllerIndex = viewControllerIndex! + 1
    if(newViewControllerIndex > viewControllerNames.count - 1) {
        return nil
    } else {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let vc = storyboard.instantiateViewController(withIdentifier: viewControllerNames[newViewControllerIndex])
        if let vc = vc as? BaseTabVC {
            vc.mainPageVC = self
            vc.intendedCollectionViewHeight = pagerViewHeight
        }
        return vc
    }
}

I'm at a loss as to how to deal with this edge case, the problem is that it can lead to fatal crashes of the app if the user then tries to press something in C that should otherwise be guaranteed to exist, and an unexpected nil or indexOutOfBounds error is thrown.


Solution

  • Own Solution

    I found the solution to this: don't use a UIPageView(Controller), use a CollectionView(Controller) instead. It is MUCH easier to keep track of the position of a collection view than to try and manually keep track of the current page in a UIPageViewController.

    The solution is as follows:

    Method

    • Refactor MainPagerVC as a CollectionView(Controller) (or as a regular VC that conforms to the UICollectionViewDelegate UICollectionViewDataSource protocols).
    • Set each page (aVC, bVC, and cVC) as a UICollectionViewCell subclass (MainCell).
    • Set each of these pages to fill the MainPagerVC.collectionView within the screen's bounds - CGSize(width: view.frame.width, height: collectionView.bounds.height).
    • Refactor the toggle-buttons at the top (A, B, and C) as three UICollectionViewCell subclasses (MenuCell) in a MenuController (itself a UICollectionViewController.
    • As collection views inherit from UIScrollView you can implement scrollViewDidScroll, scrollViewDidEndScrollingAnimation and scrollViewWillEndDragging methods, along with delegation (with didSelectItemAt indexPath) to couple the MainPagerVC and MenuController collection views.

    Code

    class MainPagerVC: UIViewController, UICollectionViewDelegateFlowLayout {
    
        fileprivate let menuController = MenuVC(collectionViewLayout: UICollectionViewFlowLayout())
        fileprivate let cellId = "cellId"
    
        fileprivate let pages = ["aVC", "bVC", "cVC"]
    
        let collectionView: UICollectionView = {
            let layout = UICollectionViewFlowLayout()
            layout.minimumLineSpacing = 0
            layout.scrollDirection = .horizontal
            let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
            cv.backgroundColor = .white
            cv.showsVerticalScrollIndicator = false
            cv.showsHorizontalScrollIndicator = false
            return cv
        }()
    
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            menuController.delegate = self
    
            setupLayout()
        }
    
        fileprivate func setupLayout() {
            guard let menuView = menuController.view else { return }
    
            view.addSubview(menuView)
            view.addSubview(collectionView)
    
            collectionView.dataSource = self
            collectionView.delegate = self
    
    
            //Setup constraints (placing the menuView above the collectionView
    
            collectionView.register(MainCell.self, forCellWithReuseIdentifier: cellId)
    
            //Make the collection view behave like a pager view (no overscroll, paging enabled)
            collectionView.isPagingEnabled = true
            collectionView.bounces = false
            collectionView.allowsSelection = true
    
            menuController.collectionView.selectItem(at: [0, 0], animated: true, scrollPosition: .centeredHorizontally)
    
        }
    
    }
    
    extension MainPagerVC: MenuVCDelegate {
    
        // Delegate method implementation (scroll to the right page when the corresponding Menu "Button"(Item) is pressed
        func didTapMenuItem(indexPath: IndexPath) {
            collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
        }
    
    }
    
    extension MainPagerVC: UICollectionViewDelegate, UICollectionViewDataSource {
    
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            let x = scrollView.contentOffset.x
            let offset = x / pages.count
            menuController.menuBar.transform = CGAffineTransform(translationX: offset, y: 0)
        }
    
        func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
            let item = Int(scrollView.contentOffset.x / view.frame.width)
            let indexPath = IndexPath(item: item, section: 0)
            collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .bottom)
        }
    
        func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
            let x = targetContentOffset.pointee.x
            let item = Int(x / view.frame.width)
            let indexPath = IndexPath(item: item, section: 0)
            menuController.collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
        }
    
    
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return pages.count
        }
    
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! MainCell
    
            return cell
        }
    
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            return .init(width: view.frame.width, height: collectionView.bounds.height)
        }
    
    }
    
    class MainCell: UICollectionViewCell {
    
        override init(frame: CGRect) {
            super.init(frame: frame)
    
            // Custom UIColor extension to return a random colour (to check that everything is working)
            backgroundColor = UIColor().random()
    
        }
    
        required init?(coder aDecoder: NSCoder) {
            fatalError()
        }
    }
    
    protocol MenuVCDelegate {
        func didTapMenuItem(indexPath: IndexPath)
    }
    
    class MenuVC: UICollectionViewController, UICollectionViewDelegateFlowLayout {
    
        fileprivate let cellId = "cellId"
        fileprivate let menuItems = ["A", "B", "C"]
    
        var delegate: MenuVCDelegate?
    
        //Sliding bar indicator (slightly different from original question - like Reddit)
        let menuBar: UIView = {
            let v = UIView()
            v.backgroundColor = .red
            return v
        }()
    
        //1px view to visually separate MenuBar region from "pager"-views
        let menuSeparator: UIView = {
            let v = UIView()
            v.backgroundColor = .gray
            return v
        }()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            collectionView.backgroundColor = .white
            collectionView.allowsSelection = true
            collectionView.register(MenuCell.self, forCellWithReuseIdentifier: cellId)
    
            if let layout = collectionViewLayout as? UICollectionViewFlowLayout {
                layout.scrollDirection = .horizontal
                layout.minimumLineSpacing = 0
                layout.minimumInteritemSpacing = 0
            }
    
            //Add views and setup constraints for collection view, separator view and "selection indicator" view - the menuBar
        }
    
        override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
            delegate?.didTapMenuItem(indexPath: indexPath)
        }
    
        override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return menuItems.count
        }
    
        override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! MenuCell
            cell.label.text = menuItems[indexPath.item]
    
            return cell
        }
    
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            let width = view.frame.width
            return .init(width: width/CGFloat(menuItems.count), height: view.frame.height)
        }
    
    }
    
    class MenuCell: UICollectionViewCell {
    
        let label: UILabel = {
            let l = UILabel()
            l.text = "Menu Item"
            l.textAlignment = .center
            l.textColor = .gray
            return l
        }()
    
        override var isSelected: Bool {
            didSet {
                label.textColor = isSelected ? .black : .gray
            }
        }
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            //Add label to view and setup constraints to fill Cell
        }
    
        required init?(coder aDecoder: NSCoder) {
            fatalError()
        }
    }
    

    References

    1. A "Lets Build That App" YouTube Video: "We Made It on /r/iosprogramming! Live coding swiping pages feature"