Crashes occurring with the viewControllerBefore and viewControllerAfter methods of UIPageViewController.

We are developing an app using UIPageViewController. We have configured the UIPageViewController to provide 1 or 2 pages with a .pageCurl animation.

We discovered a scenario where the viewControllerBefore and viewControllerAfter methods of the UIPageViewControllerDataSource are being called excessively. This happens when dragging on the last page, including dragging along the vertical axis (e.g., dragging from the top right to the bottom left). In this scenario, crashes occur intermittently, and we have observed similar issues in Apple Books as well.

We would like to eliminate or minimize these crashes. Unfortunately, due to design constraints, we cannot remove the animation or adjust the page transition speed.

Questions:

  1. Are there any updates or news regarding this issue, such as changes in the UIKit framework?
  2. What is the best way to prevent or minimize this crash?

Crash Informations:

  • The number of view controllers provided (0) doesn't match the number required (2) for the requested transition
  • The number of view controllers provided (0) doesn't match the number required (1) for the requested transition

Crash Videos:

  • https://www.dropbox.com/scl/fo/bz7ykvm41du29u03ywbwo/AHO1y7CxURIi7s2QrERxPZk?rlkey=ugavf4tqo22q60g5bexe3kguz&e=1&st=xao8ypm6&dl=0

I am also experiencing this problem and have submitted feedback about it (FB14999139).

My debugging results are very similar to yours: the gesture recognizer calls viewControllerBefore and then immediately calls viewControllerAfter, which effectively tries to navigate backwards with the nil result from the viewControllerAfter method, causing the crash.

The best workaround I have found is to explicitly account for these rapid viewControllerBefore viewControllerAfter calls. I did this by comparing the times that they are called, and if they are called too close together, presenting a special view controller.

Presenting a unique view controller that is outside of the pages that I would like flipped has the benefit, at least in terms of how this sample code works, of being caught by the guard statements in both viewControllerBefore and viewControllerAfter methods, effectively disabling gesture navigation. Gesture navigation is only re-enabled by manually pressing the button that displays a view controller from the pre-defined range. To me, this maintains a spatial location better than flipping to the same last page over and over again.

This is not perfect. The user can trigger it by quickly tapping forward and back, but for now I feel like the unique view controller still handles that gracefully. Better than crashing the app!

Here is the example from my test app.

import UIKit
import SwiftUI

class MyPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
    
    let viewControllersList: [UIViewController] = {
        let colors: [UIColor] = [.orange, .green, .blue]
        
        let viewControllers: [UIViewController] = colors.map { color in
            let vc = UIViewController()
            vc.view.backgroundColor = color
            return vc
        }
        
        return viewControllers
    }()
    
    /// Time that viewControllerBefore is called.
    var beforeTime: TimeInterval = Date.timeIntervalSinceReferenceDate
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        dataSource = self
        delegate = self
        
        // Set initial view controller
        if let firstVC = viewControllersList.first {
            setViewControllers([firstVC], direction: .forward, animated: true, completion: nil)
        }
    }
    
    // MARK: - UIPageViewControllerDataSource
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let currentIndex = viewControllersList.firstIndex(of: viewController) else { return nil }
        // update the time that this method is called
        beforeTime = Date.timeIntervalSinceReferenceDate
        let previousIndex = currentIndex - 1
        return previousIndex >= 0 ? viewControllersList[previousIndex] : nil
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let currentIndex = viewControllersList.firstIndex(of: viewController) else { return nil }
        // compare the time interval since the before method was called
        let currentTime = Date.timeIntervalSinceReferenceDate
        let delta = currentTime - beforeTime
        
        // guard against the methods being called too quickly.
        // I found 0.01 would still allow the crash to happen so went with something longer.
        // Note that if the user manually tries to navigate back and forth, this can be triggered.
        guard delta > 0.08 else {
            print("rapid calls detected")
            // a SwiftUI view with a button that calls the `goHomeAction`
            let goHomeVC = UIHostingController(rootView: GoHomeView(goHomeAction: { [weak self] in
                guard let self = self else { return }
                self.setViewControllers([viewControllersList[0]], direction: .reverse, animated: true)
            }))
            return goHomeVC }
        let nextIndex = currentIndex + 1
        return nextIndex < viewControllersList.count ? viewControllersList[nextIndex] : nil
    }
}

//MARK: - SwiftUI View

struct GoHomeView: View {
    var goHomeAction: () -> Void
    
    var body: some View {
        Button("Go Home") {
            goHomeAction()
        }
    }
}

FWIW, I have also submitted a support request ticket. I hope there is a better solution than this. I will update if I learn anything more.

Hi @tc_matthew ,

Thank you so much for your response. We recently resolved this issue as well, and it seems to be working well. I wanted to share my comments on your suggestion. Since your method and approach are different, it seems like we can choose based on the specific situation.

We registered a UIGestureRecognizer with the UIPageViewController and decided whether to handle the gesture in gestureRecognizerShouldBegin. This method is useful when you can determine the problematic page.

extension MyPageViewController: UIGestureRecognizerDelegate {

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
            return false
        }

        let x = panGestureRecognizer.translation(in: panGestureRecognizer.view).x

        if let leftPage = pageViewController.viewControllers?.first,
           let leftPageIndex = pages.firstIndex(of: leftPage) {
            if leftPageIndex == 0 {
                let isRightToLeftGesture = x < 0
                return !isRightToLeftGesture
            }
        }

        if let rightPage = pageViewController.viewControllers?.last,
           let index = pages.firstIndex(of: rightPage) {
            if index == pages.count - 1 {
                let isLeftToRightGesture = x > 0
                return !isLeftToRightGesture
            }
        }

        return false
    }
}

(sample commit / The description is in Korean, but the code itself is straightforward.)

Thanks again for your help!

Crashes occurring with the viewControllerBefore and viewControllerAfter methods of UIPageViewController.
 
 
Q