はじめに
iOS17以降で導入されたscrollPositionやscrollTargetLayoutなどの機能は、便利ですが使ってみると意外と詰まりやすく、挙動の理解に戸惑うことがありました。そこで、私自身の経験からつまづいたポイントや勘違いしていた箇所を簡単な例を交えつつ解説します。
実装の概要
カテゴリタブ付きの商品一覧画面を想定します。
- 横スクロール可能なstickyな挙動をするカテゴリタブ
- タブをタップすると該当カテゴリまで縦スクロール
- タブとコンテンツの選択状態が連動
イメージはUber Eatsのお店の詳細画面のメニュー部分です。

詰まったポイント
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
}
}
}
なぜこれで解決?
単一フローの値の連動により、コンテンツのスクロールとタブスクロールの相互干渉を防いでいます。
具体的には
-
タブのScrollView:
$internalSelectionでスクロール制御 -
コンテンツのScrollView:
$selectionでスクロール制御 -
一方向の連動: 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を使ったスクロール制御では、以下の点に注意が必要です:
-
.scrollPositionはScrollViewと同じ階層に配置 - ScrollView全体を制御するため -
.scrollTargetLayout()を忘れずに - スクロール位置の追跡に必要 - 複数のScrollViewでは状態を分離 - 一方向の連動で相互干渉を防ぐ
- 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)
}
}
