MacでSwiftUIの行内の空白をクリック可能にするには、行のコンテンツを利用可能な幅いっぱいに広げ、矩形のコンテンツシェイプを適用します。行でアクションを実行する場合は、両方のモディファイアをButtonラベル内に配置します。
このチュートリアルでは、合成した図形とテキストを含む小さなmacOSアプリを作成します。図形、そのラベル、またはその横の空白をクリックすると、同じ行アクションが実行されます。行の外側の領域は、そのアクションの対象外です。
テキストには反応するのに、空白には反応しないのはなぜですか?
レイアウトとヒットテストは、それぞれ異なる問いに答えます。フレームは提案されたスペース内でビューがどのように配置されるかを決め、コンテンツシェイプはヒットテストに使う領域を定義します。Appleはこれらを、柔軟なフレームモディファイアとコンテンツシェイプモディファイアで別々に説明しています。
たとえば、コンテナがウィンドウいっぱいに広がっているため行が広く見えても、操作が短いテキストビューだけに付与されている場合があります。同様に、空のレイアウトスペースを追加しただけでは、行全体をヒットテストの対象にすることは明示されません。実用的な解決策は、まず行の意図した境界を確保し、その領域に操作用のシェイプを指定することです。
.frame(maxWidth: .infinity, alignment: .leading)を使うと、親ビューが追加の幅を提案したときにラベルを広げられます。次に、その拡張したラベルに.contentShape(.interaction, Rectangle())を適用します。Appleはinteractionコンテンツシェイプの種類を、ヒットテストとアクセシビリティのためのものとして定義しています。
これで行が無限に広がるわけではありません。フレームは引き続き、親ビューのレイアウト提案に従います。親の幅が狭ければ行も狭くなり、コンテンツシェイプで不足しているレイアウトスペースを補うことはできません。この組み合わせは、十分な幅のラベルと明示的なヒットテスト領域という2つの要件に対応します。
行ボタンの例を完成させる
XcodeでSwiftUIとSwiftを使ったmacOSアプリプロジェクトを作成します。明示的な.interactionコンテンツシェイプのオーバーロードはmacOS 12以降で利用可能とAppleが示しているため、デプロイメントターゲットをmacOS 12以降に設定します。生成されたアプリ宣言とコンテンツビューを、以下の単一ファイルのプログラムに置き換えます。@mainアプリ宣言は1つだけにしてください。利用可能なOSバージョンについては、Appleのコンテンツシェイプのオーバーロードに関するドキュメントを参照してください。
各行でアクションを実行するため、この例ではButtonを使います。Appleは、アクションを実行するインターフェース要素にはボタンを推奨し、任意のビューをボタンラベルとして使えることを明示しています。また、タップジェスチャのドキュメントでは、ジェスチャベースのコントロールと機能的に同等であれば、カスタマイズしたボタンを使うことも推奨しています。
import SwiftUI
@main
struct ClickableRowsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct SampleItem: Identifiable {
let id: Int
let title: String
let color: Color
}
struct ContentView: View {
private let items = [
SampleItem(id: 1, title: "Blue square", color: .blue),
SampleItem(id: 2, title: "Orange square", color: .orange),
SampleItem(id: 3, title: "Purple square", color: .purple)
]
@State private var lastActivated = "None"
@State private var activationCount = 0
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Choose a shape")
.font(.headline)
ScrollView {
VStack(spacing: 8) {
ForEach(items) { item in
Button {
lastActivated = item.title
activationCount += 1
} label: {
HStack(spacing: 12) {
Rectangle()
.fill(item.color)
.frame(width: 24, height: 24)
Text(item.title)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity,
alignment: .leading)
.contentShape(.interaction, Rectangle())
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.12))
}
}
}
Text("Last activated: \(lastActivated)")
Text("Activations: \(activationCount)")
}
.padding(16)
.frame(minWidth: 360, minHeight: 280)
}
}
アイテムと操作状態はすべてメモリ内に保持されます。各アイテムには固定の整数IDがあり、安定した識別性を表すSwiftのIdentifiableプロトコルに沿っています。親ビューが有効化状態を所有します。これは、必要な状態を所有するビュー内でローカルのStateを非公開にするというAppleのガイダンスに従ったものです。
アプリを実行し、どのタイトルでも右側の空白部分をクリックします。有効化回数が1回増え、ステータスにその行の名前が表示されるはずです。灰色の背景は確認時に意図した行領域を見やすくするもので、操作領域は明示的なコンテンツシェイプで定義します。
このプログラムは取得したAPIドキュメントと照合しましたが、Swiftツールチェーンが利用できなかったため、ここではコンパイルも実行もしていません。組み込む前に、対象のmacOS環境でポインタとキーボードの動作を確認してください。
フレーム、パディング、コンテンツシェイプはどこに置くべきですか?
この例では、順序に意図があります。ラベルのコンテンツを作成し、内側のパディングを加え、できあがったラベルを広げてから、その領域に操作用のシェイプを定義します。これらの操作はすべてボタンのラベルクロージャ内で行います。Appleはモディファイアの順序が重要だと説明しています。
パディングをこのシェイプ付きラベルの内側に置くと、パディング部分も意図した操作対象に含まれます。一方、ボタンの外側にパディングを置くと、その周囲にレイアウトスペースが作られます。そのスペースがボタンの操作可能なラベルに含まれるとは限りません。同様に、外側のコンテナだけを広げても、ボタン内のコンテンツを広げた場合と同じラベルの形状にはなりません。
.buttonStyle(.plain)モディファイアを使うと、見た目をシンプルに保てます。Appleによると、plainボタンスタイルは待機中に装飾を表示せず、押下、フォーカス、または有効状態では表示が変わることがあります。これはスタイルの選択であり、ラベルの幅やコンテンツシェイプを定義する代わりにはなりません。
デプロイメントターゲットがmacOS 12より前の場合は、.contentShape(.interaction, Rectangle())を.contentShape(Rectangle())に置き換えます。Appleは旧オーバーロードをヒットテスト用のシェイプを定義するものとして説明し、macOS 10.15以降で利用可能としています。この置き換えで変わるのはコンテンツシェイプAPIの要件です。アプリ内のほかのAPIについては個別に確認してください。
行にはボタン、リスト選択、ジェスチャのどれを使うべきですか?
行をクリックしたときの意味に応じて操作方法を選びます。空白に反応させる場合も、その意味と行内の各コントロールの所有関係を保ってください。
| 読者に見える動作 | 適切な開始点 | 保つべき境界 |
|---|---|---|
| 行全体で1つのアクションを実行する | ラベルを広げてシェイプを設定したButton |
行のアクションを所有する要素は1つにする |
| 行が現在の選択項目を表す | 選択バインディングを備えたList |
選択動作はリストに任せる |
| カスタムビューに独立したクリックジェスチャが必要 | onTapGestureを設定したシェイプ付きビュー |
意図した領域にジェスチャを付与する |
| 行に複数の独立したコントロールがある | 個別の兄弟コントロール | 各コントロールに固有の対象領域を持たせる |
リスト選択には、行全体のクリックハンドラーを追加する前に、コンテナの選択モデルを使います。AppleのListドキュメントでは、単一選択には任意の項目IDへのバインディングを、複数選択にはIDの集合へのバインディングを使うと説明しています。アクションを実行するボタンと、選択状態になるリスト行は、異なる操作要件です。
カスタムビューでジェスチャ認識が特に必要な場合は、ジェスチャを使うのが適切です。onTapGestureを付与する前に、同じ形状の順序、つまりパディングとフレームを設定してからコンテンツシェイプを適用します。Appleはそのcountパラメータを、必要なタップまたはクリックの回数として説明しています。コンテンツシェイプは対象領域を指定しますが、それだけではアクションを与えません。onTapGesture(count:perform:)を参照してください。
行に別のボタンやトグルも含まれる場合は、主ボタンのラベルの外に配置します。横方向のスタックに、幅を広げた主ボタンと、末尾に置く別のコントロールを含める構成が便利です。主ラベルの空白部分は主アクションに属し、末尾のコントロールは独自の領域を所有します。行全体を囲むボタンの中に操作可能なコントロールを入れないでください。
空白部分の対象範囲を修正するためだけに、ジェスチャ優先度を変更するモディファイアを追加しないでください。AppleはhighPriorityGestureを優先順位の変更として説明し、simultaneousGestureでは複数のジェスチャを同時に処理できます。これらの操作はジェスチャ間の関係を変え、子ビューの操作に影響することがあります。行に不足している形状を定義するものではありません。
行がクリックを拾わない場合の診断方法
行を最小構成にして、次の順で確認してください。サイズの問題、対象シェイプの問題、ビューの重なりによる問題を切り分けられます。
- ラベルの実際の境界を確認する。 ラベルのフレームモディファイアの直後に、一時的に
.border(Color.orange)を追加します。枠線がテキストの横で終わる場合は、親が提案する幅と固定幅の制約を確認してください。 - モディファイアの配置を確認する。 コンテンツシェイプが、クリック可能にしたいパディングも含めて、幅を広げたラベルを覆っていることを確認します。
- 操作を所有する要素を確認する。 ボタンの行ではボタンアクションを使います。原因を切り分ける間は、重複した行タップジェスチャを取り除いてください。
- 重なっている装飾を確認する。 行の上にあるオーバーレイや兄弟ビューを調べます。入力を一切受け取らない装飾には、その装飾だけに
.allowsHitTesting(false)を適用する方法を検討してください。 - ほかのコントロールを1つずつ戻す。 それぞれを戻した後に個別の領域をテストし、最後に残りの空白部分を確認します。
AppleはallowsHitTesting(_:)を、ビューがヒットテスト操作に参加するかどうかを制御するものとして説明しています。行全体に適用すると行が操作対象から外れるため、それが意図した動作である場合に限り、対象の装飾ビューに適用してください。
いくつか異なる場所をクリックして、結果を確認します。次の表は提示したプログラムで期待される動作を示しており、測定済みのテスト結果ではありません。
| クリック位置 | 期待される結果 |
|---|---|
| 色付きの四角形または行のタイトル | その行を1回有効化する |
| 灰色の行内で右側にある空白部分 | その行を1回有効化する |
| タイトルの上下にある内側のパディング | その行を1回有効化する |
| 行と行の間の隙間 | どの行も有効化しない |
| 外側のパディングまたは行の下の空白部分 | どの行も有効化しない |
ウィンドウのサイズを変更して、空白部分をもう一度確認します。また、Macのキーボードナビゲーション設定を使い、キーボードでフォーカスを移動して有効化できることも確認してください。これらの確認により、ポインタに反応するだけでなく、コンポーネントが意図したコントロールとして動作するかを見極められます。
AppKitのヒットテストが関係するのはどんなときですか?
通常のSwiftUI行では、まずSwiftUIのレイアウト、コンテンツシェイプ、コントロールAPIを使います。独自のネイティブビューや、イベントの対象を変える埋め込みビューをアプリに含める場合は、AppKitが関係します。
AppleはNSView.hitTest(_:)を、指定した点を含む最も深い子孫ビューを探すものとして説明しています。ウィンドウでは主に、マウスダウンイベントをどのビューに送るかを決めるために使われます。入力点はスーパービューの座標系で表し、Appleによれば直接呼び出す必要はほとんどありません。
独自のAppKitビューを管理している場合は、その境界でビューの境界とヒットテストのオーバーライドを調べてください。SwiftUIのコンテンツシェイプは、別のネイティブビューが持つイベントの所有権を回避する指示ではありません。クリックを受け取る責任を持つビュー階層内で修正してください。
このチュートリアルの行に使う方法は簡単です。ボタンラベルを意図した行幅まで広げ、その矩形の操作用シェイプを定義し、ほかのコントロールにはそれぞれ独自の領域を持たせます。これにより、役立つ空白部分をクリック可能にしながら、ウィンドウのほかの操作はそのまま保てます。
出典
- contentShape(_:eoFill:) — Apple
- contentShape(::eoFill:) — Apple
- interaction — Apple
- frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) — Apple
- Button — Apple
- plain — Apple
- padding(::) — Apple
- Identifiable — Apple
- State — Apple
- List — Apple
- onTapGesture(count:perform:) — Apple
- highPriorityGesture(_:including:) — Apple
- simultaneousGesture(_:including:) — Apple
- allowsHitTesting(_:) — Apple
- hitTest(_:) — Apple