Paging is achieved by using UIPageView from SwiftUI. I am having trouble with the same page being displayed twice in a row when swiping through pages. The condition that causes this phenomenon is the speed of page turning. This problem does not occur when the page is turned slowly or very quickly, but it does occur when the page is turned at an intermediate speed. Here is a video of this phenomenon.
The full code for reproduction is attached below. Your assistance would be greatly appreciated. Thank you in advance.
struct ContentView: View {
@State var currentPage: Int = 2
var body: some View {
VStack {
Text(currentPage.description)
.font(.largeTitle)
PageView(
pages: Array(0...100).map({ num in
Text("Page of \(num)")
.font(.largeTitle)
.padding(100)
.background(Color(.systemOrange))
.cornerRadius(10)
}),
currentPage: $currentPage)
}
}
}
struct PageView<Page: View>: UIViewControllerRepresentable {
var pages: [Page]
@Binding var currentPage: Int
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [UIPageViewController.OptionsKey.interPageSpacing: 30])
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
context.coordinator.controllers = pages.map({
let hostingController = UIHostingController(rootView: $0)
hostingController.view.backgroundColor = .clear
return hostingController
})
let currentViewController = context.coordinator.controllers[currentPage]
pageViewController.setViewControllers([currentViewController], direction: .forward, animated: false)
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self, pages: pages)
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageView
var controllers: [UIViewController]
init(parent: PageView, pages: [Page]) {
self.parent = parent
self.controllers = pages.map({
let hostingController = UIHostingController(rootView: $0)
hostingController.view.backgroundColor = .clear
return hostingController
})
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = controllers.firstIndex(of: viewController) else {
return controllers[parent.currentPage - 1]
}
if index == 0 {
return nil
}
return controllers[index - 1]
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = controllers.firstIndex(of: viewController) else {
return controllers[parent.currentPage + 1]
}
if index + 1 == controllers.count {
return nil
}
return controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let currentViewController = pageViewController.viewControllers?.first,
let currentIndex = controllers.firstIndex(of: currentViewController)
{
parent.currentPage = currentIndex
}
}
}
}
We also tried to realize paging from SwiftUI such as TabView and ScrollView. However, the screen I wanted to display was very heavy, and the method using UIKit's UIPageView was the only one that had almost no screen nibbling.
In didFinishAnimating
, you only update currentPage
when you can find the current view controller in controllers
:
if completed,
let currentViewController = pageViewController.viewControllers?.first,
let currentIndex = controllers.firstIndex(of: currentViewController)
{
parent.currentPage = currentIndex
}
Consider what happens when you set parent.currentPage
- updateUIViewController
will be called, and you give a whole new array of VCs to the coordinator. If at this point didFinishAnimating
is called again, currentViewController
still contains the old instances. It will not find currentViewController
in controllers
, and fail to set currentPage
.
Notice that setViewControllers
only changes pageViewController.viewControllers
after the animation completes.
So to fix this, just don't give new UIHostingController
s every time the view updates:
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
let currentViewController = context.coordinator.controllers[currentPage]
pageViewController.setViewControllers([currentViewController], direction: .forward, animated: false)
}
This works if you have a constant set of pages. Otherwise, this means that when pages
change, you won't be able to update the view controllers.
A better solution would be to use a data-oriented approach, like all other SwiftUI views do.
The idea is that PageView
should take a list of data, instead of a list of View
s. It would also take a function that converts the data to View
s.
The coordinator also works with the data. Using firstIndex(of:)
on the data array, and only creates VCs when necessary.
Here is a rough sketch of how to do this. It works, but it can probably be optimised by using Identifiable
and a Dictionary
instead. The VCs made by makeVC
could also be reused if the data hasn't changed.
struct PageView<Data: Equatable, Page: View>: UIViewControllerRepresentable {
let pages: [Data]
@Binding var currentPage: Int
@ViewBuilder let pageView: (Data) -> Page
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [UIPageViewController.OptionsKey.interPageSpacing: 30])
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
context.coordinator.pages = pages
context.coordinator.pageView = pageView
let currentViewController = context.coordinator.makeVC(page: pages[currentPage])
pageViewController.setViewControllers([currentViewController], direction: .forward, animated: false)
context.coordinator.pageDidChange = { currentPage = $0 }
}
func makeCoordinator() -> Coordinator {
return Coordinator(pages: pages, pageView: pageView)
}
class PageHostingController: UIHostingController<Page> {
let data: Data
init(rootView: Page, data: Data) {
self.data = data
super.init(rootView: rootView)
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var pageDidChange: ((Int) -> Void)?
var pages: [Data]
var pageView: (Data) -> Page
init(pages: [Data], @ViewBuilder pageView: @escaping (Data) -> Page) {
self.pages = pages
self.pageView = pageView
}
func makeVC(page: Data) -> PageHostingController {
let hostingController = PageHostingController(rootView: pageView(page), data: page)
hostingController.view.backgroundColor = .clear
return hostingController
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let pageHost = viewController as? PageHostingController,
let index = pages.firstIndex(of: pageHost.data) else {
return nil
}
if index == 0 {
return nil
}
return makeVC(page: pages[index - 1])
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let pageHost = viewController as? PageHostingController,
let index = pages.firstIndex(of: pageHost.data) else {
return nil
}
if index + 1 == pages.count {
return nil
}
return makeVC(page: pages[index + 1])
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let currentViewController = pageViewController.viewControllers?.first as? PageHostingController,
let currentIndex = pages.firstIndex(of: currentViewController.data)
{
pageDidChange?(currentIndex)
}
}
}
}
Example usage:
@State var currentPage: Int = 2
@State var pages = [1,2,3]
var body: some View {
VStack {
Text(currentPage.description)
.font(.largeTitle)
PageView(
pages: pages,
currentPage: $currentPage) { num in
Text("Page of \(num)")
.font(.largeTitle)
.padding(100)
.background(Color(.systemOrange))
.cornerRadius(10)
}
Button("Add") {
pages.append(pages.count + 1)
}
}
}