Skip to content

Instantly share code, notes, and snippets.

@couchdeveloper
Created January 6, 2023 13:37
Show Gist options
  • Save couchdeveloper/a2ff265f3069031bd2b65132eaff16af to your computer and use it in GitHub Desktop.
Save couchdeveloper/a2ff265f3069031bd2b65132eaff16af to your computer and use it in GitHub Desktop.
SwiftUI PageView - a UIPageViewController with dynamic content
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)
}
}
}
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()))"
)
}
}
}
}
}
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")
}
}
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)")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment