UIPageViewController:反转从右到左语言的滚动动画方向

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

当使用 UIPageViewController.TransitionStyle.scroll 时,是否可以为从右到左的语言反转 UIPageViewController 的动画方向?

向左和向右滑动可以正确地反转从右到左的方向,但我的下一个按钮解决方案的动画方向错误(动画就像从左到右那样)。

我目前的解决方案是设置 pageControl.semanticContentAttribute = .forceLeftToRight,并反转 viewModels,以便从右到左的索引 0 是最后一个索引 (viewModels.count - 1) ,以及反转动画方向 + viewControllerBefore/viewControllerAfter,例如:

但我想要一个简单地改变开箱即用的动画方向的解决方案,就像在为 UIPageViewController.TransitionStyle.pageCurl 更改 spineLocation 时似乎是可能的,比如 [=39] =] 状态。

动画

请注意,对于 How it is by default,页面来自右侧,但页面控件指示器向相反(预期)方向移动。

How it is by default Expected Functionality

默认实现:

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 { [=11=].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
    }
}

手动倒车以支持从右到左(我试图避免的解决方案)。

排除了 ViewModelDummyViewController,因为它们没有变化。

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 { [=12=].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
    }
}


事实证明这比我意识到的 简单 - 设置 direction 只是改变 animation 方向,它不会' 更改 index/view 将要显示的控制器。

因此,解决方案只是将 transitionFrom 更改为:

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

并将 nextTapped 更改为:

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

替代的、侵入性较小的解决方案

或者,一个侵入性较小的解决方案是子类化 UIPageViewController,如果我们处于从右到左的区域设置,只需翻转动画方向即可。

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

那么我只需要将 UIPageViewController 的初始化更改为 LocalizedPageViewController,不需要其他更改!