iosswiftuipageviewcontrollerright-to-left

UIPageViewController: Reverse .scroll animation direction for right-to-left languages


Is it possible to reverse the animation direction for UIPageViewController for right-to-left languages when using UIPageViewController.TransitionStyle.scroll?

Swiping left and right works inverts correctly for right-to-left, but my next button solution animates in the wrong direction (animates like it does for left-to-right).

My current solution to do so is to set pageControl.semanticContentAttribute = .forceLeftToRight, and to inverse the viewModels so that index 0 for right-to-left is the last index (viewModels.count - 1), as well as inverting the animation direction + viewControllerBefore/viewControllerAfter, e.g.:

But I would like a solution that simply changes the animation direction out-of-the-box, like it seems to be possible when changing the spineLocation for UIPageViewController.TransitionStyle.pageCurl, like this question states.

Animations

Note that for the How it is by default the page comes from the right, but the page control indicator moves in the opposite (expected) direction.

How it is by default Expected Functionality
current functionality enter image description here

Default Implementation:

import UIKit

struct ViewModel {
    var index: Int
    var text: String
    var color: UIColor
}

class DummyViewController : UIViewController {
    let label = UILabel()
    
    let vm: ViewModel
    
    let index: Int
    
    init(vm: ViewModel) {
        self.vm = vm
        self.index = vm.index
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    
    override func loadView() {
        let view = UIView()
        view.backgroundColor = vm.color
        
        let label = UILabel()
        label.frame = CGRect(x: 20, y: 200, width: 200, height: 20)
        label.textColor = .black
        label.text = vm.text
        
        view.addSubview(label)
        
        self.view = view
    }
}

class ViewController : UIViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {
    let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
    
    let pageControl = UIPageControl()
    
    let nextButton = UIButton()
    
    var isRightToLeft: Bool {
        traitCollection.layoutDirection == .rightToLeft
    }
    
    var currentIndex: Int = 0 {
        didSet {
            pageControl.currentPage = currentIndex
        }
    }
    
    var numberOfPages: Int { return viewModels.count }
    
    var viewModels: [ViewModel] = [
        ViewModel(index: 0, text: "First", color: .red),
        ViewModel(index: 1, text: "Second", color: .blue),
        ViewModel(index: 2, text: "Third", color: .green),
    ]
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        
        pageViewController.dataSource = self
        pageViewController.delegate = self
        
        pageControl.currentPage = currentIndex
        pageControl.numberOfPages = numberOfPages
        
        nextButton.setTitle("Next", for: .normal)
        nextButton.setTitleColor(.black, for: .normal)
    }
    
