やりたきこと
要件
- カードをページ切り替えできる
- カード以外の部分もスワイプジェスチャが使える
- カード以外の部分のアニメーションはスワイプに連動してカスタマイズできる
アプローチ
サンプルはアップルの公式プロジェクトLandMarkを利用する
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit
-
カードをページ切り替えできる
→PageViewController
で実装する -
カード以外の部分もスワイプジェスチャが使える
→PageViewController
に与えるpage
にカード以外の部分を包含する -
カード以外の部分のアニメーションはスワイプに連動してカスタマイズできる
→PageViewController
からscrollOffset
をexposeして、カード以外の部分のアニメーションに使う
実際のコード
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
を使う。
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がセットされないため、真っ白な次カードが表示されてしまう。つまり下記の部分のメソッドが呼び出されていない
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