swiftuikituipageviewcontrolleruiviewanimationios16

How to properly animate UIPageViewController's view while page curl animated transition is happening?


The effect I'm trying to achieve: link to video (note that in this video the issue is present).

Basically, a simultaneous page curl and translation on the x axis (since the video was registered in landscape, the translation appears on the y axis).

The page curl animation is performed under the hood whenever setViewControllers method of UIPageViewController is called, while the translation is achieved through UIView.animate by updating constraints.

Until iOS 15, this worked perfectly fine, however since iOS 16 the animation is laggy and does not work as intended, see below.

  1. Regular page curl transition (no translation): the back side of the page is fully visible. This was also the behaviour with translation on iOS 14 and 15.
  2. Page curl with translation: the back side of the page is partially transparent.

The issue is clearly related to the x translation occurring while the curl animation is being performed, as without it everything works fine. Probably this is not the right way to perform multiple animations I assume, however since the page curl happens under the hood I'm not really sure on how to handle it.

My question is: how can I achieve a page curl and x translation animations simultaneously? And on a side note, does anyone knows why the strange behaviour I encountered happens only on newer versions of iOS?

EDIT: to further clarify what I mean, this is a slow-mo video of curl+x translation where the issue is visible. The part of the vc highlighted with red lines should not be transparent, but red. This is a bug.

This is the curl animation with no x translation, in which there is no transparency issue / no bugs.

Sample project which reproduces the issue - note, it is intended to work only in landscape:

import UIKit

final class ViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
    
    private var pageController: UIPageViewController?
    private var pageControllerViewLeadingAnchor: NSLayoutConstraint?

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // init
        pageController = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
        pageController?.dataSource = self
        pageController?.delegate = self
        
        // adding it
        addChild(pageController!)
        view.addSubview(pageController!.view)
        
        // constraints
        pageController?.view.translatesAutoresizingMaskIntoConstraints = false
        pageController?.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        pageController?.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        pageControllerViewLeadingAnchor = pageController?.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: -view.bounds.width/4)
        pageControllerViewLeadingAnchor?.isActive = true
        pageController?.view.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
    }
        
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        DispatchQueue.main.asyncAfter(deadline: .now()+0.5) {
            
            // x translation animation
            UIView.animate(withDuration: 0.3, animations: {
                self.pageControllerViewLeadingAnchor?.constant = 0
                self.view.layoutIfNeeded()
            })
            
            // pageController curl animation
            let vc = UIViewController()
            let vc2 = UIViewController()
            vc.view.backgroundColor = .red
            vc2.view.backgroundColor = .blue
            self.pageController?.setViewControllers([vc, vc2], direction: .forward, animated: true, completion: nil)
        }
    }

    func pageViewController(_ pageViewController: UIPageViewController, spineLocationFor orientation: UIInterfaceOrientation) -> UIPageViewController.SpineLocation {
        
        // setting spine to mid and adding vcs
        let vc = UIViewController()
        let vc2 = UIViewController()
        vc2.view.backgroundColor = .green
        pageController?.setViewControllers([vc, vc2], direction: .forward, animated: true, completion: nil)
        pageController?.isDoubleSided = true
        return .mid
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        return UIViewController()
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        return UIViewController()
    }

}

Solution

  • You may want to try embedding the page controller in a "container" UIView ... then animate the container into place.

    Give this a try:

    final class ViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
        
        private var pageController: UIPageViewController?
        private var containerViewLeadingAnchor: NSLayoutConstraint?
        
        private let pageControllerContainerView = UIView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // init
            pageController = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
            pageController?.dataSource = self
            pageController?.delegate = self
            
            // adding it
            addChild(pageController!)
            
            // add the container view
            pageControllerContainerView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(pageControllerContainerView)
            
            // add the page controller view as a subview of theh container view
            pageControllerContainerView.addSubview(pageController!.view)
            
            // constrain page controller view to all 4 sides of the container view
            pageController?.view.translatesAutoresizingMaskIntoConstraints = false
            pageController?.view.topAnchor.constraint(equalTo: pageControllerContainerView.topAnchor).isActive = true
            pageController?.view.bottomAnchor.constraint(equalTo: pageControllerContainerView.bottomAnchor).isActive = true
            pageController?.view.leadingAnchor.constraint(equalTo: pageControllerContainerView.leadingAnchor).isActive = true
            pageController?.view.trailingAnchor.constraint(equalTo: pageControllerContainerView.trailingAnchor).isActive = true
            
            guard let g = view else { return }
            NSLayoutConstraint.activate([
                // constrain container view Top / Bottom / Width
                pageControllerContainerView.topAnchor.constraint(equalTo: g.topAnchor),
                pageControllerContainerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
                pageControllerContainerView.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 1.0),
            ])
            
            // container view leading constant will be set in viewDidLayoutSubviews()
            containerViewLeadingAnchor = pageControllerContainerView.leadingAnchor.constraint(equalTo: g.leadingAnchor)
            containerViewLeadingAnchor?.isActive = true
            
            pageControllerContainerView.clipsToBounds = true
        }
        
        // track the container width
        var cvw: CGFloat = -1
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            
            // only execute if the container view frame width has changed
            //  (such as on launch)
            if cvw != pageControllerContainerView.frame.width {
                cvw = pageControllerContainerView.frame.width
                containerViewLeadingAnchor?.constant = -cvw * 0.25
            }
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            
            DispatchQueue.main.asyncAfter(deadline: .now()+1.5) {
                
                // pageController curl animation
                let vc = UIViewController()
                let vc2 = UIViewController()
                
                vc.view.backgroundColor = .red
                vc2.view.backgroundColor = .blue
    
                self.pageController?.setViewControllers([vc, vc2], direction: .forward, animated: true, completion: nil)
                
                // x translation animation
                UIView.animate(withDuration: 0.3, animations: {
                    self.containerViewLeadingAnchor?.constant = 0
                    self.view.layoutIfNeeded()
                })
                
            }
            
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, spineLocationFor orientation: UIInterfaceOrientation) -> UIPageViewController.SpineLocation {
            
            // setting spine to mid and adding vcs
            let vc = UIViewController()
            let vc2 = UIViewController()
            
            vc2.view.backgroundColor = .green
            pageController?.setViewControllers([vc, vc2], direction: .forward, animated: true, completion: nil)
            pageController?.isDoubleSided = true
            return .mid
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
            return UIViewController()
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
            return UIViewController()
        }
        
    }
    

    Edit

    Adding pageControllerContainerView.clipsToBounds = true appears to resolve the issue.