SwiftUIのList行にButtonが1つあると行全体がタップ領域になる — .buttonStyle(.borderless)でインラインに戻す

AI主体で執筆

設定画面のGoogleアカウント行で、名前とメールアドレスの横に「サインアウト」ボタンを置いていたら、名前の部分をタップしただけでサインアウトしてしまう事故が起きました。見た目は普通の情報表示行で、右端に小さなリンクがあるだけなのに、左の名前領域までボタンの一部として扱われている状態です。原因はSwiftUIが「Listの行にButtonが1つだけあると、行全体をそのボタンのヒット領域に拡張する」こと。.buttonStyle(.borderless)を付けるだけで、ラベル部分だけがタップ対象に戻ります。

事故のコード

問題になったのは、こんな形の設定行でした。

Section("Google アカウント") {
    HStack {
        VStack(alignment: .leading) {
            Text(account.displayName)
                .font(.body)
            Text(account.email)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        Spacer()
        Button("サインアウト") {
            showSignOutConfirm = true
        }
        .foregroundStyle(.red)
    }
}

見た目は普通の情報行で、右端に「サインアウト」のテキストリンクがあります。左にはアカウントの表示名とメール。SwiftUIに任せたデフォルトのレンダリングで、左半分は情報を見せるだけ、右のリンクだけが操作の対象、というつもりで書きました。

動かしてみると、名前の部分をタップしても、メールの部分をタップしても、サインアウトの確認ダイアログが出ます。左半分がハイライトされるわけでもないので、触った側は何が起きたか一瞬分からないのがやっかいでした。

原因:Listが行のButtonを「行アクション」として扱う

SwiftUIのListは、行の中にButtonが1つだけ含まれるとき、その行全体をそのボタンのヒット領域にする挙動を持っています。「行をタップして1つの操作を起こす」というList本来のメタファに寄せるための調整で、NavigationLinkが行全体をタップ可能にする挙動と似ています。

Buttonが2つ以上あると、SwiftUIは「どちらのButtonに行全体を割り当てればよいか分からない」状態になるので、各ラベル部分だけがタップ対象に戻ります。今回のように1つだけ置いたときに限って、左半分まで巻き込まれる、ということでした。

メモ:VoiceOverで画面をなぞると確認が早くて、行全体が1つのButton要素として読み上げられます。タップ領域を視覚で判別できないときでも、フォーカスリングの大きさで実際の当たり判定が見えます。

対処:.buttonStyle(.borderless)でインラインに戻す

対処は1行で、ButtonまたはHStackに.buttonStyle(.borderless)を付けます。これで「このボタンは行アクションではなく、行の中のインライン要素だ」という宣言になります。

Button("サインアウト") {
    showSignOutConfirm = true
}
.foregroundStyle(.red)
.buttonStyle(.borderless)

これでサインアウトのラベル部分だけがタップ対象になり、名前やメール部分をタップしても何も起きません。設定.appのメールアカウント行や、通知設定の「Clear」リンクなど、iOS純正のインラインアクションと同じパターンです。

.borderless以外に.plainも使えますが、.plainは押下時のハイライト色までシステムに任せない形なので、赤や青で色付きのボタンには.borderlessのほうが見た目が揃います。

破壊的な操作は確認アラートとセットにする

サインアウトやアカウント削除のような操作では、タップ領域を直しただけで終わらせず、確認アラートを挟むのが安全です。行全体が反応しなくなっても、ラベル部分を誤ってタップする可能性は残ります。

Button("サインアウト") {
    showSignOutConfirm = true
}
.foregroundStyle(.red)
.buttonStyle(.borderless)
.confirmationDialog(
    "サインアウトしますか?",
    isPresented: $showSignOutConfirm,
    titleVisibility: .visible
) {
    Button("サインアウト", role: .destructive) { signOut() }
    Button("キャンセル", role: .cancel) {}
}

confirmationDialogの中のButtonにはrole: .destructiveを付けてよくて、こちらはタップ直後にデータが動くので赤色と予期がそろいます。姉妹記事の swipeActionsでrole: .destructiveと確認アラートを組み合わせると行がちらつく と同じ判断の軸で、「タップからデータ変更までに別のUIが挟まらない位置のボタン」にはroleを付けてよい、という切り分けになります。

気付くための習慣

この事故がやっかいなのは、見た目のデフォルトでは検出できないところです。行の左半分がハイライトされるわけでもないし、コンパイルエラーも警告も出ません。普段使うのはVoiceOverですが、手触りだけで見つけたい場合は次の2つを試すと確度が上がりました。

どちらも機械的に拾える習慣です。「見た目が変わらない挙動の違い」は、人間の注意力だけでは抜けるので、VoiceOverか自動テストに任せたほうが安全でした。

関連記事