0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIの .scrollPositionによるタブバーとコンテンツのスクロール連動実装で詰まったポイント

Posted at

はじめに

iOS17以降で導入されたscrollPositionやscrollTargetLayoutなどの機能は、便利ですが使ってみると意外と詰まりやすく、挙動の理解に戸惑うことがありました。そこで、私自身の経験からつまづいたポイントや勘違いしていた箇所を簡単な例を交えつつ解説します。

実装の概要

カテゴリタブ付きの商品一覧画面を想定します。

  • 横スクロール可能なstickyな挙動をするカテゴリタブ
  • タブをタップすると該当カテゴリまで縦スクロール
  • タブとコンテンツの選択状態が連動

イメージはUber Eatsのお店の詳細画面のメニュー部分です。
名称未設定.gif

完成イメージ。
画面収録 2025-11-27 午後1.25.50.gif

詰まったポイント

1. scrollPositionはScrollViewと同じ階層に置く

❌ 間違い
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemView(item)
                .id(item)
        }
    }
    .scrollTargetLayout()
    .scrollPosition(id: $selection) // ここではダメ!
}
✅ 正解
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemView(item)
                .id(item)
        }
    }
    .scrollTargetLayout()
}
.scrollPosition(id: $selection, anchor: .top) // ScrollViewと同じ階層

.scrollPositionはScrollView内のコンテンツのidをもとにScrollView全体のスクロール位置を制御するモディファイアで、ScrollView直下に配置しないと正しく動作しない。

2. scrollTargetLayoutを忘れずに

❌ 間違い

ScrollView {
    ForEach(items) { item in
        ItemView(item)
            .id(item)
    }
}
.scrollPosition(id: $selection)

✅ 正解

ScrollView {
    ForEach(items) { item in
        ItemView(item)
            .id(item)
    }
    .scrollTargetLayout() // これが必要
}
.scrollPosition(id: $selection)

scrollTargetLayoutが付与されたviewの範囲でスクロール位置の追跡が行われるため、付与を忘れるとスクロール位置の制御が上手く動作しません。

3. スクロール制御を分離して、選択状態のみ連動させる

タブ(横スクロール)とコンテンツ(縦スクロール)は別々のScrollViewなので、スクロール位置の管理は独立させる必要があります。ただし、選択状態は連動させるようにします。

❌ 間違い

選択状態を両方のScrollViewで共有

struct ProductListView: View {
    @State private var selectedCategory: Category?

    var body: some View {
        ScrollView {
            VStack {
                LazyVStack(pinnedViews: .sectionHeaders) {
                    Section {
                        // コンテンツ
                        ForEach(categories) { category in
                            CategorySection(category: category)
                                .id(category)
                        }
                    } header: {
                        // カテゴリタブ
                        CategoryTabBar(
                            selection: $selectedCategory,
                            categories: categories
                        )
                    }
                }
                .scrollTargetLayout()
            }
        }
        .scrollPosition(id: $selectedCategory, anchor: .top)
        .onAppear {
            // 初期選択
            selectedCategory = categories.first
        }
    }
}

struct CategoryTabBar: View {
    @Binding var selection: Category?
    @Namespace private var underline
    let categories: [Category]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(categories) { category in
                    CategoryTab(
                        category: category,
                        isSelected: category == selection, underline: underline
                    )
                    .onTapGesture {
                        withAnimation { selection = category }
                    }
                    .animation(.default, value: selection)
                    .id(category)
                }
            }
            .padding(.horizontal, 16)
            .scrollTargetLayout()
        }
        .scrollPosition(id: $selection, anchor: .center) // selectedCategoryをタブとコンテンツの双方で共有している
        .scrollIndicators(.hidden)
        .frame(height: 60)
        .background(Color.white)
    }
}
何が問題?

タブ(横)とコンテンツ(縦)の2つのScrollViewが$selectedCategoryを双方向で奪い合ってしまう。
コンテンツをスクロールするとselectedCategoryが変わり、それがタブの.scrollPositionにも即座に反映されてタブが激しく揺れる。
逆に、タブを手動でスクロールするとコンテンツが意図せず動いてしまう。

✅ 正解

タブのスクロール制御を内部状態(internalSelection)で独立管理

struct ProductListView: View {
    @State private var selectedCategory: Category?

    var body: some View {
        ScrollView {
            VStack {
                LazyVStack(pinnedViews: .sectionHeaders) {
                    Section {
                        // コンテンツ
                        ForEach(categories) { category in
                            CategorySection(category: category)
                                .id(category)
                        }
                    } header: {
                        // カテゴリタブ
                        CategoryTabBar(
                            selection: $selectedCategory,
                            categories: categories
                        )
                    }
                }
                .scrollTargetLayout()
            }
        }
        .scrollPosition(id: $selectedCategory, anchor: .top)
        .onAppear {
            // 初期選択
            selectedCategory = categories.first
        }
    }
}

