在 UIPageViewController 中可靠地跟踪页面索引 (Swift)

Reliably track Page Index in a UIPageViewController (Swift)

问题:

我有一个母版 UIPageViewController (MainPageVC),其中包含三个嵌入式页面视图(ABC),它们都可以访问使用滑动手势并按下 MainPageVC 中自定义页面指示器*中的适当位置(*不是真正的 UIPageControl 但由三个 ToggleButton 组成 - [=20 的简单重新实现=] 成为切换按钮)。我的设置如下:

往期阅读: , , and UIPageViewController: return the current visible view 表示最好的方法是使用 didFinishAnimating 调用,并手动跟踪当前页面索引,但我发现这不能处理某些边缘情况。

我一直在尝试提供一种安全的方式来跟踪当前页面索引(使用 didFinishAnimatingwillTransitionTo 方法)但是我在处理用户处于的边缘情况时遇到了问题查看 A,然后一直滑动到 C(没有抬起手指),然后超过 C,然后松开手指...在这种情况下 didFinishAnimating 没有被调用,应用程序仍然认为它在 A 中(即 A 切换按钮仍然被按下并且 pageIndex 没有被 viewControllerBeforeviewControllerAfter 方法)。

我的代码:

@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
    }
}

我不知道如何处理这种边缘情况,问题是如果用户随后尝试在 C 中按下一些应该否则保证存在,并抛出意外的 nilindexOutOfBounds 错误。

问题写得很好。特别是对于一个新手。 (已投票。)您清楚地说明了您遇到的问题,包括插图和您当前的代码。

我在另一个线程中提出的解决方案是 subclass UIPageControl 并让它在其 currentPage 属性 上实现一个 didSet。然后,您可以让页面控件将当前页面索引通知给视图控制器。 (通过给你的自定义 subclass 一个委托 属性,通过发送通知中心消息,或者任何最适合你需要的方法。)

(我对这种方法做了一个简单的测试,它奏效了。但是我没有进行详尽的测试。)

UIPageViewController 可靠地更新页面控件,但没有可靠、明显的方法来确定当前页面索引这一事实似乎是此 class.

设计中的疏忽

自己的解决方案

我找到了解决方法:不要使用UIPageView(Controller),使用CollectionView(Controller)反而。跟踪集合视图的位置比尝试手动跟踪 [=13] 中的当前页面要容易 MUCH =].

解决方法如下:

方法

  • MainPagerVC 重构为 CollectionView(Controller)(或作为符合 UICollectionViewDelegate UICollectionViewDataSource 协议的常规 VC)。
  • 将每个页面(aVCbVCcVC)设置为 UICollectionViewCell 个子类(MainCell)。
  • 设置每个页面以填充屏幕边界内的 MainPagerVC.collectionView - CGSize(width: view.frame.width, height: collectionView.bounds.height)
  • 将顶部的切换按钮(ABC)重构为三个 UICollectionViewCell 子类(MenuCellMenuController(本身就是一个 UICollectionViewController.
  • 由于集合视图继承自 UIScrollView,您可以实现 scrollViewDidScrollscrollViewDidEndScrollingAnimationscrollViewWillEndDragging 方法,以及委托(使用 didSelectItemAt indexPath)来耦合MainPagerVCMenuController 集合视图。

代码

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()
    }
}

参考资料

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