Created
January 6, 2023 13:37
-
-
Save couchdeveloper/a2ff265f3069031bd2b65132eaff16af to your computer and use it in GitHub Desktop.
SwiftUI PageView - a UIPageViewController with dynamic content
This file contains 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 characters
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 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 characters
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 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 characters
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 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 characters
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