Upgrade to Pro — share decks privately, control downloads, hide ads and more …

StickyHeaderとScroll追従の構造を紐解く

Avatar for Fumiya Sakai Fumiya Sakai
July 21, 2025
90

 StickyHeaderとScroll追従の構造を紐解く

Mobile勉強会 #21 ウォンテッドリー × チームラボ × Sansanでの登壇資料になります。

プレゼンテーションでは、まずSticky Headerの基本概念として、固定表示(Sticky)、伸縮効果(Stretchy)、視差効果(Parallax)という3つの主要なパターンを定義し、X(旧Twitter)やInstagramのプロフィール画面といった身近な例を用いて説明しています。

技術的な内容では、iOS開発における実装方法の大きな変遷について詳しく解説されています。iOS17以前では、GeometryReaderとPreferenceKeyを組み合わせた複雑な実装が必要で、状態伝搬の煩雑さやViewの責務過多といった課題がありました。しかし、iOS18で導入されたonScrollGeometryChangeモディファイアにより、実装が劇的に簡素化され、より直感的で保守性の高いコードが書けるようになったことが強調されています。

さらに、Android開発におけるJetpack Composeでの実装方法も紹介し、LazyColumnのstickyHeaderスロットを用いた宣言的な実装について説明しています。プラットフォーム間の比較を通じて、それぞれの思想やアプローチの違いを明確にし、開発者がより深い理解を得られるよう構成されています。

最後に、プラットフォームを問わず適用できるベストプラクティスとして、状態管理の分離、再描画の最適化、専用APIの適切な活用、そしてプラットフォーム間でのユーザーエクスペリエンスの一貫性維持について実践的なアドバイスを提供しています。

Avatar for Fumiya Sakai

Fumiya Sakai

July 21, 2025
Tweet

More Decks by Fumiya Sakai

