スワイプでページ切り替えと指定のアニメーションを同時に行う

## やりたきこと

![page_view_sync_with_text.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2332865/a91f102f-7473-98bf-0b18-6120522edd06.gif)

要件
1. カードをページ切り替えできる
2. カード以外の部分もスワイプジェスチャが使える
3. カード以外の部分のアニメーションはスワイプに連動してカスタマイズできる

## アプローチ

サンプルはアップルの公式プロジェクトLandMarkを利用する
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit

1. カードをページ切り替えできる
→ `PageViewController`で実装する

2. カード以外の部分もスワイプジェスチャが使える
→ `PageViewController`に与える`page`にカード以外の部分を包含する

3. カード以外の部分のアニメーションはスワイプに連動してカスタマイズできる
→ `PageViewController`から`scrollOffset`をexposeして、カード以外の部分のアニメーションに使う

### 実際のコード

```swift:PageViewController
import SwiftUI
import UIKit

struct PageViewController<Page: View>: UIViewControllerRepresentable {
  var pages: [Page]
  @Binding var currentPage: Int
  @Binding var scrollOffset: CGFloat // scrollOffsetをexpose

  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }

  func makeUIViewController(context: Context) -> UIPageViewController {
    let pageViewController = UIPageViewController(
      transitionStyle: .scroll,
      navigationOrientation: .horizontal)
    pageViewController.dataSource = context.coordinator
    pageViewController.delegate = context.coordinator

    // scrollViewのdelegateをセット
    if let scrollView = pageViewController.view.subviews.first(where: { $0 is UIScrollView })
      as? UIScrollView
    {
      scrollView.delegate = context.coordinator
    }

    pageViewController.setViewControllers(
      [context.coordinator.controllers[currentPage]],
      direction: .forward,
      animated: true)

    return pageViewController
  }

  func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
    // scrollOffsetを更新
    if let scrollView = pageViewController.view.subviews.first(where: { $0 is UIScrollView })
      as? UIScrollView
    {
      scrollOffset = scrollView.contentOffset.x
      print("scrollOffset: \(scrollOffset)")
    }
  }

  // UIScrollViewDelegateも継承
  class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate,
    UIScrollViewDelegate
  {
    var parent: PageViewController
    var controllers = [UIViewController]()

    init(_ pageViewController: PageViewController) {
      parent = pageViewController
      controllers = parent.pages.map { UIHostingController(rootView: $0) }
    }

    func pageViewController(
      _ pageViewController: UIPageViewController,
      viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
      guard let index = controllers.firstIndex(of: viewController) else {
        return nil
      }
      if index == 0 {
        return controllers.last
      }
      return controllers[index - 1]
    }

    func pageViewController(
      _ pageViewController: UIPageViewController,
      viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
      guard let index = controllers.firstIndex(of: viewController) else {
        return nil
      }
      if index + 1 == controllers.count {
        return controllers.first
      }
      return controllers[index + 1]
    }

    func pageViewController(
      _ pageViewController: UIPageViewController,
      didFinishAnimating finished: Bool,
      previousViewControllers: [UIViewController],
      transitionCompleted completed: Bool
    ) {
      if completed,
        let visibleViewController = pageViewController.viewControllers?.first,
        let index = controllers.firstIndex(of: visibleViewController)
      {
        parent.currentPage = index
      }
    }

    // boilerplate
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
      parent.scrollOffset = scrollView.contentOffset.x
    }
  }
}

```

SwiftUIの`PageView`側で、`page`にアニメーションのカスタマイズをしたい部分（サンプルでは`AAA`などのテキスト）のスペースを確保して、`ZStack`に入れる。
アニメーションはexposeされた`scrollOffSet`を使う。

```swift:PageView
import SwiftUI

struct PageView<Page: View>: View {
  var pages: [Page]
  @State private var currentPage = 0
  @State private var scrollOffset: CGFloat = 0
  let width = UIScreen.main.bounds.width
  let texts = ["AAA", "BBB", "CCC"]

  private func getVStackPages() -> [some View] {
    return pages.map { page in
      VStack {
        page
        // スペース確保
        Text("placeHolder")
          .font(.largeTitle)
          .opacity(0)
      }
    }
  }

  var body: some View {
    ZStack(alignment: .bottom) {
      ZStack(alignment: .bottomTrailing) {
        PageViewController(
          pages: getVStackPages(),
          currentPage: $currentPage,
          scrollOffset: $scrollOffset)
        PageControl(numberOfPages: pages.count, currentPage: $currentPage)
          .frame(width: CGFloat(pages.count * 18))
          .padding(.trailing)
          .offset(y: -56)
      }
      .aspectRatio(4 / 3, contentMode: .fit)

      // アニメーションをカスタマイズしたい部分
      ZStack {
        Text(texts[currentPage])
          .font(.largeTitle)
          .opacity(1.0 - abs((scrollOffset - width) / width))

        Text(texts[getNextPageIndex()])
          .font(.largeTitle)
          .opacity(abs((scrollOffset - width) / width))
      }
      .allowsHitTesting(false)  // スワイプが貫通できるようにする
    }
  }

  private func getNextPageIndex() -> Int {
    let direction = scrollOffset - width < 0 ? -1 : 1
    let nextIndex = currentPage + direction

    // Handle wraparound
    if nextIndex < 0 {
      return texts.count - 1
    } else if nextIndex >= texts.count {
      return 0
    }
    return nextIndex
  }
}
```

## 失敗したアプローチ

Text(`AAA`)の部分をGesture検知して、`scrollOffset.x`を`PageViewController`渡してセットする
↑うまくいかない。カードの部分はスクロールするものの、次のカードのControllerがセットされないため、真っ白な次カードが表示されてしまう。つまり下記の部分のメソッドが呼び出されていない

```swift:PageViewController.Coordinator
    func pageViewController(
      _ pageViewController: UIPageViewController,
      viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
      guard let index = controllers.firstIndex(of: viewController) else {
        return nil
      }
      if index == 0 {
        return controllers.last
      }
      return controllers[index - 1]
    }

    func pageViewController(
      _ pageViewController: UIPageViewController,
      viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
      guard let index = controllers.firstIndex(of: viewController) else {
        return nil
      }
      if index + 1 == controllers.count {
        return controllers.first
      }
      return controllers[index + 1]
    }
```

同じ問題はstackoverflowでもあった
https://stackoverflow.com/questions/59137120/scroll-uipageviewcontroller-programmatically