axiom-swiftui-layout について
SwiftUI開発者向けのアダプティブレイアウトスキルです。デバイス機種ではなくコンテナの利用可能な空間に応じて画面を構成する方法を説明し、レイアウトツールの選択、iPadのマルチタスク、iOS 26のウィンドウサイズ変更を扱います。
主な機能
- 01ViewThatFitsやAnyLayoutなどの選択
- 02onGeometryChangeによる寸法取得
- 03Dynamic Typeとサイズクラスへの適応
- 04iOS 26のリサイズ可能なウィンドウ
- 05レイアウトのアンチパターンと修正案
活用シーン
- 01iPhoneとiPadで画面を適応
- 02Split ViewやStage Managerのレイアウトを修正
- 03iOS 26のウィンドウリサイズに対応
- 04実際のウィンドウ寸法に応じてレイアウトを切り替え
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftui-layoutインストール後、Claude Code で/axiom-swiftui-layoutを入力して読み込まれたか確認します。
詳細
SwiftUI の適応型レイアウト
概要
デバイスを前提とするのではなく、利用可能なスペースに応じて変化するレイアウトを構築するための、規律を徹底するスキルです。ツールの選択、サイズクラスの制約、iOS 26 の自由形式ウィンドウ、よくあるアンチパターンについて説明します。
基本原則: Apple が明日新しいデバイスを発売しても、来年 iPadOS に新しいマルチタスクモードが追加されても、レイアウトが正しく動作するようにしてください。デバイスについての思い込みではなく、コンテナーに応じて対応します。
このスキルを使う場面
- 「iPad と iPhone の両方でこのレイアウトを動作させるにはどうすればよいですか?」
- 「GeometryReader と ViewThatFits のどちらを使うべきですか?」
- 「Split View / Stage Manager でレイアウトが崩れます」
- 「サイズクラスでは必要な情報が得られません」
- 「デザイナーから、縦向きと横向きで異なるレイアウトにしてほしいと言われました」
- 「iOS 26 のウィンドウサイズ変更に向けてアプリを準備しています」
判断ツリー
"I need my layout to adapt..."
│
├─ TO AVAILABLE SPACE (container-driven)
│ │
│ ├─ "Pick best-fitting variant"
│ │ → ViewThatFits
│ │
│ ├─ "Animated switch between H↔V"
│ │ → AnyLayout + condition
│ │
│ ├─ "Read size for calculations"
│ │ → onGeometryChange (iOS 16+)
│ │
│ └─ "Custom layout algorithm"
│ → Layout protocol
│
├─ TO PLATFORM TRAITS
│ │
│ ├─ "Compact vs Regular width"
│ │ → horizontalSizeClass (⚠️ iPad limitations)
│ │
│ ├─ "Accessibility text size"
│ │ → dynamicTypeSize.isAccessibilitySize
│ │
│ └─ "Platform differences"
│ → #if os() / Environment
│
└─ TO WINDOW SHAPE (aspect ratio)
│
├─ "Portrait vs Landscape semantics"
│ → Geometry + custom threshold
│
├─ "Auto show/hide columns"
│ → NavigationSplitView (automatic in iOS 26)
│
└─ "Window lifecycle"
→ @Environment(\.scenePhase)
ツールの選択
すばやく判断する
Do you need a calculated value (width, height)?
├─ YES → onGeometryChange
└─ NO → Do you need animated transitions?
├─ YES → AnyLayout + condition
└─ NO → ViewThatFits
各ツールを使う場面
| 必要なこと | 使うもの | 使わないもの |
|---|---|---|
| 2~3 種類のレイアウト候補から選ぶ | ViewThatFits | if size > X |
| アニメーション付きで H↔V を切り替える | AnyLayout | 条件付きの HStack/VStack |
| コンテナーのサイズを読み取る | onGeometryChange | GeometryReader |
| アクセシビリティ向けのテキストに対応する | dynamicTypeSize | 固定のブレークポイント |
| コンパクトな幅を検出する | horizontalSizeClass | UIDevice.idiom |
| iPad で狭いウィンドウを検出する | Geometry + threshold | サイズクラスのみ |
| サイドバーを表示/非表示にする | NavigationSplitView | 手動のカラム制御 |
| カスタムのレイアウトアルゴリズムを実装する | Layout protocol | ネストした GeometryReader |
パターン 1:ViewThatFits
使用する場面: レイアウト候補が 2~3 種類あり、SwiftUI に収まる候補を選ばせたい場合です。
ViewThatFits {
// First choice: horizontal
HStack {
Image(systemName: "star")
Text("Favorite")
Spacer()
Button("Add") { }
}
// Fallback: vertical
VStack {
HStack {
Image(systemName: "star")
Text("Favorite")
}
Button("Add") { }
}
}
制限事項: ViewThatFits では、どの候補が選択されたかを取得できません。その状態をほかのビューでも使う必要がある場合は、代わりに AnyLayout を使います。
パターン 2:アニメーション付きの切り替えに AnyLayout を使う
使用する場面: レイアウト間の切り替えをアニメーションさせたい場合、または現在のレイアウト状態を把握する必要がある場合です。
struct AdaptiveStack<Content: View>: View {
@Environment(\.horizontalSizeClass) var sizeClass
let content: Content
var layout: AnyLayout {
sizeClass == .compact
? AnyLayout(VStackLayout(spacing: 12))
: AnyLayout(HStackLayout(spacing: 20))
}
var body: some View {
layout {
content
}
.animation(.default, value: sizeClass)
}
}
Dynamic Type の場合:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var layout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}
パターン 3:onGeometryChange(Geometry にはこちらを推奨)
使用する場面: 計算に実際の寸法が必要な場合です。GeometryReader よりこちらを推奨します。
struct ResponsiveGrid: View {
@State private var columnCount = 2
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount)) {
ForEach(items) { item in
ItemView(item: item)
}
}
.onGeometryChange(for: Int.self) { proxy in
max(1, Int(proxy.size.width / 150))
} action: { newCount in
columnCount = newCount
}
}
}
アスペクト比を検出する場合(iPad の「向き」):
struct WindowShapeReader: View {
@State private var isWide = true
var body: some View {
content
.onGeometryChange(for: Bool.self) { proxy in
proxy.size.width > proxy.size.height * 1.2
} action: { newValue in
isWide = newValue
}
}
}
パターン 4:GeometryReader(必要な場合)
使用する場面: iOS 15 以前で Geometry が必要な場合、またはレイアウトフェーズ中に Geometry が必要な場合(副作用として使うだけではない場合)です。
// ✅ CORRECT: Constrained GeometryReader
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
.frame(height: 44) // MUST constrain!
Button("Next") { }
}
// ❌ WRONG: Unconstrained (greedy)
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
// Takes all available space, crushes siblings
Button("Next") { }
}
サイズクラスの真理値表(iPad)
| 構成 | 横方向 | 縦方向 |
|---|---|---|
| フルスクリーンの縦向き | .regular | .regular |
| フルスクリーンの横向き | .regular | .regular |
| 70% Split View | .regular | .regular |
| 50% Split View | .regular | .regular |
| 33% Split View | .compact | .regular |
| Slide Over | .compact | .regular |
| キーボード使用時 | (変更なし) | (変更なし) |
重要なポイント: iPad でサイズクラスが .compact になるのは、幅が約 33% の場合、または Slide Over の場合だけです。より細かく制御するには Geometry を使います。
iOS 26 の自由形式ウィンドウ
変更点
| iOS 26 より前 | iOS 26 以降 |
|---|---|
| 固定サイズの Split View | ドラッグによる自由なサイズ変更 |
UIRequiresFullScreen が使用可能 | 非推奨 |
| iPad にメニューバーなし | .commands によるメニューバー |
| カラムの表示/非表示を手動で制御 | NavigationSplitView が自動で適応 |
Apple のガイドライン
「アプリのサイズを変更しても、レイアウトが恒久的に変わらないようにします。可能な限り、開始時の状態に戻れるようにしてください。」
つまり: ウィンドウサイズに基づいてレイアウトの状態を保存しないでください。ウィンドウが元のサイズに戻ったときは、レイアウトも元に戻るようにします。
NavigationSplitView による自動適応
// iOS 26: Columns automatically show/hide
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// No manual columnVisibility management needed
移行チェックリスト
- Info.plist から
UIRequiresFullScreenを削除する - 任意のウィンドウサイズでテストする(33/50/66% だけでなく)
- サイズ変更後にレイアウトが「固定」されないことを確認する
- よく使う操作のためのメニューバーコマンドを追加する
- Window Controls がツールバー項目に重ならないことをテストする
アンチパターン
❌ デバイスの向きの監視
// ❌ WRONG: Reports device, not window
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification, ...
)
let orientation = UIDevice.current.orientation
if orientation.isLandscape { ... }
失敗する理由: ウィンドウの形状ではなく、デバイス本体の向きを報告します。Split View、Stage Manager、iOS 26 では正しく動作しません。
修正方法: onGeometryChange を使って、実際のウィンドウの寸法を読み取ります。
❌ 画面の境界
// ❌ WRONG: Returns full screen, not your window
let width = UIScreen.main.bounds.width
if width > 700 { useWideLayout() }
失敗する理由: マルチタスクでは、アプリが画面の 40% しか使えないことがあります。
修正方法: ビューの実際のコンテナーサイズを読み取ります。
❌ デバイスモデルの判定
// ❌ WRONG: Breaks on new devices, wrong in multitasking
if UIDevice.current.userInterfaceIdiom == .pad {
useWideLayout()
}
失敗する理由: Split View の 1/3 の iPad は、横向きの iPhone 14 Pro Max より幅が狭くなります。
修正方法: デバイスの種類ではなく、利用可能なスペースに応じて対応します。
❌ 制約のない GeometryReader
// ❌ WRONG: GeometryReader is greedy
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { } // Crushed
}
修正方法: .frame() で制約するか、onGeometryChange を使います。
❌ 向きの代替指標としてのサイズクラス
// ❌ WRONG: iPad is .regular in both orientations
var isLandscape: Bool {
horizontalSizeClass == .regular // Always true on iPad!
}
修正方法: アスペクト比が必要な場合は、実際のジオメトリから計算します。
注意が必要なシナリオ
「デザイナーが iPhone 専用のレイアウトを求めている」
誘惑: if UIDevice.current.userInterfaceIdiom == .phone
回答: 「利用可能なスペースに応じて切り替わる『コンパクト』と『レギュラー』のレイアウトとして実装します。ウィンドウが狭い場合は、iPad にも iPhone のレイアウトが表示されます。これなら Stage Manager と iOS 26 にも対応できます。」
「GeometryReader を使えばいいだけです」
誘惑: すべてを GeometryReader で囲む。
回答: 「GeometryReader には、貪欲に拡張するという既知のレイアウト上の副作用があります。onGeometryChange なら、レイアウトに影響を与えずに同じデータを読み取れます。iOS 16 にバックポートされています。」
「以前はサイズクラスで問題ありませんでした」
誘惑: すべてをサイズクラスで処理する。
回答: 「サイズクラスは大まかです。iPad はどちらの向きでも .regular です。大まかな分類にはサイズクラスを、細かいしきい値にはジオメトリを使います。」
「iPad のマルチタスクには対応しません」
誘惑: UIRequiresFullScreen = true
回答: 「Apple は iOS 26 で、フルスクリーン専用の指定を非推奨にしました。Split View に積極的に対応しない場合でも、サイズ変更でアプリが壊れないようにする必要があります。スペースに基づくレイアウトでも、コストは変わりません。」
リソース
WWDC:2025-208、2024-10074、2022-10056
スキル:axiom-swiftui-layout-ref、axiom-swiftui-debugging、axiom-liquid-glass