SwiftUIのTabViewをループ可能に!

393 Views

October 29, 26

スライド概要

profile-image

Swiftを書いています

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

SwiftUIのTabViewを スワイプでループ可能に! @Ockey

2.

Ockey 25新卒で2年目 PalcyのiOSアプリエンジニア TCAを使っている KeyPathとenumとguardが好き 2

3.

Palcy ピクシブと講談社が協業で開発するマンガアプリ 2018/8にリリース SwiftUIに移行しながらLiquid Glassに対応中 ©株式会社講談社 3

4.

連載タブ 曜日ごとのタブをネストして、連載作品を表示している 4

5.

連載タブ 曜日のタブはスワイプでループできる 5

6.

連載タブ Liquid Glass対応でコンテンツをタブバーの裏側まで 広げると、iPadの画面回転で崩れてしまう しかし、元の実装はかなり手を加えにくい… Liquid Glass対応にあたり、SwiftUIで再実装できない か試した 6

7.

まずはループできないTabView 7

8.

タブを表すenum 曜日を表すWeekdayを定義する ForEachでallCasesを使えるように、 CaseIterableに準拠しておく enum Weekday: CaseIterable { case monday case tuesday case wednesday case thursday case friday case saturday case sunday } 8

9.

タブを表すenum 動作確認用に、曜日ごとの タイトルと色も定義しておく extension Weekday { var title: String { switch self { case .monday: "月" case .tuesday: "火" case .wednesday: "水" case .thursday: "木" case .friday: "金" case .saturday: "土" case .sunday: "日" extension Weekday { var color: Color { switch self { case .monday: .red case .tuesday: .orange case .wednesday: .yellow case .thursday: .green case .friday: .cyan case .saturday: .blue case .sunday: .purple } } } } } } 9

10.

7つの曜日を表示する TabViewで全てのcaseを 表示する まだループはできない struct WeeklyView: View { var body: some View { TabView { ForEach(Weekday.allCases, id: \.self) { weekday in weekday.color.opacity(0.5) .overlay { Text(weekday.title) .font(.system(size: 200)) } } } .tabViewStyle(.page(indexDisplayMode: .never)) } } 10

11.

7つの曜日を表示する 11

12.

ループできるTabView 12

13.

ループの方針 1. 日曜の右にダミーの月曜を追加する 2. ダミーの月曜に遷移した際に、アニメーションなしで本物の月曜に 差し替える 月曜の左にもダミーの日曜を追加する 13

14.

ダミーを持つenum ダミーの日曜と月曜を追加した LoopableWeekdayを定義する ForEachでallCasesを使えるように、 CaseIterableに準拠しておく enum LoopableWeekday: CaseIterable { case dummySunday case monday case tuesday case wednesday case thursday case friday case saturday case sunday case dummyMonday } 14

15.

ダミーを持つenum Computed Propertyで Weekdayに紐付けておく extension LoopableWeekday { var weekday: Weekday { switch self { case .monday, .dummyMonday: .monday case .tuesday: .tuesday case .wednesday: .wednesday case .thursday: .thursday case .friday: .friday case .saturday: .saturday case .sunday, .dummySunday: .sunday } } } 15

16.

ダミーを持つenum extension LoopableWeekday { var title: String { switch self { case .dummySunday: “_日_” case .monday: "月" 動作確認用に、曜日ごとの タイトルを定義しておく case .tuesday: "火" case .wednesday: "水" case .thursday: "木" case .friday: "金" case .saturday: "土" case .sunday: "日" case .dummyMonday: “_月_” } } } 16

17.

ダミーを持つTabView TabViewで全てのcaseを表示する まだループはできない struct WeeklyView: View { var body: some View { TabView { ForEach(LoopableWeekday.allCases, id: \.self) { loopableWeekday in loopableWeekday.weekday.color.opacity(0.5) .overlay { Text(loopableWeekday.title) .font(.system(size: 150)) } } } .tabViewStyle(.page(indexDisplayMode: .never)) } } 17

18.

ダミーを持つTabView 18

19.

ループの方針 タブの選択状態を制御したい • ダミーの月曜になったら月曜に差し替える • ダミーの日曜になったら日曜に差し替える 19

20.

タブを差し替えられそうなタイミング • 新しいタブの.onAppear • 全体の.onChange(of: selectedDay) • 古いタブのonDisappear 20

21.

呼び出されるタイミング 火曜の .onAppear 全体の .onChange(of: selectedDay) 月曜の onDisappear 21

22.

呼び出されるタイミング • 新しいタブの.onAppear → 新しいタブがほんの少しでも見えたとき • 全体の.onChange(of: selectedDay) → 新しいタブが半分見えたとき • 古いタブのonDisappear → 古いタブが完全に見えなくなったとき 22

23.

呼び出されるタイミング 1/3 新しいタブの.onAppear → 新しいタブがほんの少しでも見えたとき 23

24.

呼び出されるタイミング 1/3 新しいタブの.onAppear → 新しいタブがほんの少しでも見えたとき ダミーの月曜が見え始めた瞬間は、まだ日曜を選択中 このタイミングで月曜に差し替えるのはかなり早い 24

25.

呼び出されるタイミング 2/3 全体の.onChange(of: selectedDay) → 新しいタブが半分見えたとき 25

26.

呼び出されるタイミング 2/3 全体の.onChange(of: selectedDay) → 新しいタブが半分見えたとき タブが半分見える位置から全体が見える位置に瞬間移動 してしまう このタイミングで月曜に差し替えるのも早い 26

