Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Updating an @EnvironmentObject var to pass data to PageViewController in SwiftUI results in loss of swiping between ViewControllers

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!

like image 455
Dan Barclay Avatar asked Jan 19 '20 19:01

Dan Barclay


2 Answers

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

like image 58
Fabrice Leyne Avatar answered Jan 01 '23 08:01

Fabrice Leyne


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.

like image 45
Lorenzo Fiamingo Avatar answered Jan 01 '23 09:01

Lorenzo Fiamingo