Apple 在 iOS 17 中为 ScrollView 发布了一些新的 SwiftUI 修饰符,允许分页滚动,但我需要一些可以在 iOS 15 及更高版本中使用的东西。
这是我能够制作的 UIPageViewController 的包装器。请注意,它是专门为我的用例量身定制的,即在 16x9 框架内全屏显示 UIPageViewController 中的 SwiftUI 视图,并且可以随着设备旋转而旋转,并且支持 iOS 15 及更高版本。
感谢 Sergey Pekar 为该解决方案提供了基础此处。
struct PagingView<Page: View>: UIViewControllerRepresentable {
/// The index of the currently visible page
@Binding var currentIndex: Int
let stopCondition: (_ index: Int) -> Bool
let pageFor: (_ index: Int) -> Page
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .vertical,
options: nil
)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
if let startingViewController = context.coordinator.createPage(for: currentIndex) {
pageViewController.setViewControllers(
[startingViewController],
direction: .forward,
animated: true,
completion: nil
)
}
return pageViewController
}
func updateUIViewController(_ uiViewController: UIPageViewController, context: Context) {
context.coordinator.view = self
let previousIndex = context.coordinator.currentIndexCached
if
let nextPage = context.coordinator.createPage(for: currentIndex),
// Do not change page if the update did not effect the currentIndex.
previousIndex != currentIndex,
let visibleViewController = uiViewController.viewControllers?.first,
let singlePageViewController = visibleViewController as? SinglePageViewController<Page>,
// Do not change page if update was caused by the user swiping to another page.
singlePageViewController.index != currentIndex
{
uiViewController.setViewControllers(
[nextPage],
direction: currentIndex > previousIndex ? .forward : .reverse,
animated: true,
completion: nil
)
}
context.coordinator.currentIndexCached = currentIndex
}
func makeCoordinator() -> Coordinator { Coordinator(self, currentIndexCached: currentIndex) }
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var view: PagingView
var currentIndexCached: Int // To detect if currentIndex has changed in updateUIViewController
init(_ pageViewController: PagingView, currentIndexCached: Int) {
self.view = pageViewController
self.currentIndexCached = currentIndexCached
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController
) -> UIViewController? {
let nextIndex = view.currentIndex - 1
guard !view.stopCondition(nextIndex) else { return nil }
return createPage(for: nextIndex)
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController
) -> UIViewController? {
let nextIndex = view.currentIndex + 1
guard !view.stopCondition(nextIndex) else { return nil }
return createPage(for: nextIndex)
}
func pageViewController(
_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted completed: Bool
) {
let visibleViewController = pageViewController.viewControllers?.first
if completed, let visiblePage = visibleViewController as? SinglePageViewController<Page> {
view.currentIndex = visiblePage.index
}
}
fileprivate func createPage(for index: Int) -> SinglePageViewController<Page>? {
SinglePageViewController(index: index, content: view.pageFor(index))
}
}
}
private class SinglePageViewController<Content: View>: UIViewController {
let index: Int
let content: Content
private var contentController: UIViewController?
init(index: Int, content: Content) {
self.index = index
self.content = content
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("This VC is intended for SwfitUI use init(index:, content:) instead")
}
override func viewDidLoad() {
super.viewDidLoad()
let contentController = PageContentViewController(index: index, content: content)
self.contentController = contentController
addChild(contentController)
view.addSubview(contentController.view)
contentController.didMove(toParent: self)
configureContentFrame(sizeForCentering: view.bounds.size)
}
override func viewWillTransition(
to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
// This seems to be the most efficient place to update the frame when the device is rotated.
// But it only works in iOS 16 and newer.
if #available(iOS 16.0, *) {
configureContentFrame(sizeForCentering: size)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Must update frame here in iOS 15
if #unavailable(iOS 16.0) {
configureContentFrame(sizeForCentering: view.bounds.size)
}
}
private func configureContentFrame(sizeForCentering: CGSize) {
// Setting the frame from the UIView (rather than from the SwiftUI view) is necessary for
// interface orientation changes to update the view correctly.
let aspectRatio: CGFloat = 16/9
let width = {
if UIApplication.shared.interfaceOrientation.isPortrait {
return UIScreen.main.bounds.width
} else {
return UIScreen.main.bounds.height * aspectRatio
}
}()
let height = {
if UIApplication.shared.interfaceOrientation.isPortrait {
return width * aspectRatio
} else {
return width / aspectRatio
}
}()
let frame = CGRect(x: 0, y: 0, width: width, height: height)
contentController?.view.frame = frame
contentController?.view.center = CGPoint(
x: sizeForCentering.width / 2, y: sizeForCentering.height / 2
)
}
}
private class PageContentViewController<Content: View>: UIHostingController<Content> {
var index: Int
init(index: Int, content: Content) {
self.index = index
super.init(rootView: content)
_disableSafeArea = true
view.backgroundColor = .clear
}
@objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("This VC is intended for SwfitUI use init(index:, content:) instead")
}
}
#Preview {
struct PagingViewPreview: View {
@State private var currentIndex: Int = 0
var body: some View {
PagingView(
currentIndex: $currentIndex,
// Here you can limit number of pages depending on index or just return false if you
// want infinite pages.
stopCondition: { index in abs(index) > 10 },
pageFor: { index in
// Create your date or whatever according to current index
Text(
Calendar.current
.date(byAdding: .day, value: index, to: Date())!
.formatted(.dateTime)
)
}
)
}
}
return PagingViewPreview()
}
有关如何实现
UIApplication.shared.interfaceOrientation
来检测界面方向,请参阅我的 SO Answer to SwiftUI 以编程方式强制设备旋转