SwiftUI: 無限にページングできるView
TabView
を活用した無限ページングは低速スワイプなら何の問題もなくページングできるのですが、実機で高速スワイプするとページが飛んでしまう不具合がありました。これはどう頑張っても修正できなかったため、TabView
を使うことを諦めてベタ実装する方針に切り替えました。
調査してみると、SwiftUIでカルーセルを実装している前例を見つけました。
こちらをベースに、破綻のない汎用的な無限にページングできるViewを実装しました。
成果物
方針
まず、TabView
ベースの無限ページングViewの課題点を洗い出します。
- タブが完全に切り替わったことを検出する方法がない
- 左右のタブから中央のタブに
selection
が切り替わるのに時間がかかる - もしタブの切り替え中にコンテンツの配列が更新されるとViewの再描画でガタつく
兎にも角にも、タブ切り替えに関するイベントハンドラが全くないので、次のページを捲るたびに中央に戻すという操作が杜撰でした。ページのonAppear()
やonDisappear()
を使ったり、selection
をonChange()
で監視したり色々と模索しましたが、どれも高速スワイプした時に破綻します。
ということで、破綻のない無限ページングViewに求められる要件はこんな感じでしょう。
- ページが切り替わったタイミングでイベントをハンドリングできる
- 左右のページから中央のページに切り替えるのが一瞬
- ページの切り替え中にコンテンツの更新をしないで済む
こんなViewを作ります。
実装
3つのページでうまいことやりくりする方針は悪くないのでそれは踏襲します。
3つのページをHStack
で横並びにしておいて、offset()
でずらしてページングを表現します。
もちろん1ページあたりの横幅は無限ページングViewの描画領域の幅と同等にします。
横幅を取得するために仕方なくGeometryReader
を使います。
このままだと、無限ページングView自体がHStack
などに入っていて両脇に何かViewがある状態だとそのViewにはみ出したページが重なってしまうので全体をclipped()
します。
ページングはDragGesture(minimumDistance: 0)
を用いてベタ実装でやっていきます。
onEnded
で指を離した時の座標を考慮してページングするかしないかを決め、次のページの座標までオフセットをずらすか、元いたページの座標までオフセットを戻します。
このオフセットの移動をwithAnimation
で包んでスムーズに行わせるのですが、iOS 17から使える様になった新しいAPIのwithAnimation(_:completionCriteria:_:completion:)
を用いて、アニメーションが完了した際=
ページが切り替わったタイミングで次のページデータのリクエストを投げる様にします。
オブジェクトに変更があった時にそれを元にページを更新し、それと同時にオフセットを中央のページを表示する様に更新すればユーザーには気づかれずにページシフトすることができます。
また、画面が回転した場合に横幅が動的に変更されるため、onChange()
で横幅を監視しておいて変更が検出されたらオフセットを修正する様にもしておきます。
なおページのデータはページングの都合上Equatable
とIdentifiable
に準拠している必要があるため、Pageable := Equatable & Identifiable
というprotocol
を用意してそれに準拠させることとします。
import SwiftUI
public enum PageDirection {
case backward
case forward
}
public protocol Pageable: Equatable & Identifiable {}
struct InfinitePagingViewModifier<T: Pageable>: ViewModifier {
@Binding var objects: [T]
@Binding var pageWidth: CGFloat
@State var pagingOffset: CGFloat
@State var draggingOffset: CGFloat
let pagingHandler: (PageDirection) -> Void
var dragGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
draggingOffset = value.translation.width
}
.onEnded { value in
let oldIndex = Int(floor(0.5 - (pagingOffset / pageWidth)))
pagingOffset += value.translation.width
draggingOffset = 0
let newIndex = Int(max(0, min(2, floor(0.5 - (pagingOffset / pageWidth)))))
withAnimation(.linear(duration: 0.1)) {
pagingOffset = -pageWidth * CGFloat(newIndex)
} completion: {
if newIndex == oldIndex { return }
if newIndex == 0 {
pagingHandler(.backward)
}
if newIndex == 2 {
pagingHandler(.forward)
}
}
}
}
init(
objects: Binding<[T]>,
pageWidth: Binding<CGFloat>,
pagingHandler: @escaping (PageDirection) -> Void
) {
_objects = objects
_pageWidth = pageWidth
_pagingOffset = State(initialValue: -pageWidth.wrappedValue)
_draggingOffset = State(initialValue: 0)
self.pagingHandler = pagingHandler
}
func body(content: Content) -> some View {
content
.offset(x: pagingOffset + draggingOffset, y: 0)
.simultaneousGesture(dragGesture)
.onChange(of: objects) { _, _ in
pagingOffset = -pageWidth
}
.onChange(of: pageWidth) { _, _ in
pagingOffset = -pageWidth
}
}
}
public struct InfinitePagingView<T: Pageable, Content: View>: View {
@Binding var objects: [T]
let pagingHandler: (PageDirection) -> Void
let content: (T) -> Content
public init(
objects: Binding<[T]>,
pagingHandler: @escaping (PageDirection) -> Void,
@ViewBuilder content: @escaping (T) -> Content
) {
assert(objects.wrappedValue.count == 3, "objects count must be 3.")
_objects = objects
self.pagingHandler = pagingHandler
self.content = content
}
public var body: some View {
GeometryReader { proxy in
HStack(alignment: .center, spacing: 0) {
ForEach(objects) { object in
content(object)
.frame(width: proxy.size.width, height: proxy.size.height)
}
}
.modifier(
InfinitePagingViewModifier(
objects: $objects,
pageWidth: Binding<CGFloat>(
get: { proxy.size.width },
set: { _ in }
),
pagingHandler: pagingHandler
)
)
}
.clipped()
}
}
使用例
import SwiftUI
import InfinitePaging
struct Page: Pageable {
var id = UUID()
var number: Int
}
struct ContentView: View {
// 最初に表示される3つのページ
@State var pages: [Page] = [
Page(number: -1),
Page(number: 0),
Page(number: 1)
]
var body: some View {
InfinitePagingView(
objects: $pages,
pagingHandler: { pageDirection in
paging(pageDirection)
},
content: { page in
pageView(page)
}
)
}
// 1ページを構成するViewを定義
private func pageView(_ page: Page) -> some View {
return Text(String(page.number))
.font(.largeTitle)
.fontWeight(.bold)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.gray)
.clipShape(RoundedRectangle(cornerRadius: 32))
.padding()
}
// ページングのリクエストが来た時に配列をシフトする
private func paging(_ pageDirection: PageDirection) {
switch pageDirection {
case .backward:
if let number = pages.first?.number {
pages.insert(Page(number: number - 1), at: 0)
pages.removeLast()
}
case .forward:
if let number = pages.last?.number {
pages.append(Page(number: number + 1))
pages.removeFirst()
}
}
}
}
破綻のない高速ページング
Discussion
「どうしよう、一から自分で作るのはなぁ…」と思っていたので、とても助かりました!ありがとうございます!!
dragGesture
に、1つ問題と、1つ大きな改善点があったので、良かったら見てください。newIndex
を決めるとき、もしfloor
を使う場合0.5
ではなく1.5
となります。0.5
の場合はceil
ですね。ここでは分かりやすく
round
を使い、1
にしました。もう一つ、
value.predictedEndTranslation.width
を使うと、予測される移動量が出てきます。この方が、かなり使い勝手が良いです。あと、好みもあると思いますが、アニメーションを
smooth
に変更し、不要なコードを削除しました。これらの修正で、iPhone、iPadでの実際の使用感がかなり改善されると思います。
newIndex
を求める段階でのpagingOffset / pageSize = s
の値域は-2 ≤ s ≤ 0
つまり
floor(0.5 - s) = t
の値域は2 ≥ t ≥ 0
です。根拠
index
は0~2の整数を取れば良いのでfloor
を使った式であっています。(1.5で計算すると 1~3になってしまいます。)ちなみに、
oldIndex
は基本的に1になるようにしています。3つページがあるうち、基本的に中央のページを表示し続ける仕組みであることに加えて、ArrayのIndexが0から始まることが理由です。
1.5にしたり
ceil
を使わないといけないという判断になったのは私のコードを変更してpredictedEndTranslation.width
を使うようにしたことが原因ではないでしょうか?value.predictedEndTranslation / pageSize = s’
の値域を調べてみたところ-1 ≤ s’ ≤ 1
でした。pagingOffset
とvalue.predicatedEndTranslation
では値域が異なるので、当然newIndex
を算出する計算式も変わりますね。すみません。
pagingOffset
の値域を勘違いしてました。お手数おかけしました