NestedScrollを利用した表現とSwiftUIでの再現方法のまとめ
!
ヘッダーやタブのアニメーションなど、複雑なUI演出をAndroidのNestedScrollで実装する方法を整理し、iOS(SwiftUI)での再現手法をまとめた記事です。
背景画像の上に一覧を重ね、スクロールするとシートが上へ広がる。さらにスクロールすると日付タブが小さくなり、その後は本文だけが流れていく。このような画面では、スクロールできる要素を並べるだけでなく、どの要素が、どの順番で移動量を使うかを設計する必要があります。
AndroidのJetpack Composeでは、Nested Scrollの仕組みを使って親と子に移動量を配分できます。一方、SwiftUIでは、スクロール位置の観測と固定ヘッダーのレイアウトを組み合わせることで、見た目が近い画面を構成できます。
本記事では、DroidKaigi 2023のタイムテーブル画面と、SwiftUIの再現サンプルをもとに、シート・タブ・本文の関係を整理します。参照コードの構成を説明したうえで、仕組みを理解するためのコード例と、設計時に確認したい点を示します。
Androidの説明はPR #979の最終パッチに対応するコミットを基準にしています。SwiftUIの数値は LikeCoodinatorLayoutExample の参照実装を基準にしています。説明用コードは、それぞれの考え方を抜き出した別のサンプルです。[1][2]
1. 最初に分けたい、3種類の状態
「ヘッダーとタブがスクロールに連動する」という説明には、異なる処理が含まれています。
状態
変化する対象
変化のきっかけ
シートの展開状態
背景ヘッダーを覆う範囲、シートの位置・サイズ
縦方向の移動量
タブの折りたたみ状態
タブ行の高さ、日付などの補助情報
移動量、またはスクロール位置
タブの選択状態
表示する日付のデータ、選択背景の位置
タブのタップ
参照Android実装の外側では、シートが上へ移動して背景ヘッダーを覆います。内側では、日付タブの高さや表示内容が変わります。シートの展開と、タブの折りたたみは別の状態です。選択中タブの背景が横へ動く処理も、縦スクロールによる縮小とは分けて考えます。
また、ここで扱うのは同じ画面内のスクロールと表示変化です。タブ変更で本文データが切り替わることと、スクロール位置から選択タブを自動判定することも異なります。参照実装の中心は、シートとタブのサイズ・位置の制御です。
2. Android:外側のシートと内側のタブに接続を置く
2.1 参照実装の階層
タイムテーブル画面は、おおむね次の責務に分かれています。[1]
要素
主な責務
TimetableScreen / Scaffold
上部バー、背景ヘッダー、シートを配置。外側の nestedScroll を接続
TimetableScreenScrollState
シートの移動範囲と sheetScrollOffset を管理
TimetableSheet
日付タブと選択日の本文を配置。内側の nestedScroll を接続
TimetableSheetContentScrollState
本文との間で移動量を配分し、タブの状態を更新
TimetableTabState
タブのオフセットと tabCollapseProgress を管理
TimetableList など
本文をスクロール表示
外側と内側の接続を分けることで、本文のスクロールに先立ってシートを動かし、その後タブを縮める構成を表現できます。
Modifier.nestedScroll(connection) は、親子のスクロール処理に参加するための接続です。これを Box や Column に付けるだけで、その要素が指のドラッグでスクロールするようになるわけではありません。LazyColumn などのスクロール可能な子が発生させるイベントを、親の接続で受け取ります。[4]
2.2 onPreScroll と onPostScroll の役割
Nested Scrollでは、子が使う前の移動量を親が受け取る「pre」と、子が使い終わった残りを親が受け取る「post」があります。複数の親がある場合、preは外側から内側へ、postは内側から外側へ処理が戻ります。
参照実装の方針は、次のように整理できます。
入力の方向
処理する段階
方針
上方向:available.y < 0
onPreScroll
可動範囲が残る親が先に消費し、残りを子へ渡す
下方向:available.y > 0
onPostScroll
本文が先に戻り、本文で使い切れなかった分で親を元に戻す
したがって、下へドラッグしただけで、常にヘッダーがすぐ展開するわけではありません。本文が途中にある間は、本文側が移動量を消費します。先頭付近で本文が使い切れなくなったときに、内側のタブ、外側のシートへ余りが届きます。
図の上方向の例では、入力 −90 pxを、外側 −40 px、内側 −20 px、本文 −30 pxへ配分しています。下方向の例では、本文が +10 pxを使い、内側が +20 px、外側が +40 pxを使った後も +10 pxが残ります。これは順序を説明する仮の数値であり、実際の消費量は各要素の現在位置と可動範囲で変わります。
onPostScroll の consumed は先行する処理が消費した量、available はその段階で利用できる残りです。親がさらに消費するときは available を使います。consumed + available をもう一度親へ適用すると、すでに使った移動量まで重ねて処理してしまいます。[4]
2.3 戻すのは、実際に状態が変わった量
親の可動範囲には上限があります。例えば、あと10 pxしか動けない親に −30 pxが届いた場合、親の戻り値は −10 pxです。残り −20 pxを子へ渡します。
private fun consume(deltaY: Float): Float {
val before = offsetPx
offsetPx = (before + deltaY).coerceIn(-rangePx, 0f)
return offsetPx - before
}
val connection = object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset = if (available.y < 0f) {
Offset(0f, consume(available.y))
} else Offset.Zero
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset = if (available.y > 0f) {
Offset(0f, consume(available.y))
} else Offset.Zero
}
この consume を、外側と内側にそれぞれ独立した状態として持たせます。縦方向の処理なので、戻り値の x は0です。available 全体をそのまま返すと、実際には動いていない量まで消費済みになり、本文のスクロールを止めてしまいます。
説明用の画面全体は NestedConsumptionExample.kt に収録しています。外側ヘッダーを180→84 dp、タブを84→60 dpに変える構成で、参照実装のシート描画をそのまま複製したものではありません。
3. オフセットを、連続的な折りたたみ進捗へ変換する
3.1 進捗は0〜1へ正規化する
タブの可動範囲を R > 0、現在のオフセットを o ∈ [-R, 0] とすると、折りたたみ進捗は次の式で求められます。
p = clamp(-o / R, 0, 1)
p = 0:展開状態
p = 1:折りたたみ完了
途中の値:指の移動に追従する中間状態
参照Android実装では、タブ行が84 dpから60 dpへ変わるため、可動範囲は24 dpです。実際の状態では、この24 dpをpxへ変換した負の下限値を保持し、オフセットを範囲内に制限しています。[1]
3.2 高さと透明度に、異なる式を使う
参照コードの変化を整理すると、次のようになります。
タブ行の最小高さ = 84 - 24p [dp]
日付の透明度 = max(1 - 2p, 0)
日付の占有高さ = 元の高さ × (1 - p)
タブ行の84→60 dpは defaultMinSize で指定する最小高さです。文字サイズや子の必要高さが大きい場合は、実際の高さがこれを超えることがあります。
日付の文字は p = 0.5 で透明になりますが、占有する高さはまだ半分残っています。先に文字を消し、残りのスクロールで余白を縮めることで、文字が押しつぶされるような見え方を避ける設計です。
これは 位置と一対一で対応する連続変化です。毎回のスクロール更新に時間ベースのアニメーションを重ねると、指の位置に対して表示が遅れる場合があります。ドラッグ中は進捗から直接計算し、タブのタップや停止後のスナップなど、目標が切り替わる操作にアニメーションを使うと整理しやすくなります。
4. シートの配置では、測定・移動・単位を分ける
4.1 layout はサイズと配置の両方に関わる
参照Androidコードは、onGloballyPositioned でヘッダーの高さを取得し、シートの可動範囲を決めています。さらに layout 内で、測定に渡す高さの上限と子の配置位置を調整します。[1]
sheetScrollOffset を s、受け取った高さの上限を H とすると、コード上の計算は次の形です。
子を測定する maxHeight = H - round(s)
子の配置に渡す y = round(s / 2)
s は0以下のpx値です。例えば −120 pxなら、測定上限は元より120 px大きくなり、配置に渡す移動量は約 −60 pxです。ただし、これは計算の説明です。最終的な画面座標には、親の制約、シートの上余白、測定結果の補正なども作用します。
/ 2 は入力と配置の移動量に差を付ける係数です。こうした移動比率はパララックス表現の材料になりますが、係数を付けるだけで背景と前景の関係がすべて決まるわけではありません。参照画面では、背景、シートの高さ、角丸の変化も組み合わせています。
SwiftUIの .offset やComposeの graphicsLayer は、通常、後続要素へ伝える占有サイズを変えずに描画位置を変えます。ヘッダーが見えなくなっても、本文の前に余白が残ることがあります。後続要素を詰めたい場合は、frame(height:) や測定・レイアウトの設計が必要です。さらに、占有サイズを変えることと、スクロールの移動量を消費することも別の処理です。
4.2 pxとdpを混ぜない
NestedScrollConnection の移動量や LayoutCoordinates.size.height はpxです。Dp.value はdpの数値部分なので、pxからそのまま引くことはできません。
val density = LocalDensity.current
val topInsetPx = with(density) {
innerPadding.calculateTopPadding().toPx()
}
// measuredHeightPx と同じ領域の余白を除く場合だけ差し引く。
val usableHeightPx = (measuredHeightPx - topInsetPx).coerceAtLeast(0f)
参照時点のコードには、測定したpx値から calculateTopPadding().value を引く式があります。再利用する際は、そのまま写さず、単位をそろえます。同時に、測定済みの領域にその余白が含まれているかも確認してください。すでに親のpaddingで除かれた余白を、もう一度引くと誤差が生じます。
画面回転やフォントサイズ変更で測定値が変わる場合は、可動範囲を更新し、現在オフセットを新しい範囲へ再度収めます。独自 layout では、minHeight <= maxHeight、非負の制約、親に報告するサイズ、はみ出しを許す範囲とクリップの有無も確認します。
5. SwiftUI:位置の観測と固定ヘッダーで近い表現を作る
5.1 再現するのは、まず見た目と操作上の結果
SwiftUI側の参照サンプルは、一つの縦 ScrollView を基本にしています。背景画像を ZStack に置き、スクロール内容の先頭に180 ptのスペーサーを置くことで、初期状態では背景が見えます。その下に、タブを Section ヘッダーとして持つ LazyVStack を配置します。[2]
この構成で使うAPIは、それぞれ役割が異なります。
API
役割
GeometryReader
マーカーの位置を読み取る
名前付き coordinateSpace
位置の基準を固定する
PreferenceKey
子で得た位置を親へ伝える
LazyVStack(pinnedViews: [.sectionHeaders])
セクションヘッダーを上端へ固定する
shouldCollapse
タブを展開/縮小のどちらで表示するか決める
スクロール位置を読むことは、移動量を消費することではありません。 PreferenceKey の更新で見た目を変えても、Androidの onPreScroll のように「子が使う前に親が20 pxを取り分ける」処理にはなりません。まず見た目と操作の目標を決め、その範囲で必要な構成を選びます。
5.2 先頭のマーカーを、固定した座標空間で読む
private struct TopMarkerKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue() // この例では発行元は一つ
}
}
ScrollView(.vertical) {
VStack(spacing: 0) {
GeometryReader { proxy in
Color.clear.preference(
key: TopMarkerKey.self,
value: proxy.frame(in: .named("headerViewport")).minY
)
}
.frame(height: 0)
Color.clear.frame(height: 180)
// この下にタブ付きの LazyVStack を配置する
}
}
.coordinateSpace(name: "headerViewport")
座標空間はスクロール内容と一緒に移動しない、ビューポート側へ設定します。マーカーは、縮小で高さが変わるタブよりも前に置きます。これにより、タブの高さを変更したこと自体がマーカーの基準位置を直接変える構成を避けられます。
開始位置を y0、マーカーの位置を minY とすると、上へ進んだ距離は d = y0 - minY です。図と説明用コードでは、上部バーをビューポートの外へ置き、上端の追加余白を設けないため、開始位置を0として d = max(0, -minY) を使います。
実際の画面にナビゲーションバー、safe area、content insetを加える場合は、開始位置の基準を確かめます。途中の位置を復元した直後の値を無条件に y0 とすると、その位置が新しい「スクロール距離0」になってしまいます。復元時も同じ基準を使えるように設計してください。
PreferenceKey の集約方法も発行元に合わせます。この例はマーカー一つなので value = nextValue() で十分です。複数のセクション位置を読む場合は、セクションIDをキーとする辞書などで集約します。
5.3 タブを固定する処理と、縮小する処理を分ける
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
// 選択されたタブの本文
} header: {
// shouldCollapse に応じて高さを変えるタブ
}
}
pinnedViews は、対象のセクションヘッダーをスクロール表示領域の上端へ固定するレイアウト機能です。固定される要素は Section の header に置きます。通常の VStack 内の任意のビューへ同じ効果が付くわけではありません。[5]
ヘッダー背景は不透明にして、本文が背後を通るときに文字が重ならないようにします。常設の上部バーがある場合は、バーの下から始まるスクロール表示領域を作るか、safe areaに合わせた配置を選びます。固定タブがバーの背後へ入り込まないことを確認します。
5.4 閾値方式には、切り替えの境界がある
参照SwiftUIコードでは、マーカーのオフセットが −192 pt未満になると shouldCollapse = true になります。タブ全体は82→53 pt、選択背景は56→32 ptへ変わります。これはAndroidの0〜1の進捗と異なり、位置から二つの状態を選び、その状態変更をアニメーションする方式です。[2]
境界付近で状態が繰り返し変わる場合は、展開と縮小で閾値を分けるヒステリシスを使えます。
let distance = max(0, -minY) // この構成では y0 = 0
if shouldCollapse {
if distance < 176 { shouldCollapse = false }
} else if distance > 192 {
shouldCollapse = true
}
176〜192 ptの間では、それまでの状態を保ちます。192 ptは参照サンプルの値、176 ptは説明用に追加した復帰側の値です。これらは共通の推奨値ではありません。スペーサーの高さ、タブが固定される位置、切り替えたいタイミングに合わせて決めます。
マーカーを安定させても、タブ縮小によって内容全体の高さが変わると、短い一覧や最下部ではスクロール位置の補正が起きる場合があります。ヒステリシスだけですべての揺れが消えるとは限りません。必要に応じて、一定の占有高さを保って内部だけ縮める設計や、最低限の内容高さも検討します。
説明用の画面全体は ObservedHeaderExample.swift に収録しています。縦ScrollViewを一つ使う構成で、移動量の横取り、複数ScrollView間の速度配分は実装していません。
5.5 iOS 18以降では、専用のスクロール観測APIも使える
iOS 18以降を対象にできる場合は、onScrollGeometryChange でスクロール情報を観測できます。変換クロージャで必要な値だけを取り出せるため、GeometryReaderとPreferenceKeyによる観測を置き換える選択肢になります。[6]
// iOS 18+。PreferenceKey版の観測と入れ替えて使う。
.onScrollGeometryChange(for: CGFloat.self) { geometry in
max(0, geometry.contentOffset.y + geometry.contentInsets.top)
} action: { _, distance in
// この distance から折りたたみ状態を更新する
}
この式は、上部insetを考慮して上端からの距離を求める例です。スクロール内容のマーカー座標を読む方式とは基準が異なるため、同じ閾値を使う前に座標と余白の対応を確認します。また、このAPIも観測であり、Androidの親子間の消費プロトコルを追加するものではありません。
6. 選択背景の横移動は、タブ領域の実測幅で計算する
SwiftUI参照実装の選択背景は、Capsule をボタンの背後へ置き、選択されたインデックスに合わせて横へ移動させています。画面幅を固定値として使わず、GeometryReader で実際のタブ領域を測ります。[2]
幅を W、左右余白を L・R、タブ間隔を G、タブ数を n とすると、等幅タブ一つ分の幅 w と、インデックス i の開始位置は次のようになります。
w = (W - L - R - G(n - 1)) / n
x(i) = L + i(w + G)
例えば W = 390 pt、左右16 pt、間隔8 pt、タブ数3なら、タブ幅は114 ptです。開始位置は16・138・260 ptになり、最後のタブの右端は374 ptで、右余白16 ptが残ります。
参照コードでは、左右paddingを含む領域へ GeometryReader を配置し、その幅から余白を差し引いています。測定するビューと計算で扱う領域をそろえることが重要です。すでに余白を除いた幅からさらに16 ptずつ引くと、選択背景がずれます。
この等幅の式は、各タブに同じ幅を配る前提です。ラベルの長さに合わせた可変幅タブでは、個別のフレームを測る方法や matchedGeometryEffect などを検討します。また、右から左へ読むレイアウトでは、開始位置の向きを含めて対応させます。
7. タブ変更時のスクロール方針を明示する
タブの選択IDと、本文のスクロール状態は別のデータです。Android側の参照コードでは、選択日を更新して表示データを切り替えていますが、rememberLazyListState() を同じ構成位置で使うだけでは、選択日が変わるたびに状態が初期化されるとは限りません。[1]
方針
本文の扱い
共通ヘッダーの扱い
本文だけ先頭へ戻す
選択変更時に先頭へ移動
現在のシート/タブ状態を保つ
タブごとの位置を復元
安定したタブIDに対応する状態を使う
共通の状態を保つ
画面全体を初期状態へ戻す
先頭へ移動
シート/タブのオフセットも戻す
Composeで本文だけをリセットする基本形は、選択変更をキーにして処理します。
val listState = rememberLazyListState()
LaunchedEffect(selectedDay) {
listState.scrollToItem(0)
}
これは対応する本文がすでに構成されている場合の例です。非同期で別日のデータを読み込む場合は、選択IDに一致するデータが表示されたタイミングで実行します。タブごとの状態を持つなら、key(selectedDay) で区切るだけで復元まで済むとは考えず、IDとスクロール状態の対応を保持します。
プログラムによる移動が、常にユーザーのドラッグと同じNested Scrollの経路を通るとは仮定しないでください。本文を先頭へ動かしただけで、外側シートや内側タブの状態も必ず初期化される、という設計にはしません。画面全体を戻す場合は、それぞれの状態を明示的に更新します。
SwiftUIで ScrollViewReader と scrollTo を使う場合も、IDの安定性、固定タブが占有する高さ、選択後のデータが存在するタイミングを確認します。「本文の先頭」と「固定タブの背後」を同じ場所として扱わず、必要な余白と移動先を定義します。
収録した二つの説明用コードは、タブ変更時に共通の本文位置を維持する簡単な方針です。実際のプロダクトでは、上の表から要件に合う方針を選んで追加します。
8. ドラッグの見た目以外に確認すること
フリング、停止位置、横スクロール
AndroidのNested Scrollには、移動量だけでなく onPreFling / onPostFling で速度を扱う段階もあります。移動量の単位はpx、速度はpx/sです。ヘッダーを停止時に展開/縮小へ寄せるなら、速度の消費、スナップの開始条件、キャンセル時の状態を別に設計します。[4]
収録コードはドラッグの移動量配分を説明するものです。フリングが入ったときに各段階へ届く移動量は利用するスクロール要素の動作にも関係します。独自の速度配分や終端スナップの完成例としては扱わないでください。
標準的な上部バーの折りたたみで足りる場合は、Material 3の TopAppBarDefaults.exitUntilCollapsedScrollBehavior() なども候補です。同じ scrollBehavior をバーと nestedScroll に接続します。独自の日付タブの縮小やシートの拡大が必要かを確認してから、カスタム処理の範囲を決めます。[7]
横スクロールのタブやページャーも組み合わせる場合は、縦方向の親が横方向の移動量まで消費しないようにします。必要な場合にだけ NestedScrollDispatcher を追加し、同じ移動を重複して通知しない構成にします。基本の LazyColumn にはNested Scrollの処理が組み込まれています。[4]
状態更新とアクセシビリティ
スクロールの各更新で、画面全体が読み取る状態を無条件に書き換えると、不要な更新が増えます。SwiftUIの閾値方式では、境界を越えたときだけ shouldCollapse を変更します。連続進捗では、更新する値と、その値を読む描画・レイアウトの範囲を小さくします。
タブは選択状態をアクセシビリティへ伝え、文字サイズを大きくしたときも操作できる高さを確保します。固定の53 ptや82 ptは参照画面の値であり、すべての文字サイズに対応する保証ではありません。SwiftUIのReduce Motion設定などに合わせ、横移動や高さ変更の時間アニメーションを省略する選択肢も用意します。
動作確認の観点
操作・条件
確認すること
ゆっくり上へドラッグ
シート→タブ→本文の順に進み、境界で引っ掛からないか
本文の途中から下へドラッグ
本文が先に戻り、先頭で親へ移動量が渡るか
強くフリング/操作途中でタップ
状態が範囲を外れず、古いアニメーションが残らないか
閾値付近の往復、最下部、短い一覧
縮小と展開を繰り返さず、内容高さの変化で位置が跳ねないか
タブの連続変更、非同期データ更新
選択したIDと本文・復元位置が一致するか
回転、分割表示、密度・文字サイズ変更
測定範囲とタブ背景を再計算し、ラベルを読めるか
位置復元、safe area変更、RTL表示
基準座標・余白・背景の方向が正しいか
おわりに
AndroidのNested Scrollは、親と子が移動量を分け合う仕組みです。DroidKaigiの参照実装では、外側でシートを動かし、内側でタブを縮め、残りを本文へ渡す構成になっています。
SwiftUIでは、一つの縦ScrollView、位置の観測、固定セクションヘッダーを組み合わせることで、近い表示結果を作れます。移動量の配分と、位置に応じた見た目の変更は役割が異なるため、まず操作の目標を決めてから実装を選ぶことが重要です。
シートの位置、タブの高さ、選択背景、本文のスクロール位置を独立した状態として扱うと、アニメーションやタブ変更時の方針も説明しやすくなります。こうした整理が、両プラットフォームで統一感のある操作を作るための土台になります。
参考資料
DroidKaigi conference-app-2023:PR #979 / 説明の基準としたコミット
fumiyasac / LikeCoodinatorLayoutExample:ContentView、ContentHeaderView、ScrollViewWithVerticalOffset
DroidKaigi 2023への貢献に関する発表資料
Android Developers:Nested scrolling modifiers
Apple:LazyVStack initializer / pinnedViews
Apple:onScrollGeometryChange(for:of:action:)
Android Developers:App bars
※コード例は仕組みの説明を目的としています。
ノートにまとめたMEMO
📝 Page1
📝 Page2
📝 Page3
📝 Page4
Mentions
NestedScrollを利用した表現とSwiftUIでの再現方法のまとめ ! ヘッダーやタブのアニメーションなど、複雑なUI演出をAndroidのNestedScrollで実装する方法を整理し、iOS(SwiftUI)での再現手法をまとめた記事です。 背景画像の上に一覧を重ね、スクロールするとシートが上へ広がる。さらにスクロールすると日付タブが小さくなり、その後は本文だけが流れていく。このような画面では、スクロールできる要素を並べるだけでなく、どの要素が、どの順番で移動量を使うかを設計する必要があります。 AndroidのJetpack Composeでは、Nested Scrollの仕組みを使って親と子に移動量を配分できます。一方、SwiftUIでは、スクロール位置の観測と固定ヘッダーのレイアウトを組み合わせることで、見た目が近い画面を構成できます。 本記事では、DroidKaigi 2023のタイムテーブル画面と、SwiftUIの再現サンプルをもとに、シート・タブ・本文の関係を整理します。参照コードの構成を説明したうえで、仕組みを理解するためのコード例と、設計時に確認したい点を示します。 Androidの説明はPR #979の最終パッチに対応するコミットを基準にしています。SwiftUIの数値は LikeCoodinatorLayoutExample の参照実装を基準にしています。説明用コードは、それぞれの考え方を抜き出した別のサンプルです。[1][2] 1. 最初に分けたい、3種類の状態 「ヘッダーとタブがスクロールに連動する」という説明には、異なる処理が含まれています。 状態 変化する対象 変化のきっかけ シートの展開状態 背景ヘッダーを覆う範囲、シートの位置・サイズ 縦方向の移動量 タブの折りたたみ状態 タブ行の高さ、日付などの補助情報 移動量、またはスクロール位置 タブの選択状態 表示する日付のデータ、選択背景の位置 タブのタップ 参照Android実装の外側では、シートが上へ移動して背景ヘッダーを覆います。内側では、日付タブの高さや表示内容が変わります。シートの展開と、タブの折りたたみは別の状態です。選択中タブの背景が横へ動く処理も、縦スクロールによる縮小とは分けて考えます。 また、ここで扱うのは同じ画面内のスクロールと表示変化です。タブ変更で本文データが切り替わることと、スクロール位置から選択タブを自動判定することも異なります。参照実装の中心は、シートとタブのサイズ・位置の制御です。 2. Android:外側のシートと内側のタブに接続を置く 2.1 参照実装の階層 タイムテーブル画面は、おおむね次の責務に分かれています。[1] 要素 主な責務 TimetableScreen / Scaffold 上部バー、背景ヘッダー、シートを配置。外側の nestedScroll を接続 TimetableScreenScrollState シートの移動範囲と sheetScrollOffset を管理 TimetableSheet 日付タブと選択日の本文を配置。内側の nestedScroll を接続 TimetableSheetContentScrollState 本文との間で移動量を配分し、タブの状態を更新 TimetableTabState タブのオフセットと tabCollapseProgress を管理 TimetableList など 本文をスクロール表示 外側と内側の接続を分けることで、本文のスクロールに先立ってシートを動かし、その後タブを縮める構成を表現できます。 Modifier.nestedScroll(connection) は、親子のスクロール処理に参加するための接続です。これを Box や Column に付けるだけで、その要素が指のドラッグでスクロールするようになるわけではありません。LazyColumn などのスクロール可能な子が発生させるイベントを、親の接続で受け取ります。[4] 2.2 onPreScroll と onPostScroll の役割 Nested Scrollでは、子が使う前の移動量を親が受け取る「pre」と、子が使い終わった残りを親が受け取る「post」があります。複数の親がある場合、preは外側から内側へ、postは内側から外側へ処理が戻ります。 参照実装の方針は、次のように整理できます。 入力の方向 処理する段階 方針 上方向:available.y < 0 onPreScroll 可動範囲が残る親が先に消費し、残りを子へ渡す 下方向:available.y > 0 onPostScroll 本文が先に戻り、本文で使い切れなかった分で親を元に戻す したがって、下へドラッグしただけで、常にヘッダーがすぐ展開するわけではありません。本文が途中にある間は、本文側が移動量を消費します。先頭付近で本文が使い切れなくなったときに、内側のタブ、外側のシートへ余りが届きます。 図の上方向の例では、入力 −90 pxを、外側 −40 px、内側 −20 px、本文 −30 pxへ配分しています。下方向の例では、本文が +10 pxを使い、内側が +20 px、外側が +40 pxを使った後も +10 pxが残ります。これは順序を説明する仮の数値であり、実際の消費量は各要素の現在位置と可動範囲で変わります。 onPostScroll の consumed は先行する処理が消費した量、available はその段階で利用できる残りです。親がさらに消費するときは available を使います。consumed + available をもう一度親へ適用すると、すでに使った移動量まで重ねて処理してしまいます。[4] 2.3 戻すのは、実際に状態が変わった量 親の可動範囲には上限があります。例えば、あと10 pxしか動けない親に −30 pxが届いた場合、親の戻り値は −10 pxです。残り −20 pxを子へ渡します。 private fun consume(deltaY: Float): Float { val before = offsetPx offsetPx = (before + deltaY).coerceIn(-rangePx, 0f) return offsetPx - before } val connection = object : NestedScrollConnection { override fun onPreScroll( available: Offset, source: NestedScrollSource, ): Offset = if (available.y < 0f) { Offset(0f, consume(available.y)) } else Offset.Zero override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset = if (available.y > 0f) { Offset(0f, consume(available.y)) } else Offset.Zero } この consume を、外側と内側にそれぞれ独立した状態として持たせます。縦方向の処理なので、戻り値の x は0です。available 全体をそのまま返すと、実際には動いていない量まで消費済みになり、本文のスクロールを止めてしまいます。 説明用の画面全体は NestedConsumptionExample.kt に収録しています。外側ヘッダーを180→84 dp、タブを84→60 dpに変える構成で、参照実装のシート描画をそのまま複製したものではありません。 3. オフセットを、連続的な折りたたみ進捗へ変換する 3.1 進捗は0〜1へ正規化する タブの可動範囲を R > 0、現在のオフセットを o ∈ [-R, 0] とすると、折りたたみ進捗は次の式で求められます。 p = clamp(-o / R, 0, 1) p = 0:展開状態 p = 1:折りたたみ完了 途中の値:指の移動に追従する中間状態 参照Android実装では、タブ行が84 dpから60 dpへ変わるため、可動範囲は24 dpです。実際の状態では、この24 dpをpxへ変換した負の下限値を保持し、オフセットを範囲内に制限しています。[1] 3.2 高さと透明度に、異なる式を使う 参照コードの変化を整理すると、次のようになります。 タブ行の最小高さ = 84 - 24p [dp] 日付の透明度 = max(1 - 2p, 0) 日付の占有高さ = 元の高さ × (1 - p) タブ行の84→60 dpは defaultMinSize で指定する最小高さです。文字サイズや子の必要高さが大きい場合は、実際の高さがこれを超えることがあります。 日付の文字は p = 0.5 で透明になりますが、占有する高さはまだ半分残っています。先に文字を消し、残りのスクロールで余白を縮めることで、文字が押しつぶされるような見え方を避ける設計です。 これは 位置と一対一で対応する連続変化です。毎回のスクロール更新に時間ベースのアニメーションを重ねると、指の位置に対して表示が遅れる場合があります。ドラッグ中は進捗から直接計算し、タブのタップや停止後のスナップなど、目標が切り替わる操作にアニメーションを使うと整理しやすくなります。 4. シートの配置では、測定・移動・単位を分ける 4.1 layout はサイズと配置の両方に関わる 参照Androidコードは、onGloballyPositioned でヘッダーの高さを取得し、シートの可動範囲を決めています。さらに layout 内で、測定に渡す高さの上限と子の配置位置を調整します。[1] sheetScrollOffset を s、受け取った高さの上限を H とすると、コード上の計算は次の形です。 子を測定する maxHeight = H - round(s) 子の配置に渡す y = round(s / 2) s は0以下のpx値です。例えば −120 pxなら、測定上限は元より120 px大きくなり、配置に渡す移動量は約 −60 pxです。ただし、これは計算の説明です。最終的な画面座標には、親の制約、シートの上余白、測定結果の補正なども作用します。 / 2 は入力と配置の移動量に差を付ける係数です。こうした移動比率はパララックス表現の材料になりますが、係数を付けるだけで背景と前景の関係がすべて決まるわけではありません。参照画面では、背景、シートの高さ、角丸の変化も組み合わせています。 SwiftUIの .offset やComposeの graphicsLayer は、通常、後続要素へ伝える占有サイズを変えずに描画位置を変えます。ヘッダーが見えなくなっても、本文の前に余白が残ることがあります。後続要素を詰めたい場合は、frame(height:) や測定・レイアウトの設計が必要です。さらに、占有サイズを変えることと、スクロールの移動量を消費することも別の処理です。 4.2 pxとdpを混ぜない NestedScrollConnection の移動量や LayoutCoordinates.size.height はpxです。Dp.value はdpの数値部分なので、pxからそのまま引くことはできません。 val density = LocalDensity.current val topInsetPx = with(density) { innerPadding.calculateTopPadding().toPx() } // measuredHeightPx と同じ領域の余白を除く場合だけ差し引く。 val usableHeightPx = (measuredHeightPx - topInsetPx).coerceAtLeast(0f) 参照時点のコードには、測定したpx値から calculateTopPadding().value を引く式があります。再利用する際は、そのまま写さず、単位をそろえます。同時に、測定済みの領域にその余白が含まれているかも確認してください。すでに親のpaddingで除かれた余白を、もう一度引くと誤差が生じます。 画面回転やフォントサイズ変更で測定値が変わる場合は、可動範囲を更新し、現在オフセットを新しい範囲へ再度収めます。独自 layout では、minHeight <= maxHeight、非負の制約、親に報告するサイズ、はみ出しを許す範囲とクリップの有無も確認します。 5. SwiftUI:位置の観測と固定ヘッダーで近い表現を作る 5.1 再現するのは、まず見た目と操作上の結果 SwiftUI側の参照サンプルは、一つの縦 ScrollView を基本にしています。背景画像を ZStack に置き、スクロール内容の先頭に180 ptのスペーサーを置くことで、初期状態では背景が見えます。その下に、タブを Section ヘッダーとして持つ LazyVStack を配置します。[2] この構成で使うAPIは、それぞれ役割が異なります。 API 役割 GeometryReader マーカーの位置を読み取る 名前付き coordinateSpace 位置の基準を固定する PreferenceKey 子で得た位置を親へ伝える LazyVStack(pinnedViews: [.sectionHeaders]) セクションヘッダーを上端へ固定する shouldCollapse タブを展開/縮小のどちらで表示するか決める スクロール位置を読むことは、移動量を消費することではありません。 PreferenceKey の更新で見た目を変えても、Androidの onPreScroll のように「子が使う前に親が20 pxを取り分ける」処理にはなりません。まず見た目と操作の目標を決め、その範囲で必要な構成を選びます。 5.2 先頭のマーカーを、固定した座標空間で読む private struct TopMarkerKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() // この例では発行元は一つ } } ScrollView(.vertical) { VStack(spacing: 0) { GeometryReader { proxy in Color.clear.preference( key: TopMarkerKey.self, value: proxy.frame(in: .named("headerViewport")).minY ) } .frame(height: 0) Color.clear.frame(height: 180) // この下にタブ付きの LazyVStack を配置する } } .coordinateSpace(name: "headerViewport") 座標空間はスクロール内容と一緒に移動しない、ビューポート側へ設定します。マーカーは、縮小で高さが変わるタブよりも前に置きます。これにより、タブの高さを変更したこと自体がマーカーの基準位置を直接変える構成を避けられます。 開始位置を y0、マーカーの位置を minY とすると、上へ進んだ距離は d = y0 - minY です。図と説明用コードでは、上部バーをビューポートの外へ置き、上端の追加余白を設けないため、開始位置を0として d = max(0, -minY) を使います。 実際の画面にナビゲーションバー、safe area、content insetを加える場合は、開始位置の基準を確かめます。途中の位置を復元した直後の値を無条件に y0 とすると、その位置が新しい「スクロール距離0」になってしまいます。復元時も同じ基準を使えるように設計してください。 PreferenceKey の集約方法も発行元に合わせます。この例はマーカー一つなので value = nextValue() で十分です。複数のセクション位置を読む場合は、セクションIDをキーとする辞書などで集約します。 5.3 タブを固定する処理と、縮小する処理を分ける LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { Section { // 選択されたタブの本文 } header: { // shouldCollapse に応じて高さを変えるタブ } } pinnedViews は、対象のセクションヘッダーをスクロール表示領域の上端へ固定するレイアウト機能です。固定される要素は Section の header に置きます。通常の VStack 内の任意のビューへ同じ効果が付くわけではありません。[5] ヘッダー背景は不透明にして、本文が背後を通るときに文字が重ならないようにします。常設の上部バーがある場合は、バーの下から始まるスクロール表示領域を作るか、safe areaに合わせた配置を選びます。固定タブがバーの背後へ入り込まないことを確認します。 5.4 閾値方式には、切り替えの境界がある 参照SwiftUIコードでは、マーカーのオフセットが −192 pt未満になると shouldCollapse = true になります。タブ全体は82→53 pt、選択背景は56→32 ptへ変わります。これはAndroidの0〜1の進捗と異なり、位置から二つの状態を選び、その状態変更をアニメーションする方式です。[2] 境界付近で状態が繰り返し変わる場合は、展開と縮小で閾値を分けるヒステリシスを使えます。 let distance = max(0, -minY) // この構成では y0 = 0 if shouldCollapse { if distance < 176 { shouldCollapse = false } } else if distance > 192 { shouldCollapse = true } 176〜192 ptの間では、それまでの状態を保ちます。192 ptは参照サンプルの値、176 ptは説明用に追加した復帰側の値です。これらは共通の推奨値ではありません。スペーサーの高さ、タブが固定される位置、切り替えたいタイミングに合わせて決めます。 マーカーを安定させても、タブ縮小によって内容全体の高さが変わると、短い一覧や最下部ではスクロール位置の補正が起きる場合があります。ヒステリシスだけですべての揺れが消えるとは限りません。必要に応じて、一定の占有高さを保って内部だけ縮める設計や、最低限の内容高さも検討します。 説明用の画面全体は ObservedHeaderExample.swift に収録しています。縦ScrollViewを一つ使う構成で、移動量の横取り、複数ScrollView間の速度配分は実装していません。 5.5 iOS 18以降では、専用のスクロール観測APIも使える iOS 18以降を対象にできる場合は、onScrollGeometryChange でスクロール情報を観測できます。変換クロージャで必要な値だけを取り出せるため、GeometryReaderとPreferenceKeyによる観測を置き換える選択肢になります。[6] // iOS 18+。PreferenceKey版の観測と入れ替えて使う。 .onScrollGeometryChange(for: CGFloat.self) { geometry in max(0, geometry.contentOffset.y + geometry.contentInsets.top) } action: { _, distance in // この distance から折りたたみ状態を更新する } この式は、上部insetを考慮して上端からの距離を求める例です。スクロール内容のマーカー座標を読む方式とは基準が異なるため、同じ閾値を使う前に座標と余白の対応を確認します。また、このAPIも観測であり、Androidの親子間の消費プロトコルを追加するものではありません。 6. 選択背景の横移動は、タブ領域の実測幅で計算する SwiftUI参照実装の選択背景は、Capsule をボタンの背後へ置き、選択されたインデックスに合わせて横へ移動させています。画面幅を固定値として使わず、GeometryReader で実際のタブ領域を測ります。[2] 幅を W、左右余白を L・R、タブ間隔を G、タブ数を n とすると、等幅タブ一つ分の幅 w と、インデックス i の開始位置は次のようになります。 w = (W - L - R - G(n - 1)) / n x(i) = L + i(w + G) 例えば W = 390 pt、左右16 pt、間隔8 pt、タブ数3なら、タブ幅は114 ptです。開始位置は16・138・260 ptになり、最後のタブの右端は374 ptで、右余白16 ptが残ります。 参照コードでは、左右paddingを含む領域へ GeometryReader を配置し、その幅から余白を差し引いています。測定するビューと計算で扱う領域をそろえることが重要です。すでに余白を除いた幅からさらに16 ptずつ引くと、選択背景がずれます。 この等幅の式は、各タブに同じ幅を配る前提です。ラベルの長さに合わせた可変幅タブでは、個別のフレームを測る方法や matchedGeometryEffect などを検討します。また、右から左へ読むレイアウトでは、開始位置の向きを含めて対応させます。 7. タブ変更時のスクロール方針を明示する タブの選択IDと、本文のスクロール状態は別のデータです。Android側の参照コードでは、選択日を更新して表示データを切り替えていますが、rememberLazyListState() を同じ構成位置で使うだけでは、選択日が変わるたびに状態が初期化されるとは限りません。[1] 方針 本文の扱い 共通ヘッダーの扱い 本文だけ先頭へ戻す 選択変更時に先頭へ移動 現在のシート/タブ状態を保つ タブごとの位置を復元 安定したタブIDに対応する状態を使う 共通の状態を保つ 画面全体を初期状態へ戻す 先頭へ移動 シート/タブのオフセットも戻す Composeで本文だけをリセットする基本形は、選択変更をキーにして処理します。 val listState = rememberLazyListState() LaunchedEffect(selectedDay) { listState.scrollToItem(0) } これは対応する本文がすでに構成されている場合の例です。非同期で別日のデータを読み込む場合は、選択IDに一致するデータが表示されたタイミングで実行します。タブごとの状態を持つなら、key(selectedDay) で区切るだけで復元まで済むとは考えず、IDとスクロール状態の対応を保持します。 プログラムによる移動が、常にユーザーのドラッグと同じNested Scrollの経路を通るとは仮定しないでください。本文を先頭へ動かしただけで、外側シートや内側タブの状態も必ず初期化される、という設計にはしません。画面全体を戻す場合は、それぞれの状態を明示的に更新します。 SwiftUIで ScrollViewReader と scrollTo を使う場合も、IDの安定性、固定タブが占有する高さ、選択後のデータが存在するタイミングを確認します。「本文の先頭」と「固定タブの背後」を同じ場所として扱わず、必要な余白と移動先を定義します。 収録した二つの説明用コードは、タブ変更時に共通の本文位置を維持する簡単な方針です。実際のプロダクトでは、上の表から要件に合う方針を選んで追加します。 8. ドラッグの見た目以外に確認すること フリング、停止位置、横スクロール AndroidのNested Scrollには、移動量だけでなく onPreFling / onPostFling で速度を扱う段階もあります。移動量の単位はpx、速度はpx/sです。ヘッダーを停止時に展開/縮小へ寄せるなら、速度の消費、スナップの開始条件、キャンセル時の状態を別に設計します。[4] 収録コードはドラッグの移動量配分を説明するものです。フリングが入ったときに各段階へ届く移動量は利用するスクロール要素の動作にも関係します。独自の速度配分や終端スナップの完成例としては扱わないでください。 標準的な上部バーの折りたたみで足りる場合は、Material 3の TopAppBarDefaults.exitUntilCollapsedScrollBehavior() なども候補です。同じ scrollBehavior をバーと nestedScroll に接続します。独自の日付タブの縮小やシートの拡大が必要かを確認してから、カスタム処理の範囲を決めます。[7] 横スクロールのタブやページャーも組み合わせる場合は、縦方向の親が横方向の移動量まで消費しないようにします。必要な場合にだけ NestedScrollDispatcher を追加し、同じ移動を重複して通知しない構成にします。基本の LazyColumn にはNested Scrollの処理が組み込まれています。[4] 状態更新とアクセシビリティ スクロールの各更新で、画面全体が読み取る状態を無条件に書き換えると、不要な更新が増えます。SwiftUIの閾値方式では、境界を越えたときだけ shouldCollapse を変更します。連続進捗では、更新する値と、その値を読む描画・レイアウトの範囲を小さくします。 タブは選択状態をアクセシビリティへ伝え、文字サイズを大きくしたときも操作できる高さを確保します。固定の53 ptや82 ptは参照画面の値であり、すべての文字サイズに対応する保証ではありません。SwiftUIのReduce Motion設定などに合わせ、横移動や高さ変更の時間アニメーションを省略する選択肢も用意します。 動作確認の観点 操作・条件 確認すること ゆっくり上へドラッグ シート→タブ→本文の順に進み、境界で引っ掛からないか 本文の途中から下へドラッグ 本文が先に戻り、先頭で親へ移動量が渡るか 強くフリング/操作途中でタップ 状態が範囲を外れず、古いアニメーションが残らないか 閾値付近の往復、最下部、短い一覧 縮小と展開を繰り返さず、内容高さの変化で位置が跳ねないか タブの連続変更、非同期データ更新 選択したIDと本文・復元位置が一致するか 回転、分割表示、密度・文字サイズ変更 測定範囲とタブ背景を再計算し、ラベルを読めるか 位置復元、safe area変更、RTL表示 基準座標・余白・背景の方向が正しいか おわりに AndroidのNested Scrollは、親と子が移動量を分け合う仕組みです。DroidKaigiの参照実装では、外側でシートを動かし、内側でタブを縮め、残りを本文へ渡す構成になっています。 SwiftUIでは、一つの縦ScrollView、位置の観測、固定セクションヘッダーを組み合わせることで、近い表示結果を作れます。移動量の配分と、位置に応じた見た目の変更は役割が異なるため、まず操作の目標を決めてから実装を選ぶことが重要です。 シートの位置、タブの高さ、選択背景、本文のスクロール位置を独立した状態として扱うと、アニメーションやタブ変更時の方針も説明しやすくなります。こうした整理が、両プラットフォームで統一感のある操作を作るための土台になります。 参考資料 DroidKaigi conference-app-2023:PR #979 / 説明の基準としたコミット fumiyasac / LikeCoodinatorLayoutExample:ContentView、ContentHeaderView、ScrollViewWithVerticalOffset DroidKaigi 2023への貢献に関する発表資料 Android Developers:Nested scrolling modifiers Apple:LazyVStack initializer / pinnedViews Apple:onScrollGeometryChange(for:of:action:) Android Developers:App bars ※コード例は仕組みの説明を目的としています。 ノートにまとめたMEMO 📝 Page1 📝 Page2 📝 Page3 📝 Page4