    override func viewDidLoad() {
        addChild(pageViewController)
        
        pageViewController.didMove(toParent: self)
        
        view.addSubview(pageViewController.view)
        view.addSubview(pageControl)
        view.addSubview(nextButton)
        
        view.subviews.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
        
        NSLayoutConstraint.activate([
            pageViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
            pageViewController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
            pageViewController.view.rightAnchor.constraint(equalTo: view.rightAnchor),
            pageViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            pageControl.centerYAnchor.constraint(equalTo: nextButton.centerYAnchor),
            
            nextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            nextButton.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
        ])
        
        nextButton.addTarget(self, action: #selector(nextTapped), for: .primaryActionTriggered)
        
        loadFirstPage()
    }
    
    func loadFirstPage() {
        let startIndex: Int
        startIndex = 0
        
        guard let start = viewControllerAtIndex(startIndex) else {
            return
        }
        
        pageViewController.setViewControllers([start], direction: .forward, animated: true, completion: nil)
    }
    
    func transitionFrom(index: Int, inDirection direction: UIPageViewController.NavigationDirection) {
        let nextIndex = direction == .forward ? index + 1 : index - 1
        
        guard let next = viewControllerAtIndex(nextIndex) else {
            return
        }
        pageViewController.setViewControllers([next], direction: direction, animated: true, completion: { finished in
            self.pageViewController(self.pageViewController, didFinishAnimating: finished, previousViewControllers: [], transitionCompleted: finished)
        })
    }
    
    func viewControllerAtIndex(_ index: Int) -> DummyViewController? {
        guard index >= 0 && index < numberOfPages  else { return nil }
        let viewModel = viewModels[index]
        return DummyViewController(vm: viewModel)
    }
    
    @objc
    func nextTapped(_ sender: UIButton) {
        guard currentIndex < numberOfPages - 1 else {
            print("ending because \(currentIndex)")
            return
        }
        
        let direction: UIPageViewController.NavigationDirection = .forward
        transitionFrom(index: currentIndex, inDirection: direction)
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let vc = viewController as? DummyViewController else { return nil }
        let nextIndex: Int = vc.index - 1
        return viewControllerAtIndex(nextIndex)
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let vc = viewController as? DummyViewController else { return nil }
        let nextIndex: Int = vc.index + 1
        return viewControllerAtIndex(nextIndex)
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        guard let viewController = pageViewController.viewControllers?.first as? DummyViewController else { return }
        currentIndex = viewController.index // Update currentIndex, which updates pageControl.currentPage
    }
}

Manual reversing to support right-to-left (solution I'm trying to avoid).

Excluded ViewModel and DummyViewController as they're unchanged.

import UIKit

class ViewController : UIViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {
    let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
    
    let pageControl = UIPageControl()
    
    let nextButton = UIButton()
    
    var isRightToLeft: Bool {
        traitCollection.layoutDirection == .rightToLeft
    }
    
    var currentIndex: Int = 0 {
        didSet {
            pageControl.currentPage = currentIndex
        }
    }
    
    var numberOfPages: Int {
        return viewModels.count
    }
    
    var viewModels: [ViewModel] = [
        ViewModel(index: 0, text: "First", color: .red),
        ViewModel(index: 1, text: "Second", color: .blue),
        ViewModel(index: 2, text: "Third", color: .green),
    ]
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        
        pageViewController.dataSource = self
        pageViewController.delegate = self
        
        pageControl.currentPage = currentIndex
        pageControl.numberOfPages = numberOfPages
        
        pageControl.semanticContentAttribute = .forceLeftToRight
        
        nextButton.setTitle("Next", for: .normal)
        nextButton.setTitleColor(.black, for: .normal)
        
        if isRightToLeft { // HERE
            viewModels = viewModels.map({ viewModel in
                ViewModel(index: (numberOfPages - viewModel.index - 1), text: viewModel.text, color: viewModel.color)
            }).reversed()
        }
    }
    
    override func viewDidLoad() {
        addChild(pageViewController)
        
        pageViewController.didMove(toParent: self)
        
        view.addSubview(pageViewController.view)
        view.addSubview(pageControl)
        view.addSubview(nextButton)
        
        view.subviews.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
        
        NSLayoutConstraint.activate([
            pageViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
            pageViewController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
            pageViewController.view.rightAnchor.constraint(equalTo: view.rightAnchor),
            pageViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            pageControl.centerYAnchor.constraint(equalTo: nextButton.centerYAnchor),
            
            nextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            nextButton.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
        ])
        
        nextButton.addTarget(self, action: #selector(nextTapped), for: .primaryActionTriggered)
        
        loadFirstPage()
    }
    
    func loadFirstPage() {
        let startIndex: Int
        if isRightToLeft { // HERE
            startIndex = numberOfPages - 1
            currentIndex = startIndex
        } else {
            startIndex = 0
        }
        
        guard let start = viewControllerAtIndex(startIndex) else {
            return
        }
        
        pageViewController.setViewControllers([start], direction: .forward, animated: true, completion: nil)
    }
    
    func transitionFrom(index: Int, inDirection direction: UIPageViewController.NavigationDirection) { // UNCHANGED
        let nextIndex = direction == .forward ? index + 1 : index - 1
        
        guard let next = viewControllerAtIndex(nextIndex) else {
            return
        }
        pageViewController.setViewControllers([next], direction: direction, animated: true, completion: { finished in
            self.pageViewController(self.pageViewController, didFinishAnimating: finished, previousViewControllers: [], transitionCompleted: finished)
        })
    }
    
    func viewControllerAtIndex(_ index: Int) -> DummyViewController? {
        guard index >= 0 && index < numberOfPages  else { return nil }
        let viewModel = viewModels[index]
        return DummyViewController(vm: vm)
    }
    
    @objc
    func nextTapped(_ sender: UIButton) {
        guard (currentIndex < numberOfPages - 1 && !isRightToLeft) || (currentIndex > 0 && isRightToLeft) else { // HERE
            return
        }
        let direction: UIPageViewController.NavigationDirection = isRightToLeft ? .reverse : .forward // HERE
        transitionFrom(index: currentIndex, inDirection: direction)
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let vc = viewController as? DummyViewController else {
            return nil
        }
        
        let nextIndex: Int
        if isRightToLeft { // HERE
            nextIndex = vc.index + 1
        } else {
            nextIndex = vc.index - 1
        }
        
        return viewControllerAtIndex(nextIndex)
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let vc = viewController as? DummyViewController else {
            return nil
        }
        
        let nextIndex: Int
        if isRightToLeft { // HERE
            nextIndex = vc.index - 1
        } else {
            nextIndex = vc.index + 1
        }
        
        return viewControllerAtIndex(nextIndex)
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        guard let viewController = pageViewController.viewControllers?.first as? DummyViewController else {
            return
        }
        
        currentIndex = viewController.index
    }
}



Solution

  • Turns out this is much simpler than I realized - setting the direction simply changes the animation direction, it doesn't change what index/view controller is going to be presented.

    As such, the solution is simply changing transitionFrom to:

        func transitionTo(nextIndex: Int, inDirection direction: UIPageViewController.NavigationDirection) {
            // Removed the `next` setting here in favor of getting it passed from `nextTapped`
            guard let next = viewControllerAtIndex(nextIndex) else {
                return
            }
            pageViewController.setViewControllers([next], direction: direction, animated: true, completion: { finished in
                self.pageViewController(self.pageViewController, didFinishAnimating: finished, previousViewControllers: [], transitionCompleted: finished)
            })
        }
    

    and changing nextTapped to:

        @objc
        func nextTapped(_ sender: UIButton) {
            guard currentIndex < numberOfPages - 1 else { return }
            
            let direction: UIPageViewController.NavigationDirection = isRightToLeft ? .reverse : .forward // This is key
            transitionTo(nextIndex: currentIndex + 1, inDirection: direction)
        }
    

    Alternate, less intrusive solution

    Alternatively, a less intrusive solution would be to subclass UIPageViewController and simply flip the animation direction if we're in a right-to-left locale.

    private extension UIPageViewController.NavigationDirection {
        var flipped: Self {
            switch self {
            case .forward:
                return .reverse
            case .reverse:
                return .forward
            @unknown default:
                return .reverse
            }
        }
    }
    
    class LocalizedPageViewController: UIPageViewController {
        override func setViewControllers(
            _ viewControllers: [UIViewController]?,
            direction: UIPageViewController.NavigationDirection,
            animated: Bool,
            completion: ((Bool) -> Void)? = nil
        ) {
            let isRTL = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
            let direction = isRTL ? direction.flipped : direction
            super.setViewControllers(viewControllers, direction: direction, animated: animated, completion: completion)
        }
    }
    

    Then I would simply need to change my initialization of the UIPageViewController to LocalizedPageViewController, and no other changes are needed!