27.

呼び出されるタイミング 3/3 古いタブのonDisappear → 古いタブが完全に見えなくなったとき 古いタブが完全に見えなくなったとき、新しいタブの全体が見えている さらに、TabViewのselectionは新しいタブを選択している selectionがダミーの曜日の場合に差し替えれば、瞬間移動に見えない 27

28.

struct WeeklyView: View { @State private var selectedDay: LoopableWeekday = .monday var body: some View { TabView(selection: $selectedDay) { ForEach(LoopableWeekday.allCases, id: \.self) { loopableWeekday in loopableWeekday.weekday.color.opacity(0.5) .overlay { Text(loopableWeekday.title) .font(.system(size: 150)) } .onDisappear { onDisappearTab() } } } .tabViewStyle(.page(indexDisplayMode: .never)) } ForEachのクロージャが受け取る曜日ではなく、 TabViewが選択している曜日を見る } func onDisappearTab() { switch selectedDay { case .dummySunday: selectedDay = .sunday case .dummyMonday: selectedDay = .monday case .monday, .tuesday, .wednesday, .thursday, .friday, .saturday, .sunday: break } } 28

29.

ループはできたが、まだ考慮することがある 29

30.

タブの中身がScrollViewの場合 30

31.

スクロール位置の保持 基本的に、隣接するタブに遷移してから戻った場合は保持される 31

32.

スクロール位置の保持 2つ以上離れたタブに遷移してから戻った場合は保持されない 32

33.

ダミーのタブは注意する必要がある 1. 月曜をスクロール 2. ダミーの日曜にスワイプ 3. スワイプし終わると日曜になる 4. ダミーの月曜にスワイプ ダミーの月曜はスクロールしていないので、 5. スワイプし終わると月曜になる しているように見えてしまう 月曜に変える瞬間にスクロール位置が瞬間移動 33

34.

内部はUICollectionViewになっている 34

35.

内部はUICollectionViewになっている 1つのタブが1つのセルになっている コンテンツをスクロールした後、 そのタブを新しいセルに再利用すると スクロール位置がリセットされてしまう 35

36.

OSバージョンごとの差 月曜のスクロール位置を保持するかどうかはiOSバージョンで異なる • iOS 27.2は保持するので瞬間移動に見える • iOS 18.6は保持しない • iOS 17.5は保持しない 36

37.

対応方針 • ScrollViewのPageスタイルを使う • UIPageViewControllerをUIViewRepresentableで包んで使う • TabViewの中でスクロール位置を制御する 37

38.

対応方針 • ScrollViewのPageスタイルを使う • UIPageViewControllerをUIViewRepresentableで包んで使う • TabViewの中でスクロール位置を制御する 上2つは画面回転時にレイアウト崩れが発生してしまった 38

39.

TabViewの中でスクロール位置を制御する 1. WeekdayのcaseをKey、スクロール位置をValueとするDictionaryを 宣言する 2. .onScrollPhaseChangeで特定のフェーズになったらスクロール位置 を保存する 3. .onAppearで復元する 39

40.

注意点 iOS 18.0以上でのみ可能 iOS 17は対象外だが、瞬間移動が起きていないので許容とする ListではなくScrollView + LazyVStackにする必要がある 曜日ごとの連載作品数はセルの再利用がなくても大丈夫そう 40

41.

スクロール位置を保存する struct WeeklyView: View { @State private var selectedDay: LoopableWeekday = .monday @State private var scrollOffsets: [Weekday: CGFloat] = [:] 1. WeekdayのcaseをKey、スクロール位置を ValueとするDictionaryを宣言する var body: some View { TabView(selection: $selectedDay) { ForEach(LoopableWeekday.allCases, id: \.self) { loopableWeekday in PageContentListView( loopableWeekday: loopableWeekday, scrollOffset: $scrollOffsets[loopableWeekday.weekday] ) .onDisappear { onDisappearTab() } } } .ignoresSafeArea(edges: .vertical) .tabViewStyle(.page(indexDisplayMode: .never)) } } func onDisappearTab() { … } 41

42.
[beta]
スクロール位置を保存する
struct PageContentListView: View {
let loopableWeekday: LoopableWeekday
@Binding var scrollOffset: CGFloat?
@State private var position = ScrollPosition()

2.

var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<100) { index in
Text(loopableWeekday.title + "\(index)")
.font(.title)
.padding(.horizontal)
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.background(loopableWeekday.weekday.color.opacity(0.5))
.scrollPosition($position)
.onAppear {
position.scrollTo(y: scrollOffset ?? 0)
}
.onScrollPhaseChange { oldPhase, newPhase, context in
guard oldPhase.isScrolling, case .idle = newPhase else {
return
}
scrollOffset = context.geometry.contentOffset.y + context.geometry.contentInsets.top
}
}

3.

}

.onScrollPhaseChangeで特定のフェーズになったら
スクロール位置を保存する
.onAppearで復元する

42

43.

スクロール位置を保存する スクロール位置を保存することで、ダミーの タブから切り替える際の瞬間移動をなくせた 43

44.

まとめ 44

45.

まとめ 左右の端にダミーのタブを追加して、ダミータブの全体が見えたタイミング で差し替えることで、ループしているように見せられる ただし、タブの再利用に注意が必要 今回はスクロール位置を保存したが、手を加えていないTabViewとは異なる 振る舞いになることにも注意が必要 45