😶‍🌫️

[SwiftUI]Listのタップ範囲が狭い?余白もタップ可能にする方法

に公開1

はじめに

添付のgif画像のようにListのテキストやアイコンしかタップできない場合、Userはタップ範囲が狭く、操作感を悪く感じます。
タップ範囲が狭い状態

そのため、今回はListのタップ範囲に空白箇所も含めてタップできるように設定していきます。

改善のポイント

SwiftUIでは、HStackSpacerなどの空白部分は見た目はあってもタップ判定がないことがあります。

そのため、次のような工夫を加える必要があります:

  • HStackSpacer()でレイアウトを拡張
  • .contentShape(Rectangle())でタップ範囲を明示

改善前のコード

まず、Listの文字やアイコンのみしかタップできない状態のコードは下記のとおりです。
この状態だと、テキストやアイコン以外の余白はタップできません。

改善前のコード全体
import SwiftUI

struct ListView: View {
    @State private var isAccountInfoActive = false

    var body: some View {
        NavigationView {
            ZStack {
                Color(UIColor.systemGroupedBackground)
                    .ignoresSafeArea()

                VStack(spacing: 20) {
                    List {
                        Section {
                            // 遷移先リンク(非表示)+ onTapGesture
                            ZStack {
                                SettingsRow(icon: "person.circle.fill", iconColor: .blue, text: "アカウント情報")
                                    .onTapGesture {
                                        isAccountInfoActive = true
                                    }

                                NavigationLink(destination: AccountInfoView(), isActive: $isAccountInfoActive) {
                                    EmptyView()
                                }
                                .hidden()
                            }

                            SettingsRow(icon: "envelope.circle.fill", iconColor: .pink, text: "お問い合わせ")
                            SettingsRow(icon: "doc.text.fill", iconColor: .orange, text: "利用規約")
                            SettingsRow(icon: "doc.plaintext.fill", iconColor: .gray, text: "ライセンス情報")
                        }
                    }
                    .listStyle(InsetGroupedListStyle())
                    .frame(height: 300)

                    // ログアウトボタン
                    Button(action: {
                        print("ログアウト tapped")
                    }) {
                        Text("ログアウト")
                            .foregroundColor(.blue)
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color(UIColor.secondarySystemGroupedBackground))
                            .cornerRadius(10)
                            .shadow(radius: 1)
                    }
                    .padding(.horizontal)

                    Spacer()
                }
                .navigationTitle("設定")
            }
        }
    }
}

struct SettingsRow: View {
    let icon: String
    let iconColor: Color
    let text: String
    var onTextTap: (() -> Void)? = nil

    var body: some View {
        HStack {
            Image(systemName: icon)
                .foregroundColor(.white)
                .frame(width: 30, height: 30)
                .background(iconColor)
                .cornerRadius(5)

            if let onTextTap = onTextTap {
                Text(text)
                    .onTapGesture {
                        onTextTap()
                    }
            } else {
                Text(text)
            }

            Spacer()
        }
        .padding(.vertical, 5)
    }
}

struct AccountInfoView: View {
    var body: some View {
        Text("アカウント情報画面")
            .font(.title)
            .navigationTitle("アカウント情報")
            .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    ListView()
}

改善後のコード

以下のように修正を加えることで、空白部分も含めてタップ可能なList行になります。

修正後のコード全体
import SwiftUI

struct ListView: View {
    @State private var isAccountInfoActive = false

    var body: some View {
        NavigationView {
            ZStack {
                Color(UIColor.systemGroupedBackground)
                    .ignoresSafeArea()

                VStack(spacing: 20) {
                    List {
                        Section {
                            // 遷移先リンク(非表示)+ onTapGesture
                            ZStack {
                                // SettingsRowの部分をHStackでラップして、Spacerで幅を広げる
                                HStack {
                                    SettingsRow(icon: "person.circle.fill", iconColor: .blue, text: "アカウント情報")
                                    Spacer() // これで右端までスペースを確保
                                }
                                .contentShape(Rectangle()) // タップ判定を行全体に拡げる
                                .onTapGesture {
                                    isAccountInfoActive = true
                                }

                                NavigationLink(destination: AccountInfoView(), isActive: $isAccountInfoActive) {
                                    EmptyView()
                                }
                                .hidden()
                            }

                            SettingsRow(icon: "envelope.circle.fill", iconColor: .pink, text: "お問い合わせ")
                            SettingsRow(icon: "doc.text.fill", iconColor: .orange, text: "利用規約")
                            SettingsRow(icon: "doc.plaintext.fill", iconColor: .gray, text: "ライセンス情報")
                        }
                    }
                    .listStyle(InsetGroupedListStyle())
                    .frame(height: 300)

                    // ログアウトボタン
                    Button(action: {
                        print("ログアウト tapped")
                    }) {
                        Text("ログアウト")
                            .foregroundColor(.blue)
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color(UIColor.secondarySystemGroupedBackground))
                            .cornerRadius(10)
                            .shadow(radius: 1)
                    }
                    .padding(.horizontal)

                    Spacer()
                }
                .navigationTitle("設定")
            }
        }
    }
}

