🦋

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

2023/09/04に公開

カレンダーの実装をしていて、翌月や前月を無限にページングしたいと思ったところ、TabViewPageTabViewStyleのように左右スクロールでページングを行うにはかなりテクいことをしないといけないことがわかりました。

デモ

デモ
両方向にページングしているところ

実装

PageDirection {
enum PageDirection {
    case backward
    case forward

    var baseIndex: Int {
        switch self {
        case .backward:
            return 0
        case .forward:
            return 2
        }
    }
}
Page
struct Page<T: Hashable>: Hashable, Identifiable {
    var id = UUID()
    var index: Int
    var object: T
}
InfinitePagingView
struct InfinitePagingView<T: Hashable, Content: View>: View {
    @Binding var objects: [T]
    @State var title: String = ""
    @State var pages: [Page<T>]
    @State var selection: Page<T>
    @State var previousPage: Page<T>
    private let pagingHandler: (PageDirection) -> Void
    private let content: (T) -> Content

    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
        let pages = (0 ..< 3).map { Page(index: $0, object: objects.wrappedValue[$0]) }
        _pages = State(initialValue: pages)
        _selection = State(initialValue: pages[1])
        _previousPage = State(initialValue: pages[1])
        self.pagingHandler = pagingHandler
        self.content = content
    }

    var body: some View {
        TabView(selection: $selection) {
            ForEach(pages) { page in
                content(page.object)
                    .tag(page)
                    .onDisappear {
                        pagingIfNeeded()
                    }
            }
        }
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
        .onChange(of: objects) { _ in
            updatePages()
        }
    }

    private func pagingIfNeeded() {
        if selection.index < previousPage.index {
            pagingHandler(.backward)
        } else if previousPage.index < selection.index {
            pagingHandler(.forward)
        }
    }

    private func updatePages() {
        pages = zip(pages, objects).map { (page, object) in
            return Page(id: page.id, index: page.index, object: object)
        }
        selection = pages[1]
        previousPage = selection
    }
}

使い方

InfinitePagingView(
    objects: 配列,
    pagingHandler: { pageDirection in
        pageDirectionに合わせて配列の要素をずらす
    },
    content: { 配列の要素 in
        配列の要素を用いるView
    }
)

ざっくり解説

  • タブは全部で3つ。それをやりくりしてあたかも無限にページングしているかのように見せる
  • ページを捲り終わった時に真ん中のタブに強制的に飛ばす
    • 真ん中のタブに強制的に飛ばすにはselectionを内部で書き換える
    • このときコンテンツは一個ずつずらしておいて証拠隠滅
  • ページを捲り終わったことはonDisappear()で検出できる
    • ただし、ページを捲り終わった時以外にも発火するのでフィルタする必要がある
  • selectionの変化だけを監視していると、ページを捲ろうとして結局捲らなかったというフェイク操作の時に不具合を起こすので、previousPageを用意してselectionと比較して差分があったら捲るようにする
    • ページングの方向を検出するのは、selectionpreviousPageindexから前後関係を算出すればいい
  • idにindexを使うと余計に色々計算しないといけないのでUUIDを使うのが良さそう

Discussion