🦋
SwiftUI: 無限にページングできるTabView
カレンダーの実装をしていて、翌月や前月を無限にページングしたいと思ったところ、TabView
のPageTabViewStyle
のように左右スクロールでページングを行うにはかなりテクいことをしないといけないことがわかりました。
デモ
両方向にページングしているところ
実装
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
と比較して差分があったら捲るようにする- ページングの方向を検出するのは、
selection
とpreviousPage
のindex
から前後関係を算出すればいい
- ページングの方向を検出するのは、
-
id
にindexを使うと余計に色々計算しないといけないのでUUID
を使うのが良さそう
Discussion