Mac アプリの小さなラベル行には、ViewThatFits を使って、非表示数を含めて収まる最長の完全な先頭部分を選びます。このチュートリアルでは、ラベルを省略せず、レイアウト中に状態を更新せずにその行を作成します。
具体的な課題は、合成ラベルを6つ含む、サイズ変更可能な macOS ウインドウです。順序を保ち、完全なラベルだけを表示し、省略された末尾の項目数を 3 more のようなバッジで示します。この解決策では、件数だけのバッジを表示するのに十分な横幅と、1行分の高さがあることを前提とします。
行全体の候補を選ぶ
Apple の説明によると、ViewThatFits は、制約された軸で提案されたサイズに理想サイズが収まる最初の子ビューを選択します。macOS 13 以降で利用できます。ここでは行の幅が課題なので .horizontal を使います。周囲のインターフェースで行の高さを確保してください。ViewThatFits
6つのラベルに対して、次の順序で7つの候補を用意します。バッジなしでラベルを6つ、1 more とラベル5つ、2 more とラベル4つ、というように続け、最後は 6 more だけにします。それぞれの候補は行全体です。バッジもサイズ判定に含まれるため、件数を追加した結果、最後に表示されるラベルが予期せず押し出されることはありません。
これは先頭から順に表示する方針です。最初のラベルが長いと、後ろに短いラベルが収まる場合でも、すべてのラベルが省略されることがあります。これは意図した動作です。個々のラベルを飛ばすことが要件なら、実装前に別の選択方針を定めてください。
各候補の行に fixedSize() を適用し、ラベルとバッジが理想サイズを保つようにします。Apple の説明では、このモディファイアによってビューが親の境界を超えることがあります。単独でサイズ内に収まることを保証するものではありません。ここでは、外側の選択コンテナがサイズ判定を行います。fixedSize()
macOS の完全な例を作成する
Xcode で macOS SwiftUI アプリを作成し、デプロイメントターゲットを macOS 13 以降に設定します。生成されたアプリ宣言とコンテンツビューを、次の単一ファイルのプログラムに置き換えます。ターゲット内の @main が付いたアプリ宣言は1つだけにしてください。
この例では、ラベルのテキストではなく明示的な識別子を使います。Apple の ForEach の説明では、識別可能な要素か、明示的な識別子のキーパスが必要とされています。異なる識別子を使えば、表示タイトルが同じ項目を2つ扱うこともできます。ForEach
import SwiftUI
struct DemoItem: Identifiable {
let id: Int
let title: String
}
struct ItemChip: View {
let title: String
var body: some View {
Text(title)
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.12))
)
}
}
struct PrefixCandidate: View {
let items: [DemoItem]
let visibleCount: Int
var body: some View {
HStack(spacing: 8) {
ForEach(items.prefix(visibleCount)) { item in
ItemChip(title: item.title)
}
if visibleCount < items.count {
Text("\(items.count - visibleCount) more")
.lineLimit(1)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.15))
)
}
}
.fixedSize()
}
}
struct WholeItemRow: View {
let items: [DemoItem]
var body: some View {
if items.isEmpty {
Text("No items")
.fixedSize()
} else {
ViewThatFits(in: .horizontal) {
ForEach(
Array((0...items.count).reversed()),
id: \.self
) { visibleCount in
PrefixCandidate(
items: items,
visibleCount: visibleCount
)
}
}
}
}
}
struct ContentView: View {
let items: [DemoItem] = [
DemoItem(id: 1, title: "Sketch"),
DemoItem(id: 2, title: "Color samples"),
DemoItem(id: 3, title: "Shapes"),
DemoItem(id: 4, title: "Paper textures"),
DemoItem(id: 5, title: "Layout notes"),
DemoItem(id: 6, title: "Preview")
]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Sample labels")
.font(.headline)
WholeItemRow(items: items)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Resize the window to change how many labels fit.")
.foregroundStyle(.secondary)
}
.font(.system(size: 14))
.padding(24)
.frame(minWidth: 320, idealWidth: 680, minHeight: 160)
}
}
@main
struct WholeItemsDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
候補の識別子は、項目数から0まで降順に並んだ、表示可能な項目数です。各候補内では、項目の識別子でラベルを識別します。各イテレーションで、横方向の行を1つ含む PrefixCandidate が作成されます。
外側の柔軟なフレームによって、選択されたコンテンツを先頭に揃えながら、行が利用可能な横幅を受け取れるようにします。fixedSize() は ViewThatFits 内の候補に適用したままにしてください。選択コンテナ全体に適用すると、親から提案された幅へのコンテナの応答が変わります。
これは完全なソースコード例ですが、ここでは Swift ツールチェーンを利用できなかったため、コンパイルも実行もしていません。次のセクションに示す例示用の計算は別途確認しました。実際のテキスト幅は Mac アプリで検証してください。
バッジを含めて収まるサイズを計算する
有用な判定ルールは、候補全体が収まる中で表示項目数が最大のものを選ぶことです。非表示数は常に、項目の総数からその候補の表示項目数を引いた値になります。別途見積もりを同期させる必要はありません。
幅が72、96、64、88ポイントの項目からなる、例示用の4項目の行を考えます。幅には項目のパディングを含みます。間隔は8ポイント、非表示数が0より大きい場合のバッジ幅は60ポイントとします。これらは計算用に設定した値であり、サンプルアプリの実測値ではありません。
4項目すべてを表示する場合、必要な幅は 72 + 96 + 64 + 88 + 3 × 8 = 344 ポイントです。3項目を表示する場合は、省略された末尾の項目の代わりにバッジが入るため、幅は 72 + 96 + 64 + 3 × 8 + 60 = 316 ポイントになります。候補には項目が3つとバッジが1つ含まれるので、間隔は3つ必要です。
| 利用可能な幅 | 表示項目数 | 非表示項目数 | 選択された候補の幅 |
|---|---|---|---|
| 344ポイント | 4 | 0 | 344ポイント |
| 343ポイント | 3 | 1 | 316ポイント |
| 300ポイント | 2 | 2 | 244ポイント |
| 220ポイント | 1 | 3 | 140ポイント |
| 60ポイント | 0 | 4 | 60ポイント |
プログラムでは、各候補に実際のバッジテキストが含まれます。そのため、例示で仮定した一定の幅に頼らず、バッジ文字列ごとの違いに対応できます。候補の幅は一定量ずつ変化する必要はありません。表示項目数を降順に並べることで、選択の優先順位を表します。
件数だけの候補によって、実用に必要な最小幅が決まります。このルールでは、そのバッジさえ収まらない場合、利用可能な幅の中に件数全体を表示することはできません。十分な最小幅を確保するか、別の配置を許可するか、件数を別の場所に移してください。クリッピングを、項目全体を表示できたものとして扱わないでください。
このサンプルでは、コンテンツの最小幅を指定し、短い合成バッジに控えめなフォントを明示的に設定しています。文言、フォント、パディング、周囲のレイアウトを変更した場合は、件数だけのケースを再確認してください。この例で選んだ最小幅が、すべてのバッジに適用できるわけではありません。
レイアウト選択とビューのライフサイクルを分ける
非表示数は、各候補の不変な入力に含まれています。プログラムは、サイズ計測メソッドからバインディングに書き込んだり、非同期の件数更新をスケジュールしたり、表示コールバックから表示状態を推測したりしません。行を選択すると、それに対応するバッジも同時に選択されます。
繰り返される候補のコンテンツはシンプルに保ちます。複数の候補でラベルを繰り返しても、すべての候補にわたってそのラベルに1つの共有識別子が設定されるわけではありません。Apple の説明では、状態の保存先はビューの識別情報に関連付けられており、識別情報が変わると状態が置き換わることがあります。Demystify SwiftUI
後からラベルを編集可能なコントロールに置き換える場合は、共通の祖先ビューまたはモデルに共有値を保持し、それぞれの表示に適した入力値やバインディングを渡してください。Apple は、共有状態を必要とするビューの最も近い共通祖先に状態を置くことを推奨しています。候補ごとのローカル状態が表示の切り替え後も維持されることに依存しないでください。Managing user interface state
この解決策は、選択された行の中に非表示数を表示します。選択された件数を兄弟ビューやアプリケーションモデルに公開するものではありません。Apple が公開している ViewThatFits のイニシャライザは、制約軸と候補コンテンツを受け取ります。選択された件数のバインディングは提供しません。この値の外部公開は、追加の設計要件として扱ってください。init(in:content:)
カスタムレイアウトに別の設計が必要となる場合を把握する
特殊なジオメトリが必要なら、カスタムの Layout が適しています。Apple の説明では、その主な役割は、複合ビューのサイズを報告し、レイアウトプロキシを使ってサブビューの位置を割り当てることです。プロキシには計測や配置の操作が用意されていますが、基になるビューに直接アクセスすることはできません。Layout
収まる最後の項目で配置を止めるだけの方法で、省略を実装しないでください。Apple の LayoutSubview の説明では、各サブビューを1回配置するよう指示されています。配置を省略することは、文書化された非表示方法ではありません。また、レイアウトプロキシには、別のサブビューを新しい件数ラベルに変えるような、コンテンツを書き換える汎用操作もありません。LayoutSubview
カスタムレイアウトを作成する場合は、間隔や使用するサイズ提案も含めて、計測と配置が一致するようにしてください。Apple は、これらの計算に一貫性を持たせることを明確に求めています。レイアウトで複数の配置を試す場合も、この要件は変わりません。placeSubviews(in:proposal:subviews:cache:)
この候補方式は、対象を意図的に限定しています。項目が6つなら、候補となる行は7つ、行全体に現れる項目は合計21個です。一般に、項目の出現回数は n × (n + 1) / 2 のように増えます。これは構造上の個数であり、パフォーマンスのベンチマークでも、更新ごとにすべての候補が描画されるという主張でもありません。項目数の少ないラベル一覧に使い、大きな一覧やコストの高いコンテンツに拡張する前にプロファイルしてください。
行の境界条件を確認する
アプリを実行し、ウインドウのサイズをゆっくり変更します。コンポーネントを応用する前に、次の条件を確認してください。
- 幅に余裕があるときは、すべてのラベルが表示され、
0 moreバッジは表示されない。 - ウインドウを狭めると、ラベルが末尾から消え、途中で切れたり省略記号が付いたりしない。
- 表示項目数とバッジの非表示項目数の合計が常に6になる。
- 先頭に極端に長いラベルがあっても、件数だけの候補が利用できる。
- 空の配列では、行に
No itemsと表示される。 - タイトルが重複していて識別子が異なる場合も、件数に両方の項目が含まれる。
- フォントやバッジの文言を変更した後も、件数だけの候補がサポートする最小幅に収まる。
小さく順序のある行では、行全体の候補を使うとサイズ判定のルールを簡単に確認できます。各候補にはラベル、間隔、正確な非表示数が含まれます。最小幅の条件を明示し、繰り返されるコンテンツを軽量に保てば、項目が途中で切れたり、別途件数を更新したりせずに、ウインドウのサイズ変更に合わせて行を調整できます。
出典
- ViewThatFits — Apple
- fixedSize() — Apple
- ForEach — Apple
- Demystify SwiftUI — Apple
- Managing user interface state — Apple
- init(in:content:) — Apple
- Layout — Apple
- LayoutSubview — Apple
- placeSubviews(in:proposal:subviews:cache:) — Apple