skip to Main Content

I’m trying to implement a book like interface in my swift app where the spine of the book is at the centre and the user is able to view two pages. For this, I’m using the UIPageViewController class and creating an array of ViewControllers to be displayed on the screen.

I’m able to initialise the first two pages properly. However when I flip the page to view page 3 and 4, it takes me directly to the last page. I’ve used a ViewContorllerRespresentable to embed this into my Swift UI to see it on the screen.

Here is the code I’m using.


import SwiftUI
import UIKit
import Combine

class CustomPageViewController: UIPageViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
            guard let index = viewControllersList.firstIndex(of: viewController), index >= 2 else {
                return nil
            }
        print("currrent index is", index)
            let previousIndex = index - 2
        print("MOVING BEHIND")
        print(previousIndex+1)
        print(previousIndex)
            setViewControllers([viewControllersList[previousIndex], viewControllersList[previousIndex + 1]], direction: .reverse, animated: true){finished in
                if finished{
                    print("completed move behind operation")
                }
            }
            return nil // The system manages the transition; no need to return a single view controller.
        }

        func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
            guard let index = viewControllersList.firstIndex(of: viewController), index < viewControllersList.count - 2 else {
                return nil
            }
            print("currrent index is", index)
            let nextIndex = index + 2
            print("MOVING AHEAD")
            print(nextIndex-1)
            print(nextIndex)
            setViewControllers([viewControllersList[nextIndex-1], viewControllersList[nextIndex]], direction: .forward, animated: true){finished in
                if finished{
                    print("completed move ahead operation")
                }
            }
            return nil // The system manages the transition; no need to return a single view controller.
        }
    

    lazy var viewControllersList: [UIViewController] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        self.dataSource = self
        self.delegate = self

        let firstViewController = UIViewController()
        firstViewController.view.backgroundColor = .red

        let secondViewController = UIViewController()
        secondViewController.view.backgroundColor = .green

        let thirdViewController = UIViewController()
        thirdViewController.view.backgroundColor = .blue
        
        let fourthViewController = UIViewController()
        thirdViewController.view.backgroundColor = .yellow
        
        let fifthViewController = UIViewController()
        thirdViewController.view.backgroundColor = .black
        
        let sixthViewController = UIViewController()
        thirdViewController.view.backgroundColor = .magenta

        viewControllersList = [firstViewController, secondViewController, thirdViewController, fourthViewController, fifthViewController, sixthViewController]

        setViewControllers([viewControllersList[0], viewControllersList[1]], direction: .forward, animated: true){finished in
            if finished{
                print("set initial VC")
            }
        }
    }

    

    func pageViewController(_ pageViewController: UIPageViewController, spineLocationFor interfaceOrientation: UIInterfaceOrientation) -> UIPageViewController.SpineLocation {
        
        if interfaceOrientation.isLandscape{
//            self.isDoubleSided = true
            print("INSIDE THIS BLOCK OF CODE")
            if let currentViewController = self.viewControllers?.first, let currentIndex = viewControllersList.firstIndex(of: currentViewController) {
                        let nextIndex = currentIndex + 1
                        let nextViewController = (nextIndex < viewControllersList.count) ? viewControllersList[nextIndex] : nil

                        if let nextVC = nextViewController {
                            setViewControllers([currentViewController, nextVC], direction: .forward, animated: true, completion: nil)
                        } else {
                            setViewControllers([currentViewController], direction: .forward, animated: true, completion: nil)
                        }
                    }            
            return .mid
        }
        else{
            return .min
        }
        
    }
}

// 2. PageViewControllerRepresentable
struct MyViewControllerRepresentable: UIViewControllerRepresentable {

    

    func makeUIViewController(context: Context) -> CustomPageViewController {
        let pageViewController = CustomPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal)
        return pageViewController
    }

    func updateUIViewController(_ uiViewController: CustomPageViewController, context: Context) {
        // Update the UIPageViewController if needed
    }

}


struct TestView : View {
 var body: some View{
     VStack{
         MyViewControllerRepresentable()
     }
}
}