// カテゴリタブバー: 内部状態でスクロール制御
struct CategoryTabBar: View {
    @Binding var selection: Category? // 外部と連動する選択状態
    @State private var internalSelection: Category? // タブスクロール用の内部状態
    @Namespace private var underline
    let categories: [Category]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(categories) { category in
                    CategoryTab(
                        category: category,
                        isSelected: category == selection, underline: underline
                    )
                    .onTapGesture {
                        withAnimation { selection = category }
                    }
                    .animation(.default, value: selection)
                    .id(category)
                }
            }
            .padding(.horizontal, 16)
            .scrollTargetLayout()
        }
        .scrollPosition(id: $internalSelection, anchor: .center)
        .scrollIndicators(.hidden)
        .frame(height: 60)
        .background(Color.white)
        .onChange(of: selection) { _, newValue in
            // 外部の選択変更をタブのスクロールに反映
            internalSelection = newValue
        }
        .task {
            // 初期値を設定
            internalSelection = selection
        }
    }
}
なぜこれで解決?

単一フローの値の連動により、コンテンツのスクロールとタブスクロールの相互干渉を防いでいます。

具体的には

  1. タブのScrollView: $internalSelectionでスクロール制御
  2. コンテンツのScrollView: $selectionでスクロール制御
  3. 一方向の連動: onChange(of: selection)によりselectionの変更はinternalSelectionに反映されるが、逆は起きない

これにより:
✅ タブタップ時: 両方が適切にスクロール
✅ コンテンツスクロール時: タブが追従
✅ タブの手動スクロール時: コンテンツに影響しない

4. ScrollViewのネストは避ける(コンポーネント化の落とし穴)

コンポーネントを再利用可能にするために独立したViewとして切り出すのは一般的な設計手法ですが、ScrollViewを含むコンポーネントを作成すると、意図せずScrollViewのネストが発生しやすく、それに伴って意図しない挙動が発生しハマってしまうことが多いので注意が必要です。

❌ 間違い
// 親View
struct ContentView: View {
    @State private var selectedCategory: Category?
    let categories: [Category] = [/* ... */]
    
    var body: some View {
        NavigationStack {
            ScrollView { // 外側の縦スクロール
                VStack {
                    CategoryTabBar(
                        selection: $selectedCategory,
                        categories: categories
                    )
                    
                    ProductListView(categories: categories) // コンポーネント化
                }
            }
        }
    }
}

// 再利用可能なコンポーネントとして切り出し
struct ProductListView: View {
    let categories: [Category]
    
    var body: some View {
        ScrollView { // 内側の縦スクロール - ネスト発生!
            LazyVStack {
                ForEach(categories) { category in
                    CategorySection(category: category)
                }
            }
        }
    }
}
何が問題?

親がすでにScrollViewを持っているのに、コンポーネント側でも独自のScrollViewを持っています。
2つの縦スクロールが競合し、スクロールイベントがどちらに送られるか不安定になり、結果、.scrollPositionが期待通りに動きません。

✅ 正解
// 親View
struct ContentView: View {
    @State private var selectedCategory: Category?
    let categories: [Category] = [/* ... */]
    
