Skip to content

Instantly share code, notes, and snippets.

@couchdeveloper
Created January 6, 2023 13:37

Revisions

  1. couchdeveloper created this gist Jan 6, 2023.
    117 changes: 117 additions & 0 deletions PageView.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,117 @@
    import SwiftUI
    import Foundation

    struct PageView<Page, PageContent: View>: View where
    Page: Identifiable,
    PageContent: Identifiable,
    PageContent.ID == Page.ID
    {
    let uiViewControllerRepresentable: PageViewController<Page, PageContent>
    @Binding var currentPage: Page

    @MainActor init(
    currentPage: Binding<Page>,
    previousPage: @escaping () -> Page?,
    nextPage: @escaping () -> Page?,
    @ViewBuilder pageContent: @escaping (Page) -> PageContent
    ) {
    self._currentPage = currentPage
    self.uiViewControllerRepresentable = PageViewController(
    currentPage: currentPage,
    previousPage: previousPage,
    nextPage: nextPage,
    pageContent: pageContent
    )
    }

    private func view() -> some View {
    return uiViewControllerRepresentable
    }

    var body: some View {
    view()
    .onAppear {
    let currentPage = self.currentPage
    self.currentPage = currentPage
    }
    }
    }


    struct PageView_Previews: PreviewProvider {

    struct MyPageView<Page, PageContent>: View where
    Page: Identifiable,
    PageContent: View,
    PageContent: Identifiable,
    Page.ID == PageContent.ID
    {
    @ObservedObject var model: PageViewModel<Page>
    @ViewBuilder var pageContent: (Page) -> PageContent

    var body: some View {
    let currentPageBinding: Binding<Page> = .init(
    get: { model.currentPage },
    set: { page in
    model.setCurrentPage(page)
    }
    )

    VStack {
    PageView(
    currentPage: currentPageBinding,
    previousPage: { model.previousPage },
    nextPage: { model.nextPage }
    ) { page in
    pageContent(page)
    }
    }
    }
    }

    struct Item: Identifiable {
    let id: String
    let url: URL
    }
    static let items = [
    Item(id: "1", url: URL(string: "https://loremflickr.com/320/240?lock=1")!),
    Item(id: "2", url: URL(string: "https://loremflickr.com/320/240?lock=2")!),
    Item(id: "3", url: URL(string: "https://loremflickr.com/320/240?lock=3")!),
    ]

    struct Page: View, Identifiable {
    let item: Item
    var id: Item.ID { item.id }
    var body: some View {
    AsyncImage(url: item.url)
    }
    }

    // `PageView` requires an _imperative_ model, i.e. the model must
    // _return_ the data of a page when requested. Note that a model having a
    // function returning a value cannot be implemented with an event driven
    // unidirectional style.
    final class MyPageModel: PageViewModel<Item> {
    override func fetchPreviousPage(of page: PageView_Previews.Item) -> PageView_Previews.Item? {
    guard let id = Int(page.id), id > 2 else {
    return nil
    }
    return Item(id: "\(id - 1)", url: URL(string: "https://loremflickr.com/320/240?lock=\(id - 1)")!)
    }

    override func fetchNextPage(of page: PageView_Previews.Item) -> PageView_Previews.Item? {
    guard let id = Int(page.id) else {
    return nil
    }
    return Item(id: "\(id + 1)", url: URL(string: "https://loremflickr.com/320/240?lock=\(id + 1)")!)
    }
    }

    static let pageModel = MyPageModel(currentPage: items[0])

    static var previews: some View {
    MyPageView(model: pageModel) { item in
    Page(item: item)
    }
    }
    }
    180 changes: 180 additions & 0 deletions PageViewController.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,180 @@
    import SwiftUI
    import UIKit

    /// Creates a `PageViewController` where a page view (`PageContent`) renders a value of type
    /// `Page`.
    ///
    /// The number of visible pages equals one, when there is no transtion.
    ///
    /// A `PageViewController` requires that both `Page` and `PageContent` conform to
    /// `Identifiable` and that there `ID` is the same. That is, the value of the`id` of the PageContent
    /// view must match the value of the `id` of the model which has been used to create or modify the view.
    public struct PageViewController<
    Page,
    PageContent: View
    >: UIViewControllerRepresentable where
    Page: Identifiable, PageContent: Identifiable, PageContent.ID == Page.ID {

    @Binding private var currentPage: Page
    private var previousPage: () -> Page?
    private var nextPage: () -> Page?
    private let pageContent: (Page) -> PageContent

    /// Initialises a PageView.
    ///
    /// - Parameters:
    /// - currentPage: The value which will be visible as a `PageContent` view after a transition has completed.
    /// - previousPage: A function which fetches the previous page from the data source.
    /// - nextPage: A function which fetches the next page from the data source.
    /// - pageContent: A function which returns a `PageContent` view from a given page value.
    init(
    currentPage: Binding<Page>,
    previousPage: @escaping () -> Page?,
    nextPage: @escaping () -> Page?,
    pageContent: @escaping (Page) -> PageContent
    ) {
    self._currentPage = currentPage
    self.previousPage = previousPage
    self.nextPage = nextPage
    self.pageContent = pageContent
    }

    public func makeCoordinator() -> Coordinator {
    Coordinator(self)
    }

    public func makeUIViewController(
    context: Context
    ) -> UIPageViewController {
    let pageViewController = UIPageViewController(
    transitionStyle: .scroll,
    navigationOrientation: .horizontal,
    options: [:]
    )
    pageViewController.dataSource = context.coordinator
    pageViewController.delegate = context.coordinator
    return pageViewController
    }

    public func updateUIViewController(
    _ uiPageViewController: UIPageViewController,
    context: Context
    ) {
    if let viewControllers = uiPageViewController.viewControllers, viewControllers.count == 0 {
    let hostingController = UIHostingController(rootView: pageContent(currentPage))
    uiPageViewController.setViewControllers(
    [hostingController],
    direction: .forward,
    animated: true
    )
    }
    }

    public final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
    var pageViewController: PageViewController

    var visiblePage: Page {
    pageViewController.currentPage
    }

    init(_ pageViewController: PageViewController) {
    self.pageViewController = pageViewController
    }

    // MARK: - UIPageViewControllerDataSource

    public func pageViewController(
    _ uiPageViewController: UIPageViewController,
    viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
    guard let newItem = self.pageViewController.previousPage() else {
    return nil
    }
    Scenes.PageView.log.debug("loading previous UIHostingController with ID \(String(describing: newItem.id))")
    return UIHostingController(rootView: self.pageViewController.pageContent(newItem))
    }

    public func pageViewController(
    _ uiPageViewController: UIPageViewController,
    viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
    guard let newItem = self.pageViewController.nextPage() else {
    return nil
    }
    Scenes.PageView.log.debug("loading next UIHostingController with ID \(String(describing: newItem.id))")
    return UIHostingController(rootView: self.pageViewController.pageContent(newItem))
    }

    // MARK: - UIPageViewControllerDelegate

    /// Called before a gesture-driven transition begins.
    public func pageViewController(
    _ uiPageViewController: UIPageViewController,
    willTransitionTo pendingViewControllers: [UIViewController]
    ) {
    let views: [PageContent] = pendingViewControllers.compactMap { viewController in
    guard let hostingController = viewController as? UIHostingController<PageContent> else {
    return nil
    }
    return hostingController.rootView
    }
    print("will transition to: \(views)")
    }

    /// Called after a gesture-driven transition completes.
    public func pageViewController(
    _ uiPageViewController: UIPageViewController,
    didFinishAnimating finished: Bool,
    previousViewControllers: [UIViewController],
    transitionCompleted completed: Bool
    ) {
    if completed {
    // Here we need to figure out the next page based on the page id
    // of the current page which will then be assigned the
    // `pageViewController.currentPage`. Note, that we can only
    // obtain the current `id` of the current page content view, not
    // the whole page data. Then, we need to figure out whether it
    // is the `next` or `previous` page which must have been already
    // computed and set by the model. Thus, it gets a bit elaborated.
    guard let viewControllers = uiPageViewController.viewControllers, viewControllers.count > 0 else {
    fatalError("uiPageViewController.viewControllers is empty")
    }
    let currentHostingControllers = viewControllers.map { viewController in
    guard let hostingController = viewController as? UIHostingController<PageContent> else {
    fatalError("could not get HostingController")
    }
    return hostingController
    }
    let currentPageId = currentHostingControllers[0].rootView.id
    if let nextPage = pageViewController.nextPage(), nextPage.id == currentPageId {
    Scenes.PageView.log.debug("nextPage: \(String(describing: nextPage.id))")
    pageViewController.currentPage = nextPage
    return
    } else if let previousPage = pageViewController.previousPage(), previousPage.id == currentPageId {
    Scenes.PageView.log.debug("previousPage: \(String(describing: previousPage.id))")
    pageViewController.currentPage = previousPage
    return
    } else {
    // What we expected is that `currentPageId` equals either
    // `pageViewController.nextPage()?.id` or
    // `pageViewController.previousPage()?.id`. If neither is the
    // case we hit some unknown edge case. What we do here then
    // is to analyse the issue and log as much information as
    // possible and do nothing else - which means that the visible
    // page won't be changed, which is at least a safe thing to
    // do.
    let previousPageIDs = previousViewControllers.map { viewController in
    guard let hostingController = viewController as? UIHostingController<PageContent> else {
    fatalError("could not get HostingController")
    }
    return hostingController.rootView.id
    }
    Scenes.PageView.log.error(
    "Could not find page data for page id \(String(describing: currentPageId)). Previous pages in UIPageViewController: \(previousPageIDs), nextPage: \(String(describing: self.pageViewController.nextPage())), previous page: \(String(describing: self.pageViewController.previousPage()))"
    )
    }
    }
    }
    }

    }
    69 changes: 69 additions & 0 deletions PageViewModel.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,69 @@
    import Combine

    /// An abstract base class for a data source for a `PageView`.
    ///
    /// Subclasses need to implement the two functions `fetchNextPage(of:)` and
    /// `fetchPreviousPage(of:)` which return the corresponding page data.
    ///
    /// `PageView` requires an _imperative_ model, i.e. the model must _synchronously return_ the data of a
    /// page when requested. Note that the calls to fetch page data will be initiated by the `PageView`. If the
    /// content of a page cannot be provided synchronously, the page itself should be performing the load, where
    /// the model merely sets up a configuration for loading data.
    ///
    /// `PageViewModel` is implemented following a `Delegate` pattern. Note that a model having a function
    /// returning a value cannot be implemented with an event driven unidirectional style, because event driven is
    /// inherently asynchronous.
    public class PageViewModel<Page>: ObservableObject {
    /// The current page which is visible.
    @Published public private(set) var currentPage: Page
    /// The page which will become visible when scrolling foreward or `nil` if there is none.
    @Published public private(set) var nextPage: Page?
    /// The page which will become visible when scrolling backwards or `nil` of there is none.
    @Published public private(set) var previousPage: Page?

    /// Initialises the model with the current visible page.
    /// - Parameter currentPage: The data for the page to be shown initially.
    public init(currentPage: Page) {
    self.currentPage = currentPage
    }

    /// Set's the current page.
    ///
    /// This function will be called by the `PageView` when a page transition completed and a new page
    /// has been set. Do not call it yourself.
    ///
    /// When this function will be called, the new page will already be visible in the Page View.
    ///
    /// - Parameter page: The page that has been set.
    public final func setCurrentPage(_ page: Page) {
    currentPage = page
    nextPage = fetchNextPage(of: page)
    previousPage = fetchPreviousPage(of: page)
    }

    /// Return the next page based on the given page.
    ///
    /// This function needs to be overriden by a subclass.
    ///
    /// Note that the call will be initiated by the `PageView` and may happen at any time. If the content of a
    /// page cannot be provided synchronously, the page itself should be performing the the load.
    ///
    /// - Parameter page: The current page for which to return the next page.
    /// - Returns: A page.
    open func fetchNextPage(of page: Page) -> Page? {
    fatalError("needs to be implemented in subclass")
    }

    /// Return the previous page based on the given page.
    ///
    /// This function needs to be overriden by a subclass.
    ///
    /// Note that the call will be initiated by the `PageView` and may happen at any time. If the content of a
    /// page cannot be provided synchronously, the page itself should be performing the the load.
    ///
    /// - Parameter page: The current page for which to return the previous page.
    /// - Returns: A page.
    open func fetchPreviousPage(of page: Page) -> Page? {
    fatalError("needs to be implemented in subclass")
    }
    }
    9 changes: 9 additions & 0 deletions Scenes.PageView.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,9 @@
    import Foundation
    import OSLog

    extension Scenes { enum PageView {} }

    extension Scenes.PageView {
    private static var subsystem = Bundle.main.bundleIdentifier!
    static let log = Logger(subsystem: subsystem, category: "\(Self.self)")
    }