SwiftUIのswipeActionsでrole: .destructiveと確認アラートを組み合わせると行がちらつく
AI主体で執筆メモアプリのタグ一覧で「タグを削除」のスワイプアクションを作ったときに、行が一瞬だけ消えかけて戻るちらつきが出ました。コードは.swipeActions(edge: .trailing, allowsFullSwipe: true)の中にButton(role: .destructive)を置いて、中で確認アラートを出すだけの素直な書き方です。原因はフルスワイプ時にSwiftUIが行の削除アニメーションを先行して走らせるところにあって、確認アラートを挟む構造だとデータソースが即時変化しないのでちらつきになります。直し方はroleを外して.tint(.red)だけを付けるパターンに落ち着きました。
症状:行が消えかけて戻る
最初のコードはこうでした。Listの各行に「削除」のスワイプアクションを置いて、タップ時に確認アラートを出す構造です。
List(tags) { tag in
TagRow(tag: tag)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
tagToDelete = tag
showDeleteAlert = true
} label: {
Label("削除", systemImage: "trash")
}
}
}
.alert("このタグを削除しますか?", isPresented: $showDeleteAlert) {
Button("削除", role: .destructive) {
deleteTag(tagToDelete)
}
Button("キャンセル", role: .cancel) {}
}
フルスワイプをすると、行が左に流れて消えるアニメーションが始まります。そのあとに確認アラートが出て、「キャンセル」を押すと行が戻る、という動きになります。見た目としては行が消えかけてから戻るので、触った側は「削除できたのか・キャンセルされたのか」が一瞬分かりません。
原因:roleが行の消滅アニメーションを予期する
role: .destructiveは「赤くする」以外に、Listが行を消すアニメーションを予期して走らせる役割があります。フルスワイプのジェスチャーが完了した時点でListは「この行はもうすぐデータソースから消える」という前提でアニメーションを始めます。ただし実際にはアラートを挟んでtags配列が変わらないので、アニメーションが途中で止まって行が戻ります。
つまりrole: .destructiveは、押した瞬間にデータソースが変わる前提の装飾です。アラートやダイアログを挟む破壊的操作とは、前提が合っていません。
メモ:allowsFullSwipe: falseにすれば、フルスワイプではなく「赤いボタンを一度タップしてから確認アラート」の導線になるので、ちらつきは消えます。ただ、タグや項目の削除はフルスワイプで一発操作のほうが自然なアプリもあるので、先にroleを外す方を試してから判断するほうがよさそうです。
対処:roleを外して.tintで赤くする
確認アラートを挟むなら、roleは付けずに.tint(.red)で色だけ付けます。ボタンの役割は「ただの赤いアクション」であって「行を消すアクション」ではない、という表現です。
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
tagToDelete = tag
showDeleteAlert = true
} label: {
Label("削除", systemImage: "trash")
}
.tint(.red)
}
この形にすると、フルスワイプが終わった直後に行はそのまま戻り、確認アラートが出てから削除の動作が走ります。削除が実行されたときにtagsが変わるので、Listが自前の更新アニメーションで行を消します。ちらつきは出ません。
VoiceOverで「破壊的操作」として読み上げたい場合は.accessibilityLabelと.accessibilityHintで補います。roleが担っていたアクセシビリティ情報はここで別途付ける、という役割分担です。
roleを残してよい条件
アラートを挟まず、タップした瞬間にデータソースが変わる破壊的操作なら、role: .destructiveを残すほうがむしろ自然です。単発のメモをアーカイブする、通知を1件だけ削除する、というような、巻き込みの小さい一撃必殺系の操作が該当します。
- アーカイブ・削除で即座に
@Modelを触る場合 →role: .destructiveを残す(SwiftUIの行消しアニメと実データの変化が同じタイミングで揃う) - 確認アラート・ダイアログを挟む場合 →
roleを外して.tint(.red)にする - ダイアログの中の「削除」ボタンには
role: .destructiveを付けてよい(こちらはタップ直後にデータが動くので予期と実態が一致する)
判断の軸は「タップからデータ変更までの間に別のUIが挟まるか」の一点で、挟むならroleを外す、挟まないならroleで行の消しアニメを任せる、と覚えておけば使い分けが揺れなくなります。