Created
February 28, 2023 15:13
-
-
Save andreyz/d876fcacd993250e49c545dd793120d8 to your computer and use it in GitHub Desktop.
An example of how to create a lazy loaded paged view in SwiftUI
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 characters
import SwiftUI | |
/** | |
A container view that manages navigation between pages of content. | |
*/ | |
public struct PageView<Content: View, Item: Hashable> { | |
public typealias ItemProvider = (Item) -> Item? | |
public typealias ViewProvider = (Item) -> Content | |
/// The style for transitions between pages. | |
public enum TransitionStyle { | |
case scroll | |
case pageCurl | |
} | |
@Binding var selection: Item | |
let style: TransitionStyle | |
let axis: Axis | |
let spacing: Int | |
let prev: ItemProvider | |
let next: ItemProvider | |
@ViewBuilder let content: (Item) -> Content | |
public init(selection: Binding<Item>, style: TransitionStyle = .scroll, axis: Axis = .horizontal, spacing: Int = 10, prev: @escaping ItemProvider, next: @escaping ItemProvider, @ViewBuilder content: @escaping ViewProvider) { | |
_selection = selection | |
self.style = style | |
self.axis = axis | |
self.spacing = spacing | |
self.prev = prev | |
self.next = next | |
self.content = content | |
} | |
} | |
extension PageView: UIViewControllerRepresentable { | |
public typealias UIViewControllerType = UIPageViewController | |
public func makeUIViewController(context: Context) -> UIPageViewController { | |
let viewController = UIPageViewController( | |
transitionStyle: style.uiPageViewController, | |
navigationOrientation: axis.uiPageViewController, | |
options: [.interPageSpacing: spacing] | |
) | |
viewController.delegate = context.coordinator | |
viewController.dataSource = context.coordinator | |
let initialView = ItemHostingController(item: selection, view: content(selection)) | |
viewController.setViewControllers([initialView], direction: .forward, animated: false) | |
return viewController | |
} | |
public func updateUIViewController(_ uiViewController: UIPageViewController, context: Context) { | |
let isAnimated = context.transaction.animation != nil | |
goTo(selection, pageViewController: uiViewController, animated: isAnimated) | |
} | |
// MARK: - Navigation | |
func goTo(_ item: Item, pageViewController: UIPageViewController, animated: Bool = true) { | |
guard let currentViewController = pageViewController.viewControllers?.first as? ItemHostingController<Item> else { | |
return | |
} | |
guard currentViewController.item != item else { | |
return | |
} | |
let viewController = ItemHostingController(item: item, view: content(item)) | |
pageViewController.setViewControllers([viewController], direction: .forward, animated: animated) | |
} | |
// MARK: - Coordinator | |
public func makeCoordinator() -> Coordinator { | |
Coordinator(self) | |
} | |
public class Coordinator: NSObject, UIPageViewControllerDelegate, UIPageViewControllerDataSource { | |
let pageView: PageView | |
init(_ pageView: PageView) { | |
self.pageView = pageView | |
} | |
// MARK: - Data Source | |
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { | |
guard let viewController = viewController as? ItemHostingController<Item> else { | |
return nil | |
} | |
if let prev = pageView.prev(viewController.item) { | |
return makeView(prev) | |
} else { | |
return nil | |
} | |
} | |
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { | |
guard let viewController = viewController as? ItemHostingController<Item> else { | |
return nil | |
} | |
if let next = pageView.next(viewController.item) { | |
print("Requesting item after \(viewController.item)") | |
return makeView(next) | |
} else { | |
print("Nothing after \(viewController.item)") | |
return nil | |
} | |
} | |
// MARK: - Delegate | |
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { | |
guard let viewController = pageViewController.viewControllers?.first as? ItemHostingController<Item> else { | |
return | |
} | |
let item = viewController.item | |
if pageView.selection != item { | |
pageView.selection = item | |
} | |
} | |
// MARK: - Helpers | |
func makeView(_ item: Item) -> PageView.ItemHostingController<Item> { | |
ItemHostingController(item: item, view: pageView.content(item)) | |
} | |
} | |
class ItemHostingController<Item>: UIHostingController<Content> { | |
let item: Item | |
init(item: Item, view: Content) { | |
self.item = item | |
super.init(rootView: view) | |
self.view.backgroundColor = .clear | |
self.view.isOpaque = false | |
} | |
@MainActor required dynamic init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
} | |
} | |
extension PageView.TransitionStyle { | |
var uiPageViewController: UIPageViewController.TransitionStyle { | |
switch self { | |
case .scroll: | |
return .scroll | |
case .pageCurl: | |
return .pageCurl | |
} | |
} | |
} | |
extension Axis { | |
var uiPageViewController: UIPageViewController.NavigationOrientation { | |
switch self { | |
case .horizontal: | |
return .horizontal | |
case .vertical: | |
return .vertical | |
} | |
} | |
} | |
struct PageView_Previews: PreviewProvider { | |
static var previews: some View { | |
CounterDemoView() | |
} | |
struct CounterDemoView: View { | |
// Selection can be anything hashable (in practice, probably your model ID) | |
@State private var selection: Int = 1 | |
var body: some View { | |
VStack { | |
PageView(selection: $selection, prev: prev, next: next) { item in | |
// This closure will be called each time a new page is needed | |
Text("Number \(item)") | |
.font(.largeTitle) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
} | |
.animation(.default, value: selection) | |
Text("Current Number: \(selection)") | |
// So we can test set setting the selection from SwiftUI | |
Button { | |
if let next = next(selection) { | |
selection = next | |
} | |
} label: { | |
Text("Next") | |
} | |
} | |
} | |
// Return the item that comes before | |
func prev(_ item: Int) -> Int? { | |
if item <= 0 { | |
return nil | |
} else { | |
return item - 1 | |
} | |
} | |
// Return the item that comes after | |
func next(_ item: Int) -> Int? { | |
if item > 9 { | |
return nil | |
} else { | |
return item + 1 | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment