Created
January 6, 2023 13:37
Revisions
-
couchdeveloper created this gist
Jan 6, 2023 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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) } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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()))" ) } } } } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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") } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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)") }