struct SettingsRow: View {
    let icon: String
    let iconColor: Color
    let text: String
    var onTextTap: (() -> Void)? = nil

    var body: some View {
        HStack {
            Image(systemName: icon)
                .foregroundColor(.white)
                .frame(width: 30, height: 30)
                .background(iconColor)
                .cornerRadius(5)

            if let onTextTap = onTextTap {
                Text(text)
                    .onTapGesture {
                        onTextTap()
                    }
            } else {
                Text(text)
            }

            Spacer()
        }
        .padding(.vertical, 5)
    }
}

struct AccountInfoView: View {
    var body: some View {
        Text("アカウント情報画面")
            .font(.title)
            .navigationTitle("アカウント情報")
            .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    ListView()
}

差分で見てみる

どこが変更されたのか細かく見ていきます。
↓before

List {
    Section {
        ZStack {
            SettingsRow(icon: "person.circle.fill", iconColor: .blue, text: "アカウント情報")
                .onTapGesture {
                    isAccountInfoActive = true
                }
            
            NavigationLink(destination: AccountInfoView(), isActive: $isAccountInfoActive) {
                EmptyView()
            }
            .hidden()
        }
        
        SettingsRow(icon: "envelope.circle.fill", iconColor: .pink, text: "お問い合わせ")
        SettingsRow(icon: "doc.text.fill", iconColor: .orange, text: "利用規約")
        SettingsRow(icon: "doc.plaintext.fill", iconColor: .gray, text: "ライセンス情報")
    }
}

↓after

List {
    Section {
        ZStack {
            // SettingsRowの部分をHStackでラップして、Spacerで幅を広げる
+           HStack {
                SettingsRow(icon: "person.circle.fill", iconColor: .blue, text: "アカウント情報")
+               Spacer() // これで右端までスペースを確保
+           }
+           .contentShape(Rectangle()) // タップ判定を行全体に拡げる
            .onTapGesture {
                isAccountInfoActive = true
            }
            
            NavigationLink(destination: AccountInfoView(), isActive: $isAccountInfoActive) {
                EmptyView()
            }
            .hidden()
        }
        
        SettingsRow(icon: "envelope.circle.fill", iconColor: .pink, text: "お問い合わせ")
        SettingsRow(icon: "doc.text.fill", iconColor: .orange, text: "利用規約")
        SettingsRow(icon: "doc.plaintext.fill", iconColor: .gray, text: "ライセンス情報")
    }

下記のコード箇所が追加されていることが確認できました。

List {
    HStack {
        Spacer()
    }
    .contentShape(Rectangle())
}

解説:なぜこれでタップ範囲が広がるのか?

HStack + Spacer() の意味

HStackの解説については、FuyanTechさんの記事が分かりやすいです。
https://zenn.dev/fuya777/books/67a40f438c9c56/viewer/9508ed

画面に複数の要素を並べるときに「スタック」という仕組みを使います。
「スタック」がなければ、要素を1つずつバラバラに配置しないといけなくなり、非常に大変になります。

HStackは、要素を横に並べる配列方法になります。

イメージとしては以下の通りです。

HStack {}の中に、icontextspace()の三種類を入れ、ひとまとめにしていることが確認できます。

HStack {
    SettingsRow(icon: "person.circle.fill", iconColor: .blue, text: "アカウント情報")
    Spacer()
}

今回、space()も入れることにより、余白も含めて横幅が形成されたことが分かります。

HStack導入前 HStack導入後

.contentShape(Rectangle())の役割

.contentShape()は、「タップできる当たり判定エリア」を指定するための修飾子です。

SwiftUIのHStackSpacerは、見た目上のサイズはあるけど、タップ判定がないことが多いです。

この.contentShape(Rectangle())を加えることで、全体を1つの大きなタップ領域として認識させることができます。

タップ範囲が広がった結果、以下のように余白部分でもスムーズに反応するUIが実現できました。

まとめ

  • HStack + Spacer + .contentShape(Rectangle())の組み合わせで、行全体をタップ可能にできる
  • 小さな改善でもUXが大きく向上する

Discussion

ビスタービスター

NavigationView(すでに非推奨です)ではなくNavigationStackを利用し、NavigationLinkのみを書くようにすればこの不具合は既に解消されています!

1