I feel that I’m really close to making it happen but I’m missing a small detail. Appreciate your help on this

2

Answers


  1. For the function setViewControllers([viewControllersList[index]], …), only use 1 viewcontroller in the array. That could help already.

    For the datasource of the pageController, i would do something like this (vcs is the array of viewcontrollers):

    //MARK: - PageController DataSource
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
    
        return vcs.firstIndex(of: viewController)! == 0 ? nil : vcs[vcs.firstIndex(of: viewController)! - 1]
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        
        return vcs.firstIndex(of: viewController)! + 1 == vcs.count ? nil : vcs[vcs.firstIndex(of: viewController)! + 1]
    }
    
    Login or Signup to reply.
  2. There are several issues here…


    First:

    When returning .mid for spine location, UIPageViewController automatically sets itself to .isDoubleSided = true. However, when returning .min it does not automatically set it to false — which makes sense, because it doesn’t know what you want.

    So, we need to explicitly set it back to false to avoid the two-pages-at-a-time problem.


    Second:

    If we are in two-page mode (spine = .mid), and we have an odd number of pages, we’ll crash when navigating to the last page — because we have only one controller to return.

    To avoid that, we can add some code that says:

    if we are already at the end of the controllers array
        if we have an even-number of controllers
            don't advance
        else we have an odd-number of controllers, so
            return a blank view controller to avoid crashing
    else
        return the next controller
    

    Third:

    If we’re in single-page mode… we’re at an odd-numbered page… we switch to two-page mode (rotate the device)…

    If we set the first controller (the left-hand side) to that odd-numbered page, we can never get back to the first page/controller.

    e.g.:

    we start at page/controller index 0
    we navigate to the next controller (index 1)
    we rotate and now we see:
        vc[1] | vc[2]
    

    If we try to swipe/drag to the previous page, we would need to display:

        vc[-1] | vc[0]
    

    which, of course, we can’t.

    To avoid that, any time we switch from single-page to two-page…

    if current index is even
        set that page to the left-hand side
    else, it's odd, so
        set that page to the right-hand side
    

    The final issue:

    There is a quirk (I’d call it a bug, but what do I know) when switching from .mid to .min. The right-hand controller/view gets its .alpha set to 0.0. So, when we try to navigate to it, we don’t see it.

    To fix that, we can set .alpha = 1.0 on the return view controller every time in both viewControllerBefore and viewControllerAfter.


    So… here is a complete example to try out:

    import SwiftUI
    import UIKit
    
    // "page" view controller
    //  has a single, centered, multi-line label
    class SimplePageVC: UIViewController {
        let theLabel = UILabel()
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .systemBackground
            theLabel.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(theLabel)
            NSLayoutConstraint.activate([
                theLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                theLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            ])
            theLabel.font = .systemFont(ofSize: 40.0, weight: .light)
            theLabel.textAlignment = .center
            theLabel.numberOfLines = 0
        }
    }
    
    class RotatablePageViewController: UIPageViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource {
        
        // when when returning to single-page mode (spine = .min)
        //  FROM two-page mode (spine = .mid)
        // the "page" that was in the right-hand side ends up with
        //  its view.alpha set to 0.0, which means we can navigate to it, but it is not visible
        // to "fix" that, we'll always set the next/prev controller's view.alpha to 1.0
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
            
            guard let index = viewControllersList.firstIndex(of: viewController), index > 0 else {
                return nil
            }
            viewControllersList[index - 1].view.alpha = 1.0
            return viewControllersList[index - 1]
            
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
            
            guard let index = viewControllersList.firstIndex(of: viewController) else {
                return nil
            }
            
            // if we're in single-page mode
            //  if we're at the end of the pages
            //      don't advance
            //  else
            //      return the next controller
            if self.spineLocation == .min {
                if index == viewControllersList.count - 1 {
                    return nil
                }
                viewControllersList[index + 1].view.alpha = 1.0
                return viewControllersList[index + 1]
            }
            
            // we're in two-page mode
            
            // if we are already at the end of the controllers array
            //  if we have an even-number of controllers
            //      don't advance
            //  else we have an odd-number of controllers, so
            //      return a blank view controller to avoid crashing
            // else
            //  return the next controller
            if index == viewControllersList.count - 1 {
                if viewControllersList.count % 2 == 0 {
                    return nil
                }
                let vc = UIViewController()
                // using .systemGreen here to make it obvious
                //  change to desired background color for the "blank" page
                vc.view.backgroundColor = .systemGreen
                return vc
            }
            
            viewControllersList[index + 1].view.alpha = 1.0
            return viewControllersList[index + 1]
            
        }
        
        lazy var viewControllersList: [UIViewController] = []
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
            
            self.dataSource = self
            self.delegate = self
            
            let colors: [UIColor] = [
                .init(red: 1.0, green: 0.7, blue: 0.7, alpha: 1.0), // light red
                .init(red: 0.5, green: 1.0, blue: 0.5, alpha: 1.0), // light green
                .init(red: 0.7, green: 0.8, blue: 1.0, alpha: 1.0), // light blue
                .init(red: 1.0, green: 1.0, blue: 0.5, alpha: 1.0), // light yellow
                .init(red: 0.5, green: 1.0, blue: 1.0, alpha: 1.0), // light cyan
                .init(red: 1.0, green: 0.7, blue: 1.0, alpha: 1.0), // light magenta
            ]
            
            // let's create some "pages"
            let numPages: Int = 7
            for i in 0..<numPages {
                let vc = SimplePageVC()
                vc.view.backgroundColor = colors[i % colors.count]
                vc.theLabel.text = "Page: (i+1) of (numPages)nnArray Index: (i)"
                viewControllersList.append(vc)
            }
            
            if self.spineLocation == .min {
                setViewControllers([viewControllersList[0]], direction: .forward, animated: false)
            } else {
                setViewControllers([viewControllersList[0], viewControllersList[1]], direction: .forward, animated: false)
            }
        }
        
        func pageViewController(_ pageViewController: UIPageViewController, spineLocationFor interfaceOrientation: UIInterfaceOrientation) -> UIPageViewController.SpineLocation {
            
            var idx: Int = 0
            if let curVC = pageViewController.viewControllers?.first, let curIDX = viewControllersList.firstIndex(of: curVC) {
                idx = curIDX
            }
            
            if interfaceOrientation.isLandscape {
                // when switching from .min to .mid (single page to two-page format)
                //  we need to set the first page (left-side) to an even-number index
                //  otherwise, we cannot navigate back to index Zero
                let newIDX = (idx % 2 == 1) ? idx - 1 : idx
                setViewControllers([viewControllersList[newIDX], viewControllersList[newIDX + 1]], direction: .forward, animated: true, completion: nil)
            } else {
                setViewControllers([viewControllersList[idx]], direction: .forward, animated: true)
            }
            
            // setting spine to .mid *automatically* sets .isDoubleSided = true
            //  but we need to set it to false when setting spine to .min
            pageViewController.isDoubleSided = interfaceOrientation.isLandscape ? true : false
            
            return interfaceOrientation.isLandscape ? .mid : .min
            
        }
    }
    
    struct RotateMyViewControllerRepresentable: UIViewControllerRepresentable {
        
        func makeUIViewController(context: Context) -> RotatablePageViewController {
            let pageViewController = RotatablePageViewController()
            return pageViewController
        }
        
        func updateUIViewController(_ uiViewController: RotatablePageViewController, context: Context) {
            // Update the UIPageViewController if needed
        }
        
    }
    
    struct RotatablePageView: View {
        var body: some View {
            VStack{
                RotateMyViewControllerRepresentable()
            }
        }
    }
    
    #Preview {
        RotatablePageView()
    }
    

    Worth noting: these issues are not related to using UIPageViewController as a UIViewControllerRepresentable in SwiftUI … the same issues show up when using it as a standard controller in a Swift / UIKit implementation.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search