Search code examples
swiftswiftuiuikituipageviewcontrolleruiviewcontrollerrepresentable

Issue where the same page is displayed twice in a row when using UIPageView from SwiftUI


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.


Solution

  • 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 UIHostingControllers 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 Views. It would also take a function that converts the data to Views.

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