Transcript

  1. 自己紹介 ・Fumiya Sakai ・Mobile Application Engineer アカウント: ・Twitter: https://twitter.com/fumiyasac ・Facebook:

    https://www.facebook.com/fumiya.sakai.37 ・Github: https://github.com/fumiyasac ・Qiita: https://qiita.com/fumiyasac@github 発表者: ・Born on September 21, 1984 これまでの歩み: Web Designer 2008 ~ 2010 Web Engineer 2012 ~ 2016 App Engineer 2017 ~ Now iOS / Android / sometimes Flutter
  2. アジェンダ 参考: UIKit時代での実装例 // UIScrollViewDelegateによる基本的なScroll追従実装 func scrollViewDidScroll(_ scrollView: UIScrollView) {

    // ScrollViewのContentOffsetを取得して状態更新 let scrollOffset = scrollView.contentOffset.y // スクロール量に応じた処理 if scrollOffset > 0 { // 上方向スクロール時の処理 updateHeaderHeight(offset: scrollOffset) } else { // 下方向スクロール時の処理(引っ張り効果) updateStretchyEffect(offset: scrollOffset) } } 本LTではモダンなUIで頻繁に利用されるStickyHeaderとスクロール追従の実装について、その構造を深く掘り下げます。 定義と身近なUIでの実例を解説 1. StickyHeaderの基本概念 iOS17以前とiOS18の比較 2. iOSでの実装方法の変遷 Jetpack Composeを用いた実装方法 3. Androidとの比較 パフォーマンス最適化と状態管理の戦略 4. 実装のコツとベストプラクティス
  3. Sticky Headerとは? Sticky Headerは、スクロールしても画面上部に留まるヘッダーのことを指します。 これによって、ユーザーは常に重要 な情報を参照できます。 (固定表示) Sticky (伸縮) Stretchy

    (視差) Parallax 最も基本的な形式で、特定のビュー(ヘッ ダー、タブなど)がスクロールしても画面 上部に留まります。 スクロール位置(特にオーバースクロール 時)に応じてヘッダーが伸び縮みする効果 です。 背景と前景のスクロール速度に差をつけるこ とで、奥行きを表現する効果です。 固定ヘッダー 伸縮ヘッダー 視差ヘッダー コンテンツ 🎨 X(旧Twitter)のタブバーが典型的な例 🎨 X(旧Twitter)のプロフィール画像が代表的な例 🎨 ゲームや写真アプリでよく使われる効果
  4. iOS17以前の実装方法 課題と複雑さ 主な実装手法 😫 状態伝搬の複雑さ スクロール位置(オフセット)を親ビューから子ビューへ、 あるいはビュー間で伝搬させる仕組みが煩雑でした。 😫 Viewの責務過多 GeometryReaderを用いて座標を計算し、PreferenceKeyでそ

    の値を親ビューに通知するなど、Viewが持つべきでないロ ジックを抱えがちでした。 😫 実装の脆弱性 スクロール中に何度も再計算が必要で、パフォーマンスに影 響を及ぼすことがありました。 ˡ هࣄαϯϓϧλΠτϧ struct PointPreferenceKey: PreferenceKey { static var defaultValue: CGPoint = .zero static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { // reduceは通常使われない } } PreferenceKeyの役割 4DSPMM7JFX 4USFUDIZ)FBEFS $POUFOUT
  5. iOS17以前の実装方法 fileprivate struct ScrollOffsetTracker: ViewModifier { var completion: (CGPoint) ->

    Void var coordinateSpace: String func body(content: Content) -> some View { content .background( GeometryReader { geometry in Color.clear .preference( key: PointPreferenceKey.self, value: CGPoint( x: geometry.frame(in: .named(coordinateSpace)).minX, y: geometry.frame(in: .named(coordinateSpace)).minY ) ) } ) .onPreferenceChange(PointPreferenceKey.self) { newValue in DispatchQueue.main.async { completion(newValue) } } } } スクロール位置を追跡するために、状態伝搬が必要 🤖 実装の複雑さ GeometryReaderとPreferenceKeyの組み合わせが複雑 座標系の基準を正しく設定する必要性 ビュー間の状態伝搬が理解しづらい 🤖 読解の難しさ 非直感的なコード構造に陥りがち Viewの責務が増加し、単一責任の原則が崩れがち
  6. iOS18での進化 iOS 18で導入されたonScrollGeometryChangeモディファイアは、 スクロール連動UIの実装を大幅に簡素化し、より直感的 な開発を可能にしました。 スクロール位置やビューのサイズを、数行のコードで直接取得可能に 😄 簡潔な位置取得 各ビューが必要な情報を自分で取得し、自律的に振る舞えるように 😄

    Viewの自律性 PreferenceKeyやGeometryReaderの複雑な組み合わせが不要に 😄 実装のシンプル化 課題と複雑さ iOS17 vs iOS18の比較 ⚠ iOS17以前の複雑さ 状態伝搬の複雑さ / Viewの責務過多 ✅ iOS18の簡潔さ GeometryReader + PreferenceKeyの定型コード 直接的なスクロール位置の取得 / Viewの状態管理の最適化 コードが大幅にシンプルかつ宣言的 🧑💻 .onScrollGeometryChange を使って、スクロール中のビューのジオメトリの変化を直接ハンドリング
  7. iOS18のコード例 struct ContentView: View { @State private var distance: CGFloat

    = 0 @State private var isImageHeadline: CGFloat = 500 var body: some View { ZStack(alignment: .top) { ScrollView { VStack { Rectangle() .foregroundStyle(.clear) .frame(height: isImageHeadline) } } .onScrollGeometryChange(for: CGFloat.self) { geometry in scrollGeometry = geometry.contentOffset.y } action: { _ in distance = scrollOffset } Image("cover") .resizable() .scaledToFill() .frame(height: imageHeight + distance) } } } iOS 18では、onScrollGeometryChangeモディファイアが導入され、スクロール 位置の取得が大幅に簡素化されました。 👌 コードの進化 これにより、以前に必要な複雑なGeometryReaderとPreferenceKeyの組み合わ せが不要になります。 コードが大幅にシンプルになります 👌 実装の利点 スクロール位置への直接のアクセスが可能 Viewは自律的にスクロール位置を処理 状態伝搬の複雑さが解消
  8. Android (Jetpack Compose) での実装 stickyHeaderブロック内にコンポーザブルを配置するだけで簡単に固定 ヘッダーを実現できます。 簡単な固定ヘッダー: 特徴 StretchyやParallaxなどの複雑な挙動を実装するには、LazyListState でスクロールオフセットを監視し、Modifier.offsetやgraphicsLayerを

    組み合わせます。 カスタム挙動: SwiftUIのiOS 18以降のアプローチに比べると、ボイラープレートは少 ないものの、状態管理やModifierの組み合わせに関する知識が求めら れ、やや抽象化が強いです。 抽象化の度合い: AndroidのモダンUIツールキットであるJetpack Composeでも、Sticky Headerは宣言的に実装できます。 stickyHeader {…} -B[Z$PMVNO TUBUFMJTU4UBUF \^ Item #1 Item #2 Item #3 💡 実装ポイント LazyColumnのstickyHeaderスロットに配置するコンポーザブルは、 自動的にスクロールしても画面上部に固定されます。複雑な挙動を 実現するには、LazyListStateを用いてスクロールオフセットを監視 する必要があります。
  9. Androidのコード例 @Composable fun StickyHeaderList() { val listState = rememberLazyListState() LazyColumn(state

    = listState) { // ... 他のコンテンツ // 固定したいヘッダー stickyHeader { Surface( color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth() ) { Text( text = "Sticky Header", modifier = Modifier.padding(16.dp) ) } } // スクロールするアイテム items(50) { index -> Text(text = "Item #$index", modifier = Modifier.padding(16.dp)) } } } SwiftUIのpinnedViewsとComposeのstickyHeaderは、それぞれのプラットフォーム における最適化されたAPIと言える 🏃 実装ポイント Jetpack ComposeのLazyColumnにstickyHeaderスロットが用意されている 固定ヘッダーはstickyHeader { ... }ブロック内に配置するだけで実現 スクロールオフセットの監視にはLazyListStateを利用する 🏃 SwiftUIとの比較 SwiftUIのiOS 18以降のアプローチに比べると、ボイ ラープレートは少ない 抽象化の度合い: Composeでは状態管理やModifierの組み合わせに関する知識 が求められる 状態管理: 複雑な挙動を実装するには、OffsetやgraphicsLayerを 組み合わせて使用する カスタム挙動:
  10. 実装のコツとベストプラクティス プラットフォームを問わず、高品質なSticky Headerを実装するための共通のヒントです。 状態管理の分離 スクロール位置やヘッダーの表示状態などのUI状態は、可能であればViewModelに 分離します。これにより、Viewの責務が明確になり、テスト容易性も向上しま す。 ✅ UI状態をViewModelに移動 ✅

    Viewのテストを容易にする 再描画の最適化 スクロールは秒間60回以上発生する可能性があり、そのたびにView全体を再描画 するとパフォーマンスが低下します。状態の変更が本当に必要なビューのみを更 新するよう設計しましょう。 ✅ SwiftUIでは@Stateの適用範囲を最小限に ✅ ComposeではderivedStateOfを活用 専用APIの活用 SwiftUIのLazyVStack(pinnedViews:)やComposeのLazyColumn(stickyHeader:)は、 パフォーマンスが最適化された強力なAPIです。これらの仕組みを正しく理解し、 適切に活用することが重要です。 ✅ SwiftUI: pinnedViewsの理解 ✅ Viewのテストを容易にする プラットフォーム間の一貫性 iOSとAndroidの実装パターンを比較しながら、共通のデザイン言語を確立しま す。プラットフォームの特性を理解しながら、ユーザーエクスペリエンスの一貫 性を維持します。 ✅ 各プラットフォームの最善ൌ践を融合 ✅ ユーザー体験の連続性を保ちつつ最適化
  11. まとめ 1. Sticky Headerは表現の幅が広い: 単純な固定表示から、StretchyやParallaxといった豊かな表現まで、UIに大きな価値をもたらします。 onScrollGeometryChangeの登場により、SwiftUIでのスクロール連動UIの実装は、かつてないほどシンプルかつ直感的になりまし た。 Android (Jetpack Compose)

    との比較を通じて、それぞれの思想やアプローチの違いが明確になり、UI実装への理解が深まりま す。 2. iOS18での実装は劇的に簡略化 3. プラットフォーム比較で理解が深まる UI開発におけるSticky Headerの実装は、これからもさらに最適化され、プラットフォーム間の差異が縮小していくでしょう。し かし、それぞれのプラットフォームの特性を理解しておくことは、最適なUIを実現するための鍵となります。 4. 今後の展望