    var body: some View {
        NavigationStack {
            ScrollView { // 1つの縦スクロールのみ
                ProductListView(
                    categories: categories,
                    selectedCategory: $selectedCategory
                )
            }
            .scrollPosition(id: $selectedCategory, anchor: .top)
            .onAppear {
                selectedCategory = categories.first
            }
            .navigationTitle("タイトル")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

// 再利用可能なコンポーネント(ScrollViewなし)
struct ProductListView: View {
    let categories: [Category]
    @Binding var selectedCategory: Category?
    
    var body: some View {
        VStack {
            ForEach(0..<5) { id in
                SomeHeaderContent(id: id)
            }
            
            LazyVStack(pinnedViews: .sectionHeaders) { // ScrollViewではなくLazyVStack
                Section {
                    ForEach(categories) { category in
                        CategorySection(category: category)
                            .id(category)
                    }
                } header: {
                    CategoryTabBar(
                        selection: $selectedCategory,
                        categories: categories
                    )
                }
            }
            .scrollTargetLayout()
        }
    }
}
なぜこれで解決?

ScrollViewは親で1つだけ持たせ、またコンポーネントはコンテンツのみとし、ScrollViewを含めないようにしています。
これによりコンポーネントの再利用性を保ちつつ、スクロールコンテキストを統一させスクロールの競合を回避できます。

まとめ

.scrollPositionを使ったスクロール制御では、以下の点に注意が必要です:

  1. .scrollPositionはScrollViewと同じ階層に配置 - ScrollView全体を制御するため
  2. .scrollTargetLayout()を忘れずに - スクロール位置の追跡に必要
  3. 複数のScrollViewでは状態を分離 - 一方向の連動で相互干渉を防ぐ
  4. ScrollViewのネストは避ける - コンポーネント化時は特に注意

これらを意識することで、タブとコンテンツが自然に連動するスクロール体験を実装できます。

最終的なコード

// 商品カテゴリ
struct Category: Identifiable, Hashable {
    let id: String
    let name: String
    let iconName: String
}

struct ContentView: View {
    @State private var selectedCategory: Category?
    let categories: [Category] = [
        Category(id: "electronics", name: "家電", iconName: "tv"),
        Category(id: "fashion", name: "ファッション", iconName: "tshirt"),
        Category(id: "food", name: "食品", iconName: "cart"),
        Category(id: "books", name: "本", iconName: "book"),
        Category(id: "game", name: "ゲーム", iconName: "gamecontroller"),
        Category(id: "home", name: "インテリア", iconName: "house"),
        Category(id: "pet", name: "ペット", iconName: "dog")
    ]

    var body: some View {
        NavigationStack {
            ZStack(alignment: .top) {
                ScrollView {
                    ProductListView(categories: categories, selectedCategory: $selectedCategory)
                }
                .scrollPosition(id: $selectedCategory, anchor: .top)
                .onAppear {
                    // 初期選択
                    selectedCategory = categories.first
                }
                .navigationTitle("タイトル")
                .navigationBarTitleDisplayMode(.inline)
                .toolbarBackground(.visible, for: .navigationBar)
            }
        }
    }
}

// メイン画面
struct ProductListView: View {
    let categories: [Category]
    @Binding var selectedCategory: Category?

    var body: some View {
        VStack {
            ForEach(0..<5) {
                someContent(id: $0)
            }

            LazyVStack(pinnedViews: .sectionHeaders) {
                Section {
                    // コンテンツ
                    ForEach(categories) { category in
                        CategorySection(category: category)
                            .id(category)
                    }
                } header: {
                    // カテゴリタブ
                    CategoryTabBar(
                        selection: $selectedCategory,
                        categories: categories
                    )
                }
            }
            .scrollTargetLayout()
        }
    }

    private func someContent(id: Int) -> some View {
        ZStack {
            Color.gray.opacity(0.5).frame(height: 300)

            Text("some content \(id)")
        }
    }
}

// カテゴリタブバー: 内部状態でスクロール制御
struct CategoryTabBar: View {
    @Binding var selection: Category? // 外部と連動する選択状態
    @State private var internalSelection: Category? // タブスクロール用の内部状態
    @Namespace private var underline
    let categories: [Category]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(categories) { category in
                    CategoryTab(
                        category: category,
                        isSelected: category == selection, underline: underline
                    )
                    .onTapGesture {
                        withAnimation { selection = category }
                    }
                    .animation(.default, value: selection)
                    .id(category)
                }
            }
            .padding(.horizontal, 16)
            .scrollTargetLayout()
        }
        .scrollPosition(id: $internalSelection, anchor: .center)
        .scrollIndicators(.hidden)
        .frame(height: 60)
        .background(Color.white)
        .onChange(of: selection) { _, newValue in
            // 外部の選択変更をタブのスクロールに反映
            internalSelection = newValue
        }
        .task {
            // 初期値を設定
            internalSelection = selection
        }
    }
}

// カテゴリタブ
struct CategoryTab: View {
    let category: Category
    let isSelected: Bool

    let underline: Namespace.ID

    var body: some View {
        VStack(spacing: 4) {
            Image(systemName: category.iconName)
                .font(.system(size: 20))
                .foregroundStyle(isSelected ? Color.blue : Color.gray)

            Text(category.name)
                .font(.system(size: 14, weight: isSelected ? .bold : .regular))
                .foregroundStyle(isSelected ? Color.blue : Color.gray)

            // 下線
            if isSelected {
                Rectangle()
                    .fill(Color.blue)
                    .frame(height: 2)
                    .matchedGeometryEffect(id: "underline", in: underline)
            } else {
                Rectangle()
                    .fill(Color.clear)
                    .frame(height: 2)
            }
        }
        .frame(minWidth: 60)
        .padding(.vertical, 8)
    }
}

// カテゴリセクション
struct CategorySection: View {
    let category: Category

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(category.name)
                .font(.title2)
                .fontWeight(.bold)
                .padding(.horizontal, 16)
                .padding(.top, 16)

            // 商品リスト
            LazyVStack(spacing: 0) {
                ForEach(0..<10) { index in
                    ProductRow(name: "\(category.name)の商品\(index + 1)")
                }
            }
        }
        .padding(.bottom, 16)
    }
}

// 商品行
struct ProductRow: View {
    let name: String

    var body: some View {
        HStack {
            RoundedRectangle(cornerRadius: 8)
                .fill(Color.gray.opacity(0.2))
                .frame(width: 60, height: 60)

            VStack(alignment: .leading, spacing: 4) {
                Text(name)
                    .font(.system(size: 16, weight: .medium))

                Text("¥1,000")
                    .font(.system(size: 14))
                    .foregroundStyle(Color.gray)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 8)
    }
}
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?