UIPageViewController with Dynamic Data

You may not know what your data count is like ahead of time and that's okay.

If your experience with UIPageViewController was anything like mine, some things seemed pretty straightforward forward and some things might not have. More specifically, the data source area. I needed to create view controllers as they’re needed and not all at once which is what most tutorials I’ve seen have shown.

In this article, I’m going to show you how I went about this in hopes that it might help you out as well. 🍻Cheers!

Here’s the gist of the idea I’m going to use for this article:

I have a collection view with a bunch of square cells that show thumbnails of either photos or videos, could be either-or. Let’s call these items media items [MediaItem].

struct MediaItem {
    let id: UUID
    let mediaType: MediaType
    let title: String?
    let mediaUrl: URL?
}

enum MediaType { case video, photo }

This data used to populate the collection view is the same data we’re going to need for the page view controller. ☜ This is what I was after.

Side Note - One small twist is that I have different view controllers that have different UI’s depending on if the media item’s mediaType is a .video or a .photo.

Here are our dummy view controllers that will be applied inside our page view controller. They are a subclass of MediaViewController, which is a subclass of UIViewController. I made it to handle functionality for both VideoViewController and PhotoViewController such as taking in a MediaItem and other things. You can also just use a protocol, but that’s not the point of this article. Check it out:

class MediaViewController: UIViewController {
    let mediaItem: MediaItem

    init(mediaItem: MediaItem) {
        self.mediaItem = mediaItem
    }

    required init(coder: NSCoder) {
        fatalError("Use init(mediaItem: MediaItem)")
    }

    // … More code
}


class VideoViewController: MediaViewController {
    ...
}

class PhotoViewController: MediaViewController {
    ...
}

In the collection view, navigating to the page view controller will probably look something like this:

// For one dimensional array
func collectionView(
    _ collectionView: UICollectionView, 
    didSelectItemAt indexPath: IndexPath
) {
    let item = mediaItems[indexPath.item]
    let pagedMediaViewController = PagedMediaController(
        mediaItem: item, 
        currentIndex: indexPath.item
    )
    pagedMediaViewController.modalPresentationStyle = .overFullScreen
    present(pagedMediaViewController, animated: true)
}

// In case you're dealing with a two dimensional array like I was 
// dealing with since the collection view's items had sections
func collectionView(
    _ collectionView: UICollectionView, 
    didSelectItemAt 
    indexPath: IndexPath
) {
    let item = mediaItems[indexPath.section][indexPath.item]
    let joinedItems = mediaItems.flatMap { $0 }
    guard let currentIndexForPagedMediaVC = joinedItems.firstIndex(of: item) else { return }
    let pagedMediaViewController = PagedMediaController(
        mediaItem: item, 
        currentIndex: currentIndexForPagedMediaVC
    )
    pagedMediaViewController.modalPresentationStyle = .overFullScreen
    present(pagedMediaViewController, animated: true)
}

Enough with the setup, let’s get to what we came here for, shall we?


Quick Note

Notice that I’m also passing in the current index of the item that was tapped. This is going to be helpful for us because we need to know which cell & media item was tapped as we want the page view controller to start there.

Let’s look at what’s happening inside PagedMediaViewController.

Properties & Init

class PagedMediaViewController: UIPageViewController {
    // MARK: - Properties
    private var mediaItems: [MediaItem]
    private var currentIndex: Int


    // MARK: - Init
    init(mediaItems: [MediaItem], currentIndex: Int) {
        self.mediaItems = mediaItems
          self.currentIndex = currentIndex
        super.init(
            transitionStyle: .scroll, 
            navigationOrientation: .horizontal, 
            options: nil
        )
    }

    required init(coder: NSCoder) {
        fatalError("Use init(mediaItems: [MediaItem], currentIndex: Int)")
    }
}

We now have our data and current index being injected.

Now we’re going to write a method that we can use any time we need a view controller inside PagedMediaViewController. It’s going to create one and return it to us so we can use it. This is going to be helpful especially when we need to create these on the fly, which is exactly what we’ll be doing. Also, in this case, I need to choose which view controller I need depending on the media type. Let’s see what this looks like:

