---
title: SwiftUIのTabViewをループ可能に！
tags: 
author: [Ockey](https://image.docswell.com/user/Ockey)
site: [Docswell](https://www.docswell.com/)
thumbnail: https://bcdn.docswell.com/page/8JDG98R6JG.jpg?width=480
description: SwiftUIのTabViewをループ可能に！ by Ockey
published: October 29, 26
canonical: https://image.docswell.com/s/Ockey/K7N8E7-LoopableTabView
---
# Page. 1

![Page Image](https://bcdn.docswell.com/page/8JDG98R6JG.jpg)

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


# Page. 2

![Page Image](https://bcdn.docswell.com/page/VEP3Z8MZ78.jpg)

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


# Page. 3

![Page Image](https://bcdn.docswell.com/page/27V4WN9MEQ.jpg)

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


# Page. 4

![Page Image](https://bcdn.docswell.com/page/5JG1DKZX7L.jpg)

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


# Page. 5

![Page Image](https://bcdn.docswell.com/page/47QD1NL5EP.jpg)

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


# Page. 6

![Page Image](https://bcdn.docswell.com/page/KE4ZRGDVJ1.jpg)

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


# Page. 7

![Page Image](https://bcdn.docswell.com/page/L71RQDZ47G.jpg)

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


# Page. 8

![Page Image](https://bcdn.docswell.com/page/G7W1MY9ZJ2.jpg)

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


# Page. 9

![Page Image](https://bcdn.docswell.com/page/4JZPQX9L73.jpg)

タブを表すenum
動作確認用に、曜日ごとの
タイトルと色も定義しておく
extension Weekday {
var title: String {
switch self {
case .monday:
&quot;月&quot;
case .tuesday:
&quot;火&quot;
case .wednesday:
&quot;水&quot;
case .thursday:
&quot;木&quot;
case .friday:
&quot;金&quot;
case .saturday:
&quot;土&quot;
case .sunday:
&quot;日&quot;
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


# Page. 10

![Page Image](https://bcdn.docswell.com/page/YE6M64KMJV.jpg)

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


# Page. 11

![Page Image](https://bcdn.docswell.com/page/GE5Z6QPQ74.jpg)

7つの曜日を表示する
11


# Page. 12

![Page Image](https://bcdn.docswell.com/page/972RMP6WER.jpg)

ループできるTabView
12


# Page. 13

![Page Image](https://bcdn.docswell.com/page/DJYDX599JM.jpg)

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


# Page. 14

![Page Image](https://bcdn.docswell.com/page/V7N61NLD78.jpg)

ダミーを持つ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


# Page. 15

![Page Image](https://bcdn.docswell.com/page/YJ9L5R48E3.jpg)

ダミーを持つ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


# Page. 16

![Page Image](https://bcdn.docswell.com/page/GJ8XYWQZ7D.jpg)

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


# Page. 17

![Page Image](https://bcdn.docswell.com/page/LJL84NX1ER.jpg)

ダミーを持つ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


# Page. 18

![Page Image](https://bcdn.docswell.com/page/47M6KXL5JW.jpg)

ダミーを持つTabView
18


# Page. 19

![Page Image](https://bcdn.docswell.com/page/P7RPXNKZJ9.jpg)

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


# Page. 20

![Page Image](https://bcdn.docswell.com/page/PJX36NL17X.jpg)

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


# Page. 21

![Page Image](https://bcdn.docswell.com/page/3JKY1NLM7D.jpg)

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


# Page. 22

![Page Image](https://bcdn.docswell.com/page/LE39QV32E5.jpg)

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


# Page. 23

![Page Image](https://bcdn.docswell.com/page/8EDG9846EG.jpg)

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


# Page. 24

![Page Image](https://bcdn.docswell.com/page/V7P3Z8VZJ8.jpg)

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


# Page. 25

![Page Image](https://bcdn.docswell.com/page/2JV4WNZM7Q.jpg)

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


# Page. 26

![Page Image](https://bcdn.docswell.com/page/5EG1DK4XJL.jpg)

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


# Page. 27

![Page Image](https://bcdn.docswell.com/page/4JQD1NG57P.jpg)

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


# Page. 28

![Page Image](https://bcdn.docswell.com/page/K74ZRG5VE1.jpg)

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


# Page. 29

![Page Image](https://bcdn.docswell.com/page/LJ1RQDW4JG.jpg)

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


# Page. 30

![Page Image](https://bcdn.docswell.com/page/GJW1MY6ZE2.jpg)

タブの中身がScrollViewの場合
30


# Page. 31

![Page Image](https://bcdn.docswell.com/page/4EZPQXYLJ3.jpg)

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


# Page. 32

![Page Image](https://bcdn.docswell.com/page/Y76M64DMEV.jpg)

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


# Page. 33

![Page Image](https://bcdn.docswell.com/page/G75Z6Q3QJ4.jpg)

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


# Page. 34

![Page Image](https://bcdn.docswell.com/page/9J2RMPZW7R.jpg)

内部はUICollectionViewになっている
34


# Page. 35

![Page Image](https://bcdn.docswell.com/page/DEYDX5R9EM.jpg)

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


# Page. 36

![Page Image](https://bcdn.docswell.com/page/VJN61NDDJ8.jpg)

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


# Page. 37

![Page Image](https://bcdn.docswell.com/page/YE9L5RG873.jpg)

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


# Page. 38

![Page Image](https://bcdn.docswell.com/page/GE8XYWVZJD.jpg)

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


# Page. 39

![Page Image](https://bcdn.docswell.com/page/LEL84N517R.jpg)

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


# Page. 40

![Page Image](https://bcdn.docswell.com/page/4JM6KX35EW.jpg)

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


# Page. 41

![Page Image](https://bcdn.docswell.com/page/PJRPXNQZE9.jpg)

スクロール位置を保存する
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


# Page. 42

![Page Image](https://bcdn.docswell.com/page/PEX36N51JX.jpg)

スクロール位置を保存する
struct PageContentListView: View {
let loopableWeekday: LoopableWeekday
@Binding var scrollOffset: CGFloat?
@State private var position = ScrollPosition()
2.
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..&lt;100) { index in
Text(loopableWeekday.title + &quot;\(index)&quot;)
.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


# Page. 43

![Page Image](https://bcdn.docswell.com/page/3EKY1N6MJD.jpg)

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


# Page. 44

![Page Image](https://bcdn.docswell.com/page/L739QV6275.jpg)

まとめ
44


# Page. 45

![Page Image](https://bcdn.docswell.com/page/87DG98V67G.jpg)

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


