0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

やりたきこと

page_view_sync_with_text.gif

要件

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

アプローチ

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

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

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

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

実際のコード

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を使う。

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.xPageViewController渡してセットする
↑うまくいかない。カードの部分はスクロールするものの、次のカードのControllerがセットされないため、真っ白な次カードが表示されてしまう。つまり下記の部分のメソッドが呼び出されていない

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?