private func mediaViewControllerAtIndex(_ index: Int) -> MediaViewController? {
    // returns nil if the index is out of bounds, this way the app won't crash.
    // You can also put a print statement before you return.
    guard (0...mediaItems.count).contains(index) else { return nil }

    let item = mediaItems[index]

    switch item.mediaType {  
    case .video:  
        return VideoViewController(mediaItem: item)  
    case .photo:  
        return PhotoViewController(mediaItem: item)
    }
}

We can return either of these view controller classes because the method says it’ll return their common superclass type MediaViewController.


Now Let’s Start Wiring Everything Up!

We’re gonna implement viewDidLoad() which will set where the dataSource is coming from and who the delegate is, and we’ll set up the initial view controller to be displayed with the item that was tapped on:

override func viewDidLoad() {
    super.viewDidLoad()
    dataSource = self
    delegate = self

    if let firstMediaVC = mediaViewControllerAtIndex(currentIndex) {
        let viewControllers = [firstMediaVC]
        setViewControllers(
            viewControllers, 
            direction: .forward, 
            animated: false, 
            completion: nil
        )
    }
}

Let's go over what we did:

  1. Don’t forget to set the data source and delegate as self

  2. mediaViewControllerAtIndex() returns an optional MediaViewController so we’re unwrapping it

  3. If we can create that view controller successfully by unwrapping it, we then create an array of view controllers with a single view controller firstMediaVC

  4. Then we use the method setViewControllers(…) from UIPageViewController to set our initial view controller

This threw me off a little cause it’s asking for an array of view controllers, but I never ended up having to pass in an array of view controllers. Now, I haven’t used UIPageViewController too often, so I’m not entirely sure when you’d want to pass in an array of view controllers. But, I can imagine that if you have a simple enough use case for a page view controller such as displaying a few screens for onboarding or something similar, I can imagine you’d just initialize them all at once and pass them in there. Even then, I might still go the route we’re using here.

Let's Get That Swipe Action Going!

There are two data source methods we need. We need to tell it which view controller comes before the current one being displayed, and which one comes after:

extension PagedMediaViewController: UIPageViewControllerDataSource {

    pageViewController(
        _ pageViewController: UIPageViewController, 
        viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
        guard currentIndex > 0 else { return nil }
        return mediaViewControllerAtIndex(currentIndex - 1)
    }


    pageViewController(
        _ pageViewController: UIPageViewController, 
        viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
        guard currentIndex < mediaItems.count - 1 else { return nil }
        return mediaViewControllerAtIndex(currentIndex + 1)
    }
}

Both of these methods’ signatures say they will return an optional UIViewController. Our method mediaViewControllerAtIndex() does just that.

  1. First, we want to make sure that the index isn’t out of bounds. When asked for the view controller before we need to make sure the current index is greater than 0. If it is, we know we can decrement the index. If not, we’ll just return nil.

  2. We want to have similar logic when asked for the view controller after. We need to check if the currentIndex is less than the total number of items minus one (cause arrays start at 0 ). If not, we’ll just return nil.

We’re passing in the current index decremented by one to get the view controller before the current one that's being displayed and we’re incrementing the current index by one to give it the view controller we need after the current one that's being displayed. Now, you may be wondering how currentIndex stays updated. Great question! …and an important one. If we’re not updating the current index, then it will break things and give us unexpected and undesirable results.

Keeping Things In Sync

Here we will implement the delegate method where we can update our current index:

extension PagedMediaViewController: UIPageViewControllerDelegate {

    func pageViewController(
        _ pageViewController: UIPageViewController, 
        didFinishAnimating finished: Bool, 
        previousViewControllers: [UIViewController], 
        transitionCompleted completed: Bool
    ) {
        guard
            let mediaVCs = pageViewController.viewControllers as? [MediaViewController],
            let currIndex = mediaItems.firstIndex(of: mediaVCs[0].mediaItem)
        else { return }

        currentIndex = currIndex
    }


    // If you want to specify the interface orientations you want to support, 
    // implement these as well:
    func pageViewControllerSupportedInterfaceOrientation(
        _ pageViewController: UIPageViewController
    ) -> UIInterfaceOrientationMask {
        // example
        .allButUpsideDown
    }

    func pageViewControllerPreferredInterfaceOrientationForPresentation(
        _ pageViewController: UIPageViewController
    ) -> UIInterfaceOrientationMask {
        // example
        .portrait
    }
}

