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.:
viewControllerBefore
becomes: nextIndex = isRightToLeft ? vc.index - 1 : vc.index + 1
(and vice-versa for viewControllerAfter
)nextTapped
's inDirection
parameter for transitionFrom
changes to isRightToLeft ? .reverse : .forward
loadFirstPage
to set the startIndex
to numberOfPages - 1
(aka viewModels.count - 1
) rather than 0viewModels.count - 1
(in init
):
if isRightToLeft {
viewModels = viewModels.map({ viewModel in
ViewModel(index: (numberOfPages - viewModel.index - 1), text: viewModel.text, color: viewModel.color)
}).reversed()
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.
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.
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
}
}
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
}
}
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)
}
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!