Following the SwiftUI tutorials, I have a PageViewController like this:
This works just like in the tutorial. You use it by providing a binding for the page:
So here's my problem. I want to move the navigation controls up in the view tree. So I first change the @State var to a @Binding so that the parent can mutate it (as well as the coordinator)
When the parent changes the currentPage, this causes the PageView and PageViewController to be re-rendered.
This ends up breaking the UIPageViewController. It will no longer scroll.
On Stack Overflow there was a suggestion to give the PageViewController a unique identifier like this:
This gets around the above issue by forcing it to consider it a new view and re-creating the UIViewController. But it also breaks animations when you set currentPage programmatically.
What can I do to to fix this?
Code Block struct PageViewController: UIViewControllerRepresentable { var controllers: [UIViewController] @Binding var currentPage: Int func makeUIViewController(context: Context) -> UIPageViewController { let pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal) pageViewController.dataSource = context.coordinator pageViewController.delegate = context.coordinator return pageViewController } func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) { pageViewController.setViewControllers( [controllers[currentPage.wrappedValue]], direction: .forward, animated: true, completion: nil) } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { var parent: PageViewController init(_ pageViewController: PageViewController) { parent = pageViewController } func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { guard let index = parent.controllers.firstIndex(of: viewController) else { return nil } if index == 0 { return nil } 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 == parent.controllers.endIndex - 1 { return nil } 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.wrappedValue = index } } } }
This works just like in the tutorial. You use it by providing a binding for the page:
Code Block struct PageView<Page: View>: View { var viewControllers: [UIHostingController<Page>] @State var currentPage = 0 init(_ views: [Page]) { self.viewControllers = views.map { UIHostingController(rootView: $0) } } var body: some View { VStack { ZStack(alignment: .bottomTrailing) { PageViewController(controllers: viewControllers, currentPage: $currentPage) } HStack { Button(action: self.navigatePrevious) { Image(systemName: "arrow.left") }.disabled(currentPage == 0) Text("Current page: \(currentPage)") Button(action: self.navigateNext) { Image(systemName: "arrow.right") }.disabled(currentPage == self.viewControllers.count - 1) } } } // ... }
So here's my problem. I want to move the navigation controls up in the view tree. So I first change the @State var to a @Binding so that the parent can mutate it (as well as the coordinator)
When the parent changes the currentPage, this causes the PageView and PageViewController to be re-rendered.
This ends up breaking the UIPageViewController. It will no longer scroll.
On Stack Overflow there was a suggestion to give the PageViewController a unique identifier like this:
Code Block PageViewController(controllers: viewControllers, currentPage: $currentPage) .id(UUID())
This gets around the above issue by forcing it to consider it a new view and re-creating the UIViewController. But it also breaks animations when you set currentPage programmatically.
What can I do to to fix this?
With the help of an engineer in the SwiftUI Lab, I was able to figure out what was going on with this sample.
First, the UIHostingController array is problematic, when contained in a SwiftUI View that can be re-created at any time. See the Coordinator methods that all check controllers.firstIndex(of:...).
When the views are re-created, none of the equality checks for the UIHostingController instances will evaluate to true anymore, hence breaking our paging methods.
The solution I came up with was to push the generic <Page> all the way to the PageViewController, and pass those to the Coordinator. Then the Coordinator creates the actual UIViewController pages. Since the Coordinator lives on, these instances are the same across re-renders of the containing SwiftUI views.
I filed a radar for the tutorial to be improved to not recommend this pattern, as it was really difficult to track down. FB7775209.
First, the UIHostingController array is problematic, when contained in a SwiftUI View that can be re-created at any time. See the Coordinator methods that all check controllers.firstIndex(of:...).
When the views are re-created, none of the equality checks for the UIHostingController instances will evaluate to true anymore, hence breaking our paging methods.
The solution I came up with was to push the generic <Page> all the way to the PageViewController, and pass those to the Coordinator. Then the Coordinator creates the actual UIViewController pages. Since the Coordinator lives on, these instances are the same across re-renders of the containing SwiftUI views.
I filed a radar for the tutorial to be improved to not recommend this pattern, as it was really difficult to track down. FB7775209.