So, let’s see what we did here:

  1. Stub out the method that has didFinishAnimating in its signature

  2. Inside that method we’re doing some unwrapping here and chaining them together

  3. We first need the view controllers prior to the transition and we’re optionally casting them as an array of our MediaViewController type so we have access to the media item

  4. Next, we need to unwrap the index as the firstIndex(of:) method returns an optional index as it might be there or it might now. We’re just grabbing the first view controller in mediaVCs and passing in its media item so we can get the index of it

  5. Once we have the index, we set that as the new value for our currentIndex property.


Let’s See What The Whole File Looks Like!

class PagedMediaViewController: UIPageViewController {

    // MARK: - Properties
    private var mediaItems: [MediaItem]
    private var currentIndex: Int


    // MARK: - Init
    init(mediaItems: [MediaItem], currentIndex: Int) {
        self.mediaItems = mediaItems
          self.currentIndex = currentIndex
        super.init(
            transitionStyle: .scroll, 
            navigationOrientation: .horizontal, 
            options: nil
        )
    }

    required init(coder: NSCoder) {
        fatalError("Use init(mediaItems: [MediaItem], currentIndex: Int)")
    }


    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        dataSource = self
        delegate = self

        if let firstMediaVC = mediaViewControllerAtIndex(currentIndex) {
            let viewControllers = [firstMediaVC]
            setViewControllers(
                viewControllers, 
                direction: .forward, 
                animated: false, 
                completion: nil
            )
        }
    }


    // MARK: - Helper Methods
    private func mediaViewControllerAtIndex(_ index: Int) -> MediaViewController? {
        // returns nil if the index is out of bounds, this way the app won't crash.
        // You can also put a print statement before you return.
        guard (0...mediaItems.count).contains(index) else { return nil }

        let item = mediaItems[index]

        switch item.mediaType {  
        case .video:  
            return VideoViewController(mediaItem: item)  
        case .photo:  
            return PhotoViewController(mediaItem: item)
        }
    }
}


// MARK: - DataSource Methods
extension PagedMediaViewController: UIPageViewControllerDataSource {

    pageViewController(
        _ pageViewController: UIPageViewController, 
        viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
        guard currentIndex > 0 else { return nil }
        return mediaViewControllerAtIndex(currentIndex - 1)
    }


    pageViewController(
        _ pageViewController: UIPageViewController, 
        viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
        guard currentIndex < mediaItems.count - 1 else { return nil }
        return mediaViewControllerAtIndex(currentIndex + 1)
    }
}


// MARK: - Delelgate Methods
extension PagedMediaViewController: UIPageViewControllerDelegate {

    func pageViewController(
        _ pageViewController: UIPageViewController, 
        didFinishAnimating finished: Bool, 
        previousViewControllers: [UIViewController], 
        transitionCompleted completed: Bool
    ) {
        guard
            let mediaVCs = pageViewController.viewControllers as? [MediaViewController],
            let currIndex = mediaItems.firstIndex(of: mediaVCs[0].mediaItem)
        else { return }

        currentIndex = currIndex
    }


    // If you want to specify the interface orientations you want to support, 
    // implement these as well:
    func pageViewControllerSupportedInterfaceOrientation(
        _ pageViewController: UIPageViewController
    ) -> UIInterfaceOrientationMask {
        // example
        .allButUpsideDown
    }

    func pageViewControllerPreferredInterfaceOrientationForPresentation(
        _ pageViewController: UIPageViewController
    ) -> UIInterfaceOrientationMask {
        // example
        .portrait
    }
}

Well, That's It!

I hope this helps anyone out that’s looking to implement a pageViewController with an unknown amount of data. My first go was creating all view controllers at once even though I knew there was a better way. There was a noticeable lag and it takes away from the user experience. The route we went in this article allows the pageViewController to pop up immediately without any lag and things run smoothly.

A good thing to note is that since you’re just swiping the different view controllers on/off-screen, the life cycle methods you’re used to like viewDidLoad or viewWillAppear still get called as expected. I haven’t tested when deinit gets called exactly. I know it gets called when the whole UIPageViewController is dismissed, but I haven’t noticed deinit getting called just by swiping. I’m not sure at this moment if so many view controllers get cached or something like that to help out with memory.

Well, if you have any further questions, feel free to reach out to me. I will do my best to answer all questions. Also, if you notice any mistakes, please let me know so I can update this article as quickly as possible. And if you have a better or different way of implementing this, then share it with everyone in the comments down below.

Happy coding!