如何围绕 UIPageViewController 创建一个 SwiftUI 包装器,使其在全屏和旋转时工作?

问题描述 投票:0回答:1

Apple 在 iOS 17 中为 ScrollView 发布了一些新的 SwiftUI 修饰符,允许分页滚动,但我需要一些可以在 iOS 15 及更高版本中使用的东西。

swiftui scrollview fullscreen uipageviewcontroller uiviewcontrollerrepresentable
1个回答
0
投票

这是我能够制作的 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 以编程方式强制设备旋转

© www.soinside.com 2019 - 2024. All rights reserved.