🦋

SwiftUI: 無限にページングできるView

2023/10/23に公開4

TabViewを活用した無限ページングは低速スワイプなら何の問題もなくページングできるのですが、実機で高速スワイプするとページが飛んでしまう不具合がありました。これはどう頑張っても修正できなかったため、TabViewを使うことを諦めてベタ実装する方針に切り替えました。

調査してみると、SwiftUIでカルーセルを実装している前例を見つけました。
https://qiita.com/fumiyasac@github/items/b5b313d9807ff858a73c

こちらをベースに、破綻のない汎用的な無限にページングできるViewを実装しました。

成果物
https://github.com/Kyome22/InfinitePaging


方針

まず、TabViewベースの無限ページングViewの課題点を洗い出します。

  1. タブが完全に切り替わったことを検出する方法がない
  2. 左右のタブから中央のタブにselectionが切り替わるのに時間がかかる
  3. もしタブの切り替え中にコンテンツの配列が更新されるとViewの再描画でガタつく

兎にも角にも、タブ切り替えに関するイベントハンドラが全くないので、次のページを捲るたびに中央に戻すという操作が杜撰でした。ページのonAppear()onDisappear()を使ったり、selectiononChange()で監視したり色々と模索しましたが、どれも高速スワイプした時に破綻します。

ということで、破綻のない無限ページングViewに求められる要件はこんな感じでしょう。

  1. ページが切り替わったタイミングでイベントをハンドリングできる
  2. 左右のページから中央のページに切り替えるのが一瞬
  3. ページの切り替え中にコンテンツの更新をしないで済む

こんな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()で横幅を監視しておいて変更が検出されたらオフセットを修正する様にもしておきます。

なおページのデータはページングの都合上EquatableIdentifiableに準拠している必要があるため、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

unun

「どうしよう、一から自分で作るのはなぁ…」と思っていたので、とても助かりました!ありがとうございます!!

dragGestureに、1つ問題と、1つ大きな改善点があったので、良かったら見てください。

    var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                if isScroll {
                    draggingOffset = value.translation.width
                }
            }
            .onEnded { value in
                if isScroll {
                    pagingOffset += value.translation.width
                    draggingOffset = 0
                    // ↓ここを修正
                    let newIndex = Int(max(0, min(2, round(1 - value.predictedEndTranslation.width / pageWidth))))
                    withAnimation(.smooth(duration: 0.1)) {
                        pagingOffset = -pageWidth * CGFloat(newIndex)
                    } completion: {
                        if newIndex == 0 {
                            pagingHandler(.backward)
                        }
                        if newIndex == 2 {
                            pagingHandler(.forward)
                        }
                    }
                }
            }
    }

newIndexを決めるとき、もしfloorを使う場合0.5ではなく1.5となります。0.5の場合はceilですね。
ここでは分かりやすくroundを使い、1にしました。

もう一つ、value.predictedEndTranslation.widthを使うと、予測される移動量が出てきます。この方が、かなり使い勝手が良いです。

あと、好みもあると思いますが、アニメーションをsmoothに変更し、不要なコードを削除しました。

これらの修正で、iPhone、iPadでの実際の使用感がかなり改善されると思います。

KyomeKyome

newIndexを求める段階での pagingOffset / pageSize = s の値域は -2 ≤ s ≤ 0

つまり floor(0.5 - s) = t の値域は 2 ≥ t ≥ 0です。

根拠
var s: Double = -2.0
(0 ... 20).forEach { _ in
    Swift.print(String(format: "%.1lf", s), floor(0.5 - s))
    s += 0.1
}
-2.0 2.0
-1.9 2.0
-1.8 2.0
-1.7 2.0
-1.6 2.0
-1.5 1.0
-1.4 1.0
-1.3 1.0
-1.2 1.0
-1.1 1.0
-1.0 1.0
-0.9 1.0
-0.8 1.0
-0.7 1.0
-0.6 1.0
-0.5 0.0
-0.4 0.0
-0.3 0.0
-0.2 0.0
-0.1 0.0
0.0 0.0

indexは0~2の整数を取れば良いので floorを使った式であっています。(1.5で計算すると 1~3になってしまいます。)

ちなみに、oldIndexは基本的に1になるようにしています。
3つページがあるうち、基本的に中央のページを表示し続ける仕組みであることに加えて、ArrayのIndexが0から始まることが理由です。

1.5にしたりceilを使わないといけないという判断になったのは私のコードを変更してpredictedEndTranslation.width を使うようにしたことが原因ではないでしょうか?

KyomeKyome

value.predictedEndTranslation / pageSize = s’ の値域を調べてみたところ -1 ≤ s’ ≤ 1 でした。
pagingOffsetvalue.predicatedEndTranslation では値域が異なるので、当然newIndexを算出する計算式も変わりますね。

unun

すみません。pagingOffsetの値域を勘違いしてました。お手数おかけしました