In my SwiftUI app, I currently have a PageViewController implemented using UIKit. It follows the traditional SwiftUI - UIKit implementation outlined in Apple's SwiftUI UIKit integration tutorials.
I have the data that populates a UIViewController, within the controllers array that is passed to the PageViewController, provided by an @Environment
variable. In a different screen in the app, you can issue an action that causes the Environment object to update, triggering a re-render of a ViewController that lives within PageViewController.
This re-render, however, causes an issue as the ViewController is re-made with a new identifier and so the index of the viewController cannot be found in the parent.controller
array within the Class Coordinator pageViewController function. This causes the index to default to nil and disables any swiping on the updated viewController. I am still able to navigate between viewControllers using the page control dots but I would like to identify how I can update the parent.controller array to include the new ViewController and to discard the old one.
After hours of searching, debugging and tinkering around I've not been able to find a way how I can reset the parents controller array with the new ViewController replacing the old view which has been discarded. Below is the code for the PageViewController.
import SwiftUI
import UIKit
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.view.backgroundColor = UIColor.clear
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: false)
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController? {
print(parent.controllers)
print(viewController)
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController) {
parent.currentPage = index
}
}
}
}
After updating the environment state that populates the data for a given ViewController, causing a re-render, the coordinator class can print an array of controllers that include the old ViewController and also the new ViewController in the annotated code below but I have not yet been able to find a reliable way to ensure that the new ViewController effectively replaces the old one.
struct PageViewController: UIViewControllerRepresentable {
...
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
...
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController? {
print(parent.controllers)
// prints out array of ViewControllers including old ViewController that has now been
// discarded
// [<_TtGC7SwiftUI19UIHostingControllerVS_7AnyView_: 0x7fd595c93de0>,
// <_TtGC7SwiftUI19UIHostingControllerVS_7AnyView_: 0x7fd595c94be0>,
// <_TtGC7SwiftUI19UIHostingControllerVS_7AnyView_: 0x7fd595c96830>,
// <_TtGC7SwiftUI19UIHostingControllerVS_7AnyView_: 0x7fd595c976b0>]
print(viewController)
// prints out new ViewController that does not exist within the parent.controllers array
// and hence nil is returned from the guard
// <_TtGC7SwiftUI19UIHostingControllerVS_7AnyView_: 0x7fd593721710>
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}
...
}
Any help or guidance with this issue would be greatly appreciated!
Having the same issue, for few last hours, I found a work around.
It seems that it is a bug. If you look at the UIViewControllerRepresentable cycle, it should go from the Init > Coordinator > makeUIViewController > updateUIViewController.
But if the pages in the PageViewController get updated (our case). The pages get re-created by SwiftUI, great, but the PageViewController get an internal wrong creation cycle going from Init > updateUIViewController. Without makeUIViewController neither Coordinator. A new UIPageViewController is somehow created (How?) Leading to the discrepancy you noticed.
To solve the issue and force a proper recreation of the PageViewController, add .id(UUID) in your page view code, such as :
struct SGPageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@Binding var currentPage:Int
init(_ views: [Page], currentPage:Binding<Int>) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
self._currentPage = currentPage
}
var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage).id(UUID())
}
}
It will work, but you will notice that the scroll position of the pages are reset. Another bug ;)
think the problem stays in the Coordinator:
when you update controllers in pageviewcontroller, the parent
property of Coordinator remains the same (and because it is a struct, all his properties). So you can simply add this line of code in your updateUIViewController
method:
context.coordinator.parent = self
also, remember that animation of pageViewController.setViewControllers
will occur, so you have to set animated to false, or handle it properly.
There are many other ways to solve this (I wrote the most intuitive solution), the important thing is to know where the error comes from.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With