10623169
10623169

Reputation: 1044

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.

Upvotes: 2

Views: 4687

Answers (2)

10623169
10623169

Reputation: 1044

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"

Upvotes: 0

Duncan C
Duncan C

Reputation: 131418

Very well written question. Especially for a newbie. (Voted.) You clearly state the problem you're having, including illustrations and your current code.

The solution I proposed in another thread was to subclass UIPageControl and have it implement a didSet on its currentPage property. You can then have the page control notify the view controller of the current page index. (By giving your custom subclass a delegate property, by sending a notification center message, or whatever method best fits your needs.)

(I did a simple test of this approach and it worked. I didn't test exhaustively however.)

The fact that the UIPageViewController reliably updates the page control but that there's no reliable, obvious way to figure out the current page index seems like an oversight in the design of this class.

Upvotes: 1

Related Questions