iPhone Duo の画面:レイアウトからコードまで

十二章。同じ場面の SwiftUI と UIKit。各章で確かめられる判断を一つずつ作り、最後にタブを下に残す理由を見る。

まず、どの幅でも UI がよく見えるようにする。折りたたみのハードウェアは、そのあとに足す強みである。iPhone Duo には外側ディスプレイ、内側ディスプレイ、そしてヒンジがある。これは新しい idiom ではない。App をもう一つ、別系統として増やしてはならない。以下は学習曲線の順に書いてある。各章が渡すのは、検証できる判断が一つだけである。先に壊れる書き方を見て、なぜそう設計するのかを説明し、同じ場面の SwiftUI と UIKit を並べる。

署名は Xcode 27.1 の iOS 27.1 SDK と照合してある。挙動の根拠は Designing for iPhone Duo、Preparing your app for iPhone Duo、Three steps である。本文は言い換えであり、原文の写しではない。

読み方。各章はまず「読み終えると、次ができる」があり、そのあと壊れる例、正しい書き方、「使うとき / 使わないとき」が続く。チェックポイントは先に自分で答え、答えは文末にある。第0章から第6章までは、折り目を問い合わせず、Arrangement を使わず、ヒンジ角も読まない。それらは後の章の話である。

第0章 · 6つの姿勢は確認項目であり、6種類の画面ではない

読み終えると、次ができる。 Duo が閉じ、開き、半折りになり、回転するのを見て、UI をどの幅で考えるべきかを言える。姿勢ごとに別々の版を書くのではない。

壊れる例

製品を次のようには組まない。閉じた縦、閉じた横、開いた縦、開いた横、本のように半折り、机の上に立てた半折り。それぞれにビューツリーを一本ずつ作る。開閉するたびにナビゲーションを入れ替え、ボタンを入れ替え、スクロール位置を捨てる。

SwiftUI で姿勢ごとに分岐する。

enum Pose { case closed, open, half }
struct Root: View {
    let pose: Pose
    var body: some View {
        switch pose {
        case .closed: PhoneHome()
        case .open: TabletHome()
        case .half: BookHome()
        }
    }
}

UIKit で姿勢ごとに分岐する。

func installRoot(for pose: String) {
    let root: UIViewController
    switch pose {
    case "closed": root = PhoneHomeController()
    case "open": root = TabletHomeController()
    default: root = BookHomeController()
    }
    // 三套控制器,三份选中项
}

Apple が幅を二つしか渡さない理由

Duo には外側ディスプレイと内側ディスプレイがある。ヒンジは閉じることも、完全に開くことも、途中で止まることもできる。人は縦に持ち、横に持ち、本のように開き、あるいは机の上に立てる。これらは、一度の利用のなかで連続して起きる変化である。HIG の要求はこうだ。外側ディスプレイは compact 幅、内側ディスプレイは regular 幅を使い、すでにあるレイアウトを、使える空間に合わせて大きくする。持ち方の種類ごとに App を作り直さない。設計しているのは依然として iPhone であり、iOS のレイアウト、バー、セーフエリアの規則は引き続き有効である。

見ているもの それは何か 設計するときの考え方
閉じた状態 外側ディスプレイ。よくある iPhone より広く、高さが低い compact 幅。縦向きのとき、高さは依然として regular
完全に開いた状態 大きな内側ディスプレイ。regular × regular iPad のレイアウトを自然に伸ばした姿。ただし依然として iPhone App
半折り 内側ディスプレイが折り目で左右に分かれる 階層は同じ一式のまま。この章では折り目をまだ扱わない
回転、Split View 同じ一台のうえにある、別々のコンテナ 幅は途中の値を通る。「外側ディスプレイ / 内側ディスプレイ全面」の二段だけではない

バーの向きは、まず一文だけ覚える。内側ディスプレイの縦向きは、上下の水平バーが残る唯一の姿勢である。 外側ディスプレイ、および内側ディスプレイの横向きでは、システムバーは側面へ移る。Split View では、それぞれの側のバーが、自分の外側に付く。この細部をコードにするのは第5章である。

正しい構成

UI は一式である。状態はレイアウトの外に置く。幅が変わったら、入れ替えるのは配置であり、データではない。

struct Root: View {
    @State private var selection: Item.ID?
    var body: some View {
        Home(selection: $selection)
    }
}
final class RootController: UIViewController {
    let model = HomeModel()
    // 开合时只让同一个控制器重新布局
}

この章で覚えておく言葉

まだ Duo の API はない。先に言葉を正しく使う。あとの章でつなぎ間違えないためである。

  • 外側ディスプレイ / 内側ディスプレイ。 閉じると外側ディスプレイを見て、開くと内側ディスプレイを見る。メインシーンは自分で画面を替える。使うとき:ハードウェアを説明するとき、そして確認のときに、測っているのがどちらの面かを言い切るとき。使わないとき:二つの面を、自分で管理する二枚のキャンバスだと思わない。

  • 姿勢。 閉じる、開く、半折り、それに向きを掛ける。使うとき:Device Hub で一つずつ開き、同じ一式の UI がまだそこにあるかを見るとき。使わないとき:レイアウトを選ぶために switch pose を書かない。

  • compact / regular。 外側ディスプレイは compact 幅として設計し、内側ディスプレイは regular 幅として設計する。使うとき:「一度に見るのは一階層」か「二階層を同時に見る」かを決めるとき。使わないとき:regular を「これは iPad だ」と読まない。読み取り方は第3章にある。

  • 水平バー / 垂直バー。 内側ディスプレイの縦向きは上下のバーを保つ。それ以外のよくある姿勢では、ナビゲーション、ツールバー、タブバーを側面へ収める。使うとき:バーを描く時点で、二つの向きの両方を描いておく。使わないとき:それを真似る偽のサイドバーを、自分で描かない。

チェックポイント

  1. 内側ディスプレイを縦にしたとき、システムバーは上下にあるか、側面にあるか。

  2. 6つの姿勢に対して、UI は何セット作るべきか。

  3. Duo を開いたあと、それは iPad アプリになるか。

この章ではまだやらない。 if isDuo は書かない。ヒンジの角度は読まない。折り目は問い合わせない。今必要なのは、「姿勢ごとに UI を一版」を拒むことだけである。

出典:HIG · Designing for iPhone Duo、Tech Talk 111466。

第1章 · 広くなったら次の階層を見せ、文を端まで引き伸ばさない

読み終えると、次ができる。 広くなった領域を受け取ったとき、ペインを足すのか、列を足すのか、それとも元の狭い UI を引き伸ばすだけなのかを決められる。折り目をまたいでよいものと、またいではいけないものも言える。

壊れる例

設定ページは、外側ディスプレイでは読みやすい一列である。内側ディスプレイを開くと、同じリストの各行が画面いっぱいに近い幅まで伸びる。タイトルとスイッチが両端に分かれ、あいだは大きな空白になる。もう一つの壊れ方は、主ボタン、QR コード、顔を画面のちょうど中央に置き、半折りのときに折り目で切断されることである。

レビューに持っていける四つの原則

  1. 機能と状態は途切れない。 閉じているときにできることは、開いたあとも残る。スクロール、選択、編集中の内容を、開閉のたびにリセットしない。変わるのは、一度に何階層見えるかである。

  2. 余った幅は、階層を見せるために使う。 メールは外側ディスプレイでは「リストがあり、タップしてから手紙」である。内側ディスプレイではリストと手紙を並べられる。これは一階層多く見ることであり、手紙の行幅を 900pt にすることではない。

  3. 折り目は本のノドである。 背景、グラデーション、スクロールする文章はまたいでよい。ボタン、文、顔、QR コード、ドラッグのハンドルは、中央にまたがせない。この章で作るのはこの判断だけである。折り目の問い合わせ方は第7章にある。

  4. コントロールは、それが作用する内容の近くに置く。 リストを操作するボタンはリストの上に残す。側面に垂直バーがあるからといって、すべてを遠くへ追いやらない。

ゲームは、今の姿勢を埋め尽くす。上下や左右に黒帯を残すより、画面の比率を変えることを優先する。文字とボタンの物理的な大きさは、できるだけ安定させる。

同じ設定ページ、二つの幅

長い文章とフォームには、読みやすい上限を一つ与える。余った空間は、隣の階層に残す。より長い文には残さない。

Form {
    Toggle("提醒", isOn: $reminders)
    Text("说明保持在容易扫读的宽度里。")
}
.frame(maxWidth: 700)
.frame(maxWidth: .infinity)
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let readable = view.readableContentGuide.layoutFrame
    formWidth.constant = min(readable.width, 700)
}

この領域がすでに「リスト」と「詳細」を同時に置けるほど広いなら、次の章でシステムの分割を使う。今は手書きの HStack サイドバーを作らない。広くなったからといって、文字サイズを一回り大きくしない。

レビューに持っていく問い。まだ API ではない

  • 広げる前に先に問う。 常駐のナビゲーション、補足の詳細、インスペクタ、プレビュー、より多くのコントロール、より高い情報密度。使うとき:compact からかなり広いところへ進むとき、この順で、同時に見せられる構造を探す。使わないとき:空白を埋めるために、先に間隔と文字サイズを大きくしない。

  • ノドのテスト。 中央に、正確にタップできない縫い目が一本あると想像する。またいでよいもの:写真の背景、テクスチャ、継続してスクロールするリストと文章。またいではいけないもの:ボタン、読み切る必要のある短い文、顔、QR コード、入力欄、小さいアイコン。

  • 近くでまとめる。 よく使う操作は、それが影響する塊のそばに残す。使うとき:リストの絞り込みはリストの上に残す。詳細の共有は詳細の上に残す。使わないとき:対称にするために、主操作を内容から遠い角へ捨てない。

  • ゲームは埋め尽くす。 姿勢が変わったら画面比率を変える。使うとき:縦または横にロックしてよい。ただし今の姿勢は埋め尽くす。使わないとき:黒帯で取り繕わない。どうしても余白を残すなら、単色ではなく画面で埋める。

チェックポイント

  1. 内側ディスプレイで設定フォームを 900pt 幅まで引き伸ばすと、問題はどこにあるか。

  2. 半折りのとき、グラデーションの背景と「支払う」ボタンの、どちらを中央に残してよいか。

  3. メールリストの「すべて選択」はリストに付いていくべきか、いちばん右のシステムバーに付いていくべきか。

この章ではまだやらない。 半折りのためにページ全体を作り直さない。折り目を問い合わせるコードも、今は書かない。デザイン稿のうえで「中央にまたがせてはいけない」要素に印を付けるだけでよい。

第2章 · どの SDK でコンパイルするかが、どこまで画面を埋めるかを決める

読み終えると、次ができる。 デプロイメントターゲットと、コンパイルに使う SDK を区別できる。すでに Duo で開けるパッケージが、なぜ黒帯を残し、バーがまだ上部にあるのかを判断できる。

壊れる例

iOS 26 SDK でコンパイルしたパッケージはインストールできる。内側ディスプレイを開くと、ウィンドウは中央に寄り、周囲が空く。閉じると、内容はステータスバーとカメラの左側で止まる。そこで「Duo なら frame をいっぱいに引く」というパッチをもう一式書いてしまう。

SDK は三段階。Duo 専用のスイッチはない

画面を埋めることと垂直バーは、コンパイルに使う SDK が連れてくる。Info.plist のブール値ではない。デプロイメントターゲットは、より低いシステムに留めてよい。古いデバイスにもインストールできる。ただし Duo で表示領域を使い切り、システムバーを垂直の形に入れるなら、このパッケージは iOS 27.1 SDK か、それより新しい SDK でコンパイルしなければならない。27.1 のパッケージを出す前に、バーが垂直になったあとも内容をタップできることを先に確認する。

コンパイルに使った SDK 開いたときの内側ディスプレイ バー
iOS 26 以前 中央に寄り、周囲が空く。外側ディスプレイの内容はステータスバーとカメラの左側 元の水平バーのまま
iOS 27 内側ディスプレイの大部分を埋めるが、右側のステータスバーは避ける ツールバーはまだ上部
iOS 27.1 以降 表示領域を使い切る ツールバーとタブバーが、ステータスバーの下で垂直バーになる

デプロイメントターゲットはより低くてよい。呼び出しは可用性で囲む

次の二段は、まだ折りたたみの API を一切呼ばない。後の各章が守る習慣を作るだけである。27.1 のシンボルは可用性チェックの中に包む。Mac Catalyst では、Xcode 27.1 の既知の問題として、これらの 27.1 シンボルがコンパイルできないことがある。さらに隔離する。

var body: some View {
    Home()
        .modifier(DuoReadyChrome())
}

struct DuoReadyChrome: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 27.1, *) {
            content // 第 5 章才把垂直栏调优放进来
        } else {
            content
        }
    }
}
override func viewDidLoad() {
    super.viewDidLoad()
    if #available(iOS 27.1, *) {
        // 同一个控制器。不要在这里换 root。
    }
}

#if !targetEnvironment(macCatalyst)
// 27.1 专有调用放这里,避免 Catalyst 编不过
#endif

コンパイルに関する事実

  • デプロイメントターゲット IPHONEOS_DEPLOYMENT_TARGET。使うとき:インストールできる最も古いシステムを決める。使わないとき:これを 27.1 に上げることが、画面を埋める前提ではない。画面を埋めるかどうかは、コンパイルに使う SDK を見る。

  • iOS 27.1 SDK。 全画面と垂直バーのスイッチである。使うとき:Duo で完全な形を渡す準備ができたとき、この SDK でコンパイルする。使わないとき:垂直バーに合わせていないまま、27.1 のパッケージを出さない。

  • #available(iOS 27.1, *)。 デプロイメントターゲットが 27.1 より低いとき、新しいシンボルは包まなければならない。使うとき:第5章以降の 27.1 API を呼ぶたび。使わないとき:普通の size class をこれで包まない。それらは、より早いシステムにすでに存在する。

  • Mac Catalyst #if !targetEnvironment(macCatalyst)。使うとき:27.1 固有の API が Catalyst ターゲットをコンパイル不能にするとき、それらの呼び出しを隔離する。使わないとき:そのために iPhone 上の適応を消さない。カメラアクセサリそのものは Catalyst では使えない。

チェックポイント

  1. デプロイメントターゲットは iOS 18 のまま、27.1 SDK でコンパイルする。垂直バーは現れるか。

  2. パッケージはすでに Duo で起動でき、周囲が空いている。次は機種を検出して引き伸ばすのか、SDK を替えるのか。

  3. なぜ 27.1 の新しい呼び出しを、iOS 18 のコードパスに直接書けないのか。

この章ではまだやらない。 画面を埋めるために画面サイズを読まない。UIScreen.main を置き換えるのは次の章である。垂直バーの項目規則も、まだ来ていない。

出典:Three steps to make your app shine on iPhone Duo。Xcode 27.1 には macOS Tahoe 26.6 以降が要る。シミュレータは Device Hub で開閉、回転、折りたたみができる。

第3章 · 問うのはこの領域の幅であり、Duo かどうかではない

読み終えると、次ができる。 デバイス、向き、UIScreen.main で切っていたレイアウトの分岐を、size class と今のコンテナ寸法で再計算するように変えられる。

壊れる例

同じ一台の Duo は、外側ディスプレイ、内側ディスプレイの全画面、内側ディスプレイの Split View に現れる。セーフエリアとバーも変わる。起動時に画面を一度読むと、一度折っただけでその値は期限切れになる。内側ディスプレイは、Info で宣言した向きも無視する。

let tallPhone = UIScreen.main.bounds.height == 844
let scale = UIScreen.main.scale
if UIDevice.current.userInterfaceIdiom == .pad
    || horizontalSizeClass == .regular {
    IPadOnlyLayout()
}
let window = UIWindow(frame: UIScreen.main.bounds)
if UIDevice.current.orientation == .landscape { wide() }
if view.window?.windowScene?.effectiveGeometry
    .interfaceOrientation.isLandscape == true { wide() }
let width = view.bounds.width
    - view.safeAreaInsets.left * 2

逆に覚えやすい二つの事実

regular 幅は、もはや iPad と等しくない。 内側ディスプレイは regular 幅、regular 高さである。かつて「regular なら iPad 専用ページ」だったコードは、今は iPhone 上で動く。Apple の要求はこうだ。内側ディスプレイは、あなたの iPad レイアウトを自然に伸ばした姿に見える。だからそのレイアウトを直す。idiom の判定を足して、それを消すのではない。

向きは形と等しくない。 今が横長か縦長かを知るには、自分が持っている領域の幅と高さを比べる。外側ディスプレイの縦は compact 幅に regular 高さ、外側ディスプレイの横は両方向とも compact である。内側ディスプレイは両方向とも regular である。一台の iPhone アプリは、これから任意の size class の組み合わせに出会う。

寸法はセッションの途中で変わる。viewIsAppearing に一度メモして、それを最後まで使い回さない。

コンテナに合わせて再計算する

struct Stage: View {
    @Environment(\.horizontalSizeClass) private var widthClass
    @Environment(\.verticalSizeClass) private var heightClass
    @Environment(\.displayScale) private var displayScale
    @State private var size: CGSize = .zero

    var body: some View {
        content
            .onGeometryChange(for: CGSize.self) { proxy in
                proxy.size
            } action: { newSize in
                size = newSize
            }
    }

    private var isWiderThanTall: Bool { size.width > size.height }
}
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let bounds = view.bounds
    let wider = bounds.width > bounds.height
    let scale = traitCollection.displayScale
    let usable = bounds.inset(by: view.safeAreaInsets)
    layout(for: usable.size, wider: wider, scale: scale)
}

override func viewWillTransition(
    to size: CGSize,
    with coordinator: UIViewControllerTransitionCoordinator
) {
    super.viewWillTransition(to: size, with: coordinator)
    coordinator.animate { _ in self.view.setNeedsLayout() }
}

let window = UIWindow(windowScene: scene)

本当に UIScreen が必要なときは、使うその瞬間に view.window?.windowScene?.screen から取る。プロパティとして保存しない。ウィンドウは scene から作る。メインスクリーンの bounds を frame にしない。

API

  • horizontalSizeClass。 SwiftUI:@Environment(\.horizontalSizeClass)。UIKit:traitCollection.horizontalSizeClass。使うとき:compact 幅と regular 幅を区別し、垂直の size class と組み合わせる。使わないとき:.regular を「iPad のページ」と訳さない。

  • onGeometryChange。 SwiftUI は寸法が変わったとき、コンテナの CGSize を受け取る。UIKit では viewDidLayoutSubviews と viewWillTransition(to:with:) が対応する。使うとき:幅と高さを比べる、または今のコンテナで局所的に測る。使わないとき:これで UIScreen.main.bounds を読まない。

  • displayScale。 SwiftUI:@Environment(\.displayScale)。UIKit:traitCollection.displayScale。使うとき:ピクセル倍率(pixel scale)が必要なとき。使わないとき:UIScreen.main.scale はもう読まない。メインスクリーンへの参照は廃止される。

  • UIWindow(windowScene:)。 scene でウィンドウを作る。使うとき:UIWindow(frame: UIScreen.main.bounds) を置き換える。使わないとき:レイアウト計算が見るのは、依然として view または window の bounds であり、物理画面の全体ではない。

チェックポイント

  1. horizontalSizeClass == .regular は、今でも iPhone 上で成り立つか。

  2. 内側ディスプレイを横にした。interfaceOrientation で「今のほうが広い」と判断するのは信頼できるか。

  3. なぜ safeAreaInsets.left * 2 は、使える幅を誤って計算するのか。

この章ではまだやらない。 幅の閾値 if width > 700 で、二つのナビゲーションツリーを切り替えない。次の章では、システムコンテナで「狭ければプッシュ、広ければ並べる」を解決する。

出典:Three steps の Step 2、および Preparing の resizing considerations。Tech Talk 111461。

第4章 · 並べるかプッシュするかは、システムコンテナに決めさせる

読み終えると、次ができる。 一つの分割コンテナで選択項目を保ち、項目の最小幅でグリッドの列数を決め、「どの並べ方が収まるか」で局所的に並べ替える。判断の軸は「今どのデバイスにいるか」ではない。

壊れる例

compact では NavigationStack を一つ使い、regular では手書きの HStack サイドバーを使う。選択項目はそれぞれが自分の場所に保存する。グリッドは「Duo なら三列、そうでなければ一列」と書く。ボタングループは if width > 700 で、横と縦をまったく別の二つのビューに裂く。

優先順位

システムナビゲーションのコンテナが使えるなら、コンテナを使う。繰り返すカードは適応グリッドを使う。局所的な「横に収まらなければ縦にする」は、残りの空間を試せるレイアウトを使う。size class はその次である。精密な幾何は最後に残す。分割の情報設計は「集合、選択、詳細」である。狭いときはリストが詳細をプッシュし、広いときは並べる。選択項目は、この変化を生き延びなければならない。余った幅は、この階層を見せるために使う。長い文章は、依然として幅を制限する。

リストと詳細

NavigationSplitView {
    List(items, selection: $selection) { item in
        Text(item.title)
    }
} detail: {
    if let selection {
        Detail(id: selection)
            .frame(maxWidth: 700)
    } else {
        Text("选择一项")
    }
}
let split = UISplitViewController(style: .doubleColumn)
split.setViewController(list, for: .primary)
split.setViewController(detail, for: .secondary)
// 选中项放在 list 与 detail 共用的 model 上
// preferredPrimaryColumnWidth 用 min / ideal / max
// 不要写死侧栏点数

カードのグリッド。最小幅であり、列数ではない

private let columns = [
    GridItem(.adaptive(minimum: 220))
]

LazyVGrid(columns: columns, spacing: 12) {
    ForEach(cards) { Card($0) }
}
func makeLayout() -> UICollectionViewLayout {
    UICollectionViewCompositionalLayout { _, env in
        let width = env.container.effectiveContentSize.width
        let count = max(1, Int(width / 220))
        let item = NSCollectionLayoutItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1 / CGFloat(count)),
                heightDimension: .estimated(160)))
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .estimated(160)),
            subitem: item, count: count)
        return NSCollectionLayoutSection(group: group)
    }
}

Split View が中間の幅に切り分けると、列数は自分で 2 に落ちる。3 から突然 1 へ跳ね戻るのではない。半折りのときに列数を偶数にしたいなら、それは第7章の話である。今は書かない。

同じ一組のビューは配置を替え、同一性は替えない

ViewThatFits(in: .horizontal) {
    HStack { Preview(); Inspector() }
    VStack { Preview(); Inspector() }
}

let layout = isWiderThanTall
    ? AnyLayout(HStackLayout())
    : AnyLayout(VStackLayout())
layout {
    Chart()
    Legend()
}

UIKit に ViewThatFits はない。対応する考え方はこうだ。レイアウトのときに「横にはまだ収まるか」と問い、同じ UIStackView の軸を変える。子コントローラを破棄して作り直さない。そうすると、編集中の文字が失われる。

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let horizontal = stack.bounds.width
        > preview.intrinsicContentSize.width
        + inspector.intrinsicContentSize.width
    stack.axis = horizontal ? .horizontal : .vertical
}

API

  • NavigationSplitView。 UIKit:UISplitViewController。使うとき:集合、選択、詳細。狭ければプッシュし、広ければ並べる。使わないとき:手書きの HStack サイドバーで代用しない。これは第8章の、二つの内容を並べる配置ではない。

  • GridItem(.adaptive(minimum:))。 UIKit:コンテナ幅を項目の最小幅で割り、列数を得る。使うとき:カード、アルバム、ダッシュボードのような繰り返しの内容。使わないとき:isDuo ? 3 : 1 と書かない。

  • ViewThatFits。 与えた軸に沿って、どのサブツリーが収まるかを試す。使うとき:ボタングループ、メタデータ、プレビューとインスペクタのような局所的な選択。使わないとき:App 全体のナビゲーション切り替えに使わない。

  • AnyLayout。 レイアウトコンテナを替え、子ビューの同一性は保つ。使うとき:チャートと凡例、プレビューとプロパティ。配置は変わるが、依然としてその二つのビューである。使わないとき:二つの if 分岐に、子ビューをそれぞれ書き分けない。

チェックポイント

  1. 並べた状態から一列へ折り戻すとき、選択項目はどこに存在すべきか。

  2. Split View のあと、幅は 220pt のカードが二枚分しかない。列数はどうやって来るのか。

  3. ViewThatFits が答えるのは「どのデバイスか」か、「どの配置が収まるか」か。

この章ではまだやらない。 タブバーを側面のナビゲーションにするかどうかは、次の章に残す。二つの内容を折り目から避けるのは、第8章に残す。今は Arrangement を導入しない。

第5章 · システムバーは自分で側面に立つ。偽のバーは立たない

読み終えると、次ができる。 ナビゲーション、ツールバー、タブバーを、水平と垂直の両方の形で見分けられるようにし、二次的なアクションをシステムのオーバーフローメニューに渡せる。

壊れる例

ページ上部が自前で描いた HStack である。あるいは、別に new した UINavigationBar、UIToolbar、UITabBar である。システムはこれらの内容を垂直バーへ収めない。もう一つのよくある問題は、共有ボタンにタイトル "Share" しかないことである。垂直バーはアイコンだけを表示する。アイコンのない項目は、そもそも縦にならない。自分でもう一つ「…」メニューを描くと、システムのオーバーフローメニューと場所を奪い合う。

バーはいつ垂直になり、誰が中に入る資格を持つか

27.1 SDK でコンパイルしたあと、外側ディスプレイはナビゲーションバー、ツールバー、タブバーを側面の一本に合成する。内側ディスプレイの横向き、および leading / trailing の位置にある一部のビューも同様である。内側ディスプレイの縦向きは、依然として水平バーを使う。分割のなかで垂直バーに参加するのは詳細列だけである。サイドバーと内容列は水平のままである。インスペクタは水平のままである。

バーはハードウェアの辺に揃う。右から左へ書く言語でも、反対側へは移さない。Split View では、左の App のバーは左にあり、右の App のバーは右にある。

項目には、タイトルとアイコンの両方が必要である。垂直の形ではアイコンを使う。水平の形ではアイコンを優先し、タイトルを使ってもよい。オーバーフローメニューでは、アイコンとタイトルが一緒に現れる。カスタムビューは、既定では水平バーに残る。垂直に置くのに適していると宣言した場合だけ例外である。文字だけのボタンは水平バーに残る。システムの Edit ボタンは、まさにこのようにシンボルと文字のあいだを切り替える。

垂直バーの上部は、戻る、または閉じるのために残す。その次が Done のような目立つアクションである。残りの項目は、元のグループを保つ。オーバーフローは既定で下から上へ収める。ナビゲーション型のページはタブバーを先に守る。作業をするページはツール項目を先に守る。

同じ一組のアクションを、二種類のバーで使える

SwiftUI では NavigationStack に掛ける。

NavigationStack {
    DetailView()
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Close", systemImage: "xmark") { dismiss() }
            }
            ToolbarItem(placement: .topBarPinnedTrailing) {
                Button("Done", systemImage: "checkmark") { save() }
            }
            ToolbarItem(placement: .bottomBar) {
                Button("Share", systemImage: "square.and.arrow.up") { share() }
            }
            .visibilityPriority(.high)
            ToolbarOverflowMenu {
                Button("Duplicate", systemImage: "plus.square.on.square") {
                    duplicate()
                }
            }
        }
        .toolbarVerticalCompressionBehavior(.automatic)
}

UIKit では、ナビゲーションコントローラの中の子コントローラに掛ける。

let close = UIBarButtonItem(
    title: "Close", image: UIImage(systemName: "xmark"),
    target: self, action: #selector(closeTapped))
let done = UIBarButtonItem(
    title: "Done", image: UIImage(systemName: "checkmark"),
    target: self, action: #selector(doneTapped))
let share = UIBarButtonItem(
    title: "Share", image: UIImage(systemName: "square.and.arrow.up"),
    target: self, action: #selector(shareTapped))
share.visibilityPriority = .high
share.axisBehavior = .automatic
navigationItem.leadingItemGroups = [
    UIBarButtonItemGroup.fixedGroup(items: [close])
]
navigationItem.pinnedTrailingGroup = UIBarButtonItemGroup.fixedGroup(items: [done])
toolbarItems = [share]
navigationItem.verticalBarCompressionBehavior = .automatic

戻るボタンは、ナビゲーションコントローラが自分で載せる。カスタムの閉じるは leading 側に入れる。Done は pinnedTrailingGroup に入れる。検索が空間を埋めているとき、ピン留めした項目がいちばん遅くオーバーフローへ入る。additionalOverflowItems は、自分の「その他」をシステムメニューへ収めるために使う。省略記号はオーバーフローに残し、ほかのメニューは別のシンボルに替える。

シンボルと文字を行き来する項目には .horizontalOnly を付ける。水平バーがないときは隠し、垂直バーへ詰め込まない。カスタムビューに本当に垂直の描き方があるなら、.verticalPreferred を使う。伸縮する空白は垂直軸では寸法が 0 である。グループには ToolbarItemGroup / UIBarButtonItemGroup を使い、固定の間隔を手で挿さない。バッジには badge を使う。数字をタイトルへ書き込まない。そうすると、この項目は純粋なシンボルになりにくい。

垂直バーから抜けるのは、安定した例外である

NavigationStack {
    CalculatorView()
        .toolbarVerticalBehavior(.disabled)
}
override var preferredVerticalBarBehavior: UIVerticalBarBehavior {
    .disabled
}

既定は .automatic である。抜けるのは次の画面だけである。全画面のビデオ、電卓のようにスクロールしない全幅レイアウト、閉じるボタンが一つだけの sheet。これは安定した選択である。viewWillAppear のなかで、ページの状態に合わせて行き来させない。あるページでバーを隠したいだけなら、可視性の API を使う。垂直の挙動を切るのではない。UIKit で設定が本当に変わったなら、setNeedsUpdateOfVerticalBarConfiguration() を呼ぶ。ナビゲーションコントローラは既定で決定をスタックトップに渡し、タブバーは現在選択されているページに渡す。

バーがどちらの辺にあるかを知り、背景をどう伸ばすか

@Environment(\.toolbarVerticalEdge) private var verticalEdge
// HorizontalEdge?;这个上下文从不用垂直栏时为 nil

Image("hero")
    .backgroundExtensionEffect()
let edge = traitCollection.verticalBarEdge
// .leading / .trailing / .unspecified
// 表示系统偏好的边,不表示栏此刻是否可见
// 从不用垂直栏的上下文返回 .unspecified

let backdrop = UIBackgroundExtensionView()
backdrop.contentView = hero

UIKit のこの辺を監視する。registerForTraitChanges に UITraitCollection.systemTraitsAffectingVerticalBarEdge を渡す。trait が変わっても、辺は同じままであることがある。

第一階層の入口。内側ディスプレイは側面のナビゲーションになれる

iPhone では、.sidebarAdaptable だけを書いても底のバーのままである。対応している場所で側面のナビゲーションに変えるには、iOS 27 の defaultTabBarPlacement(.sidebar) をさらに足す。iOS 18 の defaultAdaptableTabBarPlacement は使わない。あれは iPad のためのものである。サイドバーに関する UI は isTabViewSidebarAvailable を見る。size class を推測し続けない。

TabView {
    Tab("Home", systemImage: "house") { HomeView() }
    Tab("Library", systemImage: "books.vertical") { LibraryView() }
}
.tabViewStyle(.sidebarAdaptable)
.defaultTabBarPlacement(.sidebar)
tabBarController.sidebar.preferredPlacement = .sidebar

製品として、底のバーを明確に保ち、広がった空間を内容のなかの分割に残すこともできる。どちらも成り立つ。幅に合わせて偽のサイドバーを自分で描かない。

API

  • toolbarVerticalBehavior(iOS 27.1)。 .automatic / .disabled。UIKit:preferredVerticalBarBehavior をオーバーライドする。型は UIVerticalBarBehavior。使うとき:電卓、全画面ビデオ、閉じるボタンが一つだけの sheet。使わないとき:ナビゲーションや、あるビューの状態に合わせて行き来させない。

  • axisBehavior(iOS 27.1)。 SwiftUI:.automatic / .horizontalOnly / .verticalPreferred。UIKit:UIBarButtonItem.axisBehavior。同じ三つの値である。使うとき:シンボルと文字を切り替える項目には horizontalOnly。垂直の描き方が本当にあるカスタムビューには verticalPreferred。使わないとき:タイトルとアイコンがある普通のボタンは automatic のままにする。

  • visibilityPriority(iOS 27.0)。 SwiftUI:.automatic / .low / .high、および init(lowerThan:) / init(higherThan:)。UIKit:.standard / .low / .high。使うとき:先にグループ全体の優先度を与え、そのあとグループ内の個別項目を与える。よく使うもの、バッジ付きのものは、最後までオーバーフローに残す。使わないとき:二つ目の「…」メニューを自分で作らない。ToolbarOverflowMenu と additionalOverflowItems がシステムメニューである。UIKit の additionalOverflowItems は iOS 16 から使える。

  • 圧縮の挙動(iOS 27.1)。 SwiftUI:.automatic / .prefersToolbarItems / .prefersTabBar。UIKit:navigationItem.verticalBarCompressionBehavior の .automatic / .prefersBarItems / .prefersTabBar。使うとき:ナビゲーション型は既定(先にツール項目を収め、タブを守る)。作業型は、先にタブバーを収めるように変える。使わないとき:圧縮を真似るために、二組のバーをそれぞれ書かない。

  • topBarPinnedTrailing(iOS 27.0)。 UIKit の対応プロパティ pinnedTrailingGroup は iOS 16 から使える。閉じるには .cancellationAction を使い、UIKit では leadingItemGroups に入れる。使うとき:Done のように、戻るの近くになければならない主アクション。使わないとき:二次的なアクションをここにピン留めしない。それらはオーバーフローできなければならない。

  • toolbarVerticalEdge(iOS 27.1)。 SwiftUI:HorizontalEdge?。垂直バーを使わない文脈では nil。UIKit:traitCollection.verticalBarEdge。値は leading、trailing、または unspecified。反映するのは好ましい辺であり、バーが今見えていなくても同じである。使うとき:カスタムの内容を、このバーに揃えたいとき。使わないとき:unspecified を「バーがさっき隠された」と読まない。それは、この文脈が垂直バーを使わないことを表す。

  • backgroundExtensionEffect()(iOS 26)。 UIKit:UIBackgroundExtensionView。contentView をセーフエリアに入れ、端は延長で埋める。使うとき:メインビジュアルや背景を、垂直バーの下まで伸ばす。使わないとき:タップできる前景を、これでセーフエリアの外へ出さない。

  • defaultTabBarPlacement(iOS 27.0)。 .sidebarAdaptable に .sidebar を足す。UIKit:sidebar.preferredPlacement = .sidebar。環境値 isTabViewSidebarAvailable と tabBarPlacement も iOS 27 である。使うとき:第一階層の区分が、広いレイアウトで常駐ナビゲーションに向いているとき。使わないとき:Duo に defaultAdaptableTabBarPlacement を使わない。その修飾子は iOS 18 からあり、作用するのは iPad である。

チェックポイント

  1. タイトルだけで、アイコンがないボタンは、垂直バーに入るか。

  2. 分割の三列のうち、どの列のバーが垂直になるか。

  3. ナビゲーション型のページで空間が足りないとき、既定では先にツール項目を収めるか、先にタブバーを収めるか。

  4. 自前で描いた UIToolbar は、なぜ側面に立たないのか。

この章ではまだやらない。 バーが左か右かを決めるために折り目を問い合わせない。バーの辺は、システムが trait / environment に入れる。Sheet をどちらの側に置くかは、次の章である。

出典:HIG · Vertical controls。Preparing · Optimize bars。Tech Talk 111462。

第6章 · 4辺は別々に計算し、Sheet はどの側へ付くかを宣言する

読み終えると、次ができる。 sheet の位置を選び、非対称のセーフエリアで使える領域を計算し、画面と同心の角丸と焦点で、全面の画像の主体を守れる。

壊れる例

左の inset を二倍して、セーフエリアの合計にする。Split View や垂直バーは左右を不等にするので、内容が偏る。もう一つの問題は、自分で全画面の覆いを作って sheet の代わりにすることである。システムの sheet、警告、メニュー、popover は折り目を避ける。自分の覆いは避けない。メインビジュアルを .fill で切ると、外側ディスプレイでは見えていた顔が、内側ディスプレイでは切られる。

Sheet のバーは、配置についていく

外側ディスプレイでは、ツールバー付きの sheet は既定で垂直バーになる。内側ディスプレイでは、sheet は既定で中央にあり、バーは水平である。leading 側には垂直バーがなく、trailing 側にはある。配置を気にするのは sheet だけである。このためにカスタムの遷移をもう一式作らない。閉じるボタンが一つだけの sheet は、前の章で垂直バーを切ってよい場合である。sourceView を設定すると、UIKit は preferredPlacement を無視する。

.sheet(item: $selection) { item in
    ItemDetail(item: item)
        .presentationPlacement(.trailing)
}
let sheet = detail.sheetPresentationController
sheet?.preferredPlacement = .trailing
// .automatic / .leading / .center / .trailing
// sourceView 非 nil 时,这个属性被忽略

セーフエリアの四辺は独立している

content
    .safeAreaPadding(.horizontal)
background
    .ignoresSafeArea()
let usable = view.bounds.inset(by: view.safeAreaInsets)
// 分别使用 left、right、top、bottom
// 折痕不是 safe area inset

装飾はセーフエリアの外へ出てよい。タップできる内容と、読まなければならない文字は中に残す。カメラ、垂直バー、Split View は、左右または上下を不等にする。画面の角丸に沿うカスタム形状は、同心の矩形を使う。半径を書き固定しない。

RoundedRectangle(cornerRadius: 12) // 不要写死去贴屏幕角
ConcentricRectangle()
view.cornerConfiguration = .corners(radius: .containerConcentric())

全面の画像は、主体を残す

Image("cover")
    .resizable()
    .aspectRatio(contentMode: isWide ? .fit : .fill)
    .frame(maxWidth: .infinity, maxHeight: isWide ? 420 : 280)
    .clipped()
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let wide = view.bounds.width > view.bounds.height
    hero.contentMode = wide ? .scaleAspectFit : .scaleAspectFill
    hero.clipsToBounds = true
}

主体が幾何の中心にないなら、切り抜きに焦点を与える。fill と fit のあいだを切り替えるだけでは足りない。ゲームは埋め尽くして比率を変える。第1章を参照する。

API

  • presentationPlacement(iOS 27.0)。 .automatic / .center / .leading / .trailing。UIKit:UISheetPresentationController.preferredPlacement。使うとき:各 sheet を、閉じた状態、開いた状態、半折りで見てから、端に付けるか中央にするかを決める。使わないとき:popover と警告にこの修飾子を被せない。sourceView を設定すると、UIKit はそれを無視する。

  • 非対称の inset。 bounds.inset(by: safeAreaInsets)。使うとき:使える幅や高さを手で計算する、あらゆる場所。使わないとき:一方の inset を反対側へ鏡写しにしない。折り目もセーフエリアへ詰め込まない。

  • ConcentricRectangle(iOS 26)。 UIKit:UICornerConfiguration のコンテナ同心半径。UIView.cornerConfiguration が存在する。使うとき:カスタムパネルを、今の画面またはコンテナの角丸に沿わせる。使わないとき:カード自身の小さい角丸は、固定半径のままでよい。これで折り目を避けない。

  • fill / fit。 今の比率または size class で選ぶ。あるいは焦点を設定する。使うとき:メインビジュアル、ビデオのカバー。狭い外側ディスプレイから広い内側ディスプレイまで、主体を残す。使わないとき:起動時に一度だけ contentMode を選んで終わらせない。

チェックポイント

  1. 内側ディスプレイで、trailing の sheet のツールバーは水平か、垂直か。

  2. なぜ left * 2 で内容の幅を得られないのか。

  3. システムの sheet は折り目を避ける。自分で描いた全画面の覆いは避けるか。

この章ではまだやらない。 カスタムキャンバス、QR コード、ドラッグコントロールを折り目から避けるときは、次の章で初めて予約領域を問い合わせる。今は折り目の座標を推測しない。

第7章 · 折り目とカメラは二つの予約領域であり、セーフエリアではない

読み終えると、次ができる。 カスタムレイアウトのなかで、有効な折り目とカメラを見つけ、遮られているその一塊だけを動かす。ページ全体を作り直すのではない。

壊れる例

半分折っただけで、別の画面に切り替える。あるいは「折り目は幅の 50%」と書き固定する。Split View と回転のあと、この比率は自分のビューの中心ではなくなる。もう一方の端は、文章の各段落をすべて折り目から避けることである。スクロールする内容は、もともとあの縫い目を通り過ぎられる。

二種類の領域。システムコンポーネントの多くは、すでに避けている

予約領域は、別のものが自分の座標系のなかで占めている矩形である。frame は margins を含む。各領域には種類、有効かどうか、識別子がある。問い合わせが返すのは、今のビューと交差する領域である。自分のビューが内側ディスプレイの半分しか占めていないなら、画面全体の折り目が見えると仮定しない。

  • division、分割。 半折りのとき、ヒンジが大きな一塊を両側に分ける。完全に平らにしたあと、この領域は有効にならず、幅は 0 である。

  • occlusion、遮蔽。 外側ディスプレイの前面カメラは常にあり、Dynamic Island まで広がることもある。内側ディスプレイの前面カメラは普段は画面の下に隠れ、使用中だけ一塊を占めて、画面を避けさせる。

警告、メニュー、sheet、分割は自分で適応する。カスタムキャンバス、QR コード、顔、途切れてはいけない短い文、ドラッグ中の物体だけが、これらの矩形を読む必要がある。動かすのは遮られた要素だけであり、関連するコントロールも一緒に連れていく。画面の反対側へ押しやらない。スクロールする文章とリストは、全体をずらさない。

今効いているものだけを先に絞り込み、それからカスタム内容を置く

SwiftUI の reservedRegions には layoutDirectionBehavior: も足せる。既定は .mirrors である。自分で鏡写しをした場合だけ .fixed を渡す。そうしないと二度鏡写しになる。カメラの物理位置は言語の向きでは反転しない。SwiftUI は既定で、先に幾何を鏡写ししてくれる。onGeometryChange の proxy でも同じように調べられる。

GeometryReader { proxy in
    let folds = proxy.reservedRegions(kind: .division)
        .filter(\.isActive)
    let cameras = proxy.reservedRegions(kind: .occlusion)
        .filter(\.isActive)
    CanvasPage(avoiding: folds + cameras)
}
override func layoutSubviews() {
    super.layoutSubviews()
    let folds = reservedRegions(kind: .division)
        .filter(\.isActive)
    let cameras = reservedRegions(kind: .occlusion)
        .filter(\.isActive)
    placeControls(avoiding: folds + cameras)
}

偶数列のような構造の決定は、デバイスが平らで、分割領域がまだ有効でないときにも、その存在を見られなければならない。そのとき初めて .includeInactive を渡す。コントロールを今すぐ動かすかどうかは、依然として isActive で決める。

let divisions = proxy.reservedRegions(
    kind: .division,
    options: .includeInactive)
let evenColumns = !divisions.isEmpty
let divisions = reservedRegions(
    kind: .division,
    options: .includeInactive)
let evenColumns = !divisions.isEmpty

API

  • reservedRegions(kind:options:)(iOS 27.1)。 SwiftUI では GeometryProxy のうえにある。UIKit:view.reservedRegions(kind:options:)。戻り値は [UIView.ReservedRegion]。使うとき:システムコンテナが代わりに動かしてくれないカスタム内容。使わないとき:普通のリスト、分割、sheet、警告。結果をセーフエリアの inset だと思わない。

  • ReservedRegion(iOS 27.1)。 id、kind、frame、margins、isActive。種類は .division と .occlusion。使うとき:frame で位置を決める。frame はすでに余白を含んでいる。使わないとき:有効でない division でボタンを押しのけない。平らなときは、タップ位置を変えてはならない。

  • .includeInactive(iOS 27.1)。 ReservedRegion.QueryOptions。使うとき:「折り目が存在するなら、グリッドは偶数列にする」のように、今半折りかどうかとは無関係な決定。使わないとき:有効でない領域が見つかったからといって、ボタンを先回りして動かさない。

  • layoutDirectionBehavior。 .mirrors または .fixed。使うとき:既定を保ち、自動レイアウトの子ビューと領域が同じ向きを使うようにする。使わないとき:手動で鏡写しするときだけ .fixed を渡す。

チェックポイント

  1. 完全に平らにしたあと、division はまだ有効か。幅はいくらか。

  2. スクロールできる文章は、各段落を折り目からずらすべきか。

  3. 内側ディスプレイのカメラは、普段 occlusion を占めているか。

この章ではまだやらない。 プレーヤーとプレイリストのような対の内容は、次の章で Arrangement に渡す。折り目を避けるかどうかを、ヒンジの角度で決めない。

出典:ReservedRegion。Preparing · Adapt to reserved regions。Tech Talk 111463。

第8章 · 二つの内容が一つの体験になるとき、入れ替えはコンテナに任せる

読み終えると、次ができる。 一対のビューに split と overlay のどちらを使うかを判断し、ナビゲーションをこのコンテナの外に残せる。

壊れる例

Arrangement で TabView や NavigationSplitView を置き換える。あるいは Arrangement を List、ScrollView、分割のどれか一列の中に入れ、結果として半分の内容がスクロールして出てこず、タップもできない。

すでに二種類のスタックに似ているなら、そこへ対応づける

Arrangement にあるのは主ビューと副ビューだけである。見るのは空間、アスペクト比、size class、分割領域であり、自分が Duo かどうかは見ない。ナビゲーションコンテナは外で包む。Apple 自身の例は、ナビゲーションスタックのなかに Arrangement を一つ置くことである。Arrangement の内側に、さらに分割を入れ子にしない。

  • split。 元の HStack / VStack に対応する。あるいは、どちらも相手を遮ってはいけない主従の関係に対応する。高さより幅が広いときは左右に分け、幅より高さが高いときは上下に積む。分割領域が現れると、両側を調整して折り目を避ける。一つの軸に制限したあと、その軸が使えないなら主ビューだけが残る。.split.axes(.horizontal) は、水平軸に並べられないとき主内容だけを表示する。

  • overlay。 元の ZStack に対応する。有効な分割領域がないとき、主ビューは副ビューの上に被さる(閉じているとき、または完全に開いているとき)。半折りのとき、主ビューは折り目の後ろ側または下側へ行き、副ビューは前側または上側へ行く。

プレーヤーとリスト。split

NavigationStack {
    ArrangementView {
        Player()
    } secondary: {
        Queue()
    }
    .arrangementViewStyle(.split)
}
let arrangement = UIArrangementViewController()
arrangement.setViewController(player, for: .primary)
arrangement.setViewController(queue, for: .secondary)
arrangement.updateArrangement(.split)
navigationController.setViewControllers([arrangement], animated: false)

左右だけを許し、縦のときはプレーヤーだけを残す。

.arrangementViewStyle(.split.axes(.horizontal))
arrangement.updateArrangement(.split.axes(.horizontal))

コントロールが画面の上に被さる。overlay

ArrangementView {
    TransportControls()
} secondary: {
    Stage()
}
.arrangementViewStyle(.overlay)
arrangement.setViewController(controls, for: .primary)
arrangement.setViewController(stage, for: .secondary)
arrangement.updateArrangement(.overlay)

比率と階層には、さらに次の調節がある。いずれも iOS 27.1 である。splitArrangementLayoutRatio、splitArrangementLayoutSize、splitArrangementFixedLayoutSize、overlayArrangementEdge。環境値 splitArrangementAxis と overlayArrangementZIndex は、子ビューが今どの配置にいるかを知るために使う。UIKit は state(for:) で zIndex、splitAxis、isHidden を読む。主ビューと副ビューの寸法範囲は UISplitArrangement.ViewProperties にあり、被さる辺は UIOverlayArrangement.ViewProperties.edge にある。

API

  • ArrangementView(iOS 27.1)。 init は primary と secondary を受け取る。UIKit:UIArrangementViewController。setViewController(_:for:animated:) の位置は .primary / .secondary。使うとき:プレーヤーとキュー、エディタとインスペクタ、キャンバスとプロパティ。二つは同じ一つの体験である。使わないとき:App の第一階層のナビゲーション。分割、リスト、スクロールビューの中に入れない。

  • .split(iOS 27.1)。 arrangementViewStyle(.split)。.axes(.horizontal) または .vertical。UIKit:updateArrangement(.split.axes(...))。使うとき:両側に専用の空間が要り、どちらも相手を覆わない。使わないとき:コントロールがもともと内容の上に浮くべきとき。それは overlay である。

  • .overlay(iOS 27.1)。 分割領域がないとき、主ビューは上にある。半折りのとき、主ビューは後ろ側または下側へ、副ビューは前側または上側へ行く。使うとき:前景 / 背景。普段は互いに遮ってよい。使わないとき:二欄のメール。それは第4章の分割か、ここでの split である。

  • updateArrangement(_:animated:)(iOS 27.1)。 split / overlay を替える、または軸だけを変える。使うとき:製品として一つの軸を明示的に固定したいとき、または二つのスタイルのあいだを切り替えるとき。使わないとき:回転のたびに自分で frame を再計算してから、これを呼ばない。既定のスタイルは、すでにコンテナについていく。

チェックポイント

  1. 元は ZStack であり、半折りのときに折り目の両側へ分けたい。split と overlay のどちらを使うか。

  2. Arrangement はナビゲーションを包むべきか、ナビゲーションに包まれるべきか。

  3. それを ScrollView に入れるとどうなるか。

この章ではまだやらない。 split か overlay かを決めるためにヒンジの角度を読まない。角度は第10章の、インタラクションの入力である。

出典:Preparing のなかの SwiftUI / UIKit の例。HIG · Arrangement views。署名は iOS 27.1 SDK の ArrangementView と UIArrangementViewController と照合してある。

第9章 · 開閉はレイアウトの変化であり、App を再起動することではない

読み終えると、次ができる。 ナビゲーション、スクロール、選択、編集、再生をレイアウトの上に残し、Split View と新しいウィンドウが Duo でどこまでかを知っている。

壊れる例

閉じているときのリストは、三通目の手紙を選択している。開いたあと、別のビューツリーは一通目から始まり、下書きも空である。もう一つの壊れ方は、外側ディスプレイの全画面と内側ディスプレイの全画面だけを測り、Split View の途中でバーが重なり、文字が切れることである。さらに「開いたら必ず広い」と仮定する。

一本の連続した幅に、ハードウェアの制限が二つ加わる

内側ディスプレイでは、すべての App が Split View に参加する。ビデオも App と重なれる。各 App の垂直バーは自分の外側に付く。だから自分のバーは左にあることも、右にあることもある。両側を測る。

とても広いレイアウトは、中間の幅へ退け、さらに compact へ退けなければならない。状態、つまりナビゲーションパス、スクロール、選択、エディタ、再生位置、未保存のフォームは、一欄か二欄かを決める層の上に置く。

Duo は、複数シーンをサポートする最初の iPhone である。iPad で新しいウィンドウを開く App は、ここでも開く。新しいウィンドウは外側ディスプレイでは作れない。要求が失敗したら、今のウィンドウに留まる。UIWindowScene.ActivationAction の設定クロージャは、ActivationConfiguration を返さなければならない。要求オブジェクトを書き換えにいかない。プラットフォームが複数ウィンドウをサポートしないときは、自分が渡した alternate へ進む。外側ディスプレイをタップすれば必ず二つ目のウィンドウが開くと仮定しない。UIRequiresFullScreen は依然として有効である。ただしデバイスの開閉で、画面の寸法はやはり変わる。

選択項目は、レイアウトの外で生きている

struct Mailbox: View {
    @State private var path: [Message.ID] = []
    @State private var selection: Message.ID?
    @State private var draft = ""

    var body: some View {
        NavigationSplitView {
            MessageList(selection: $selection)
        } detail: {
            Editor(text: $draft, messageID: selection)
        }
    }
}
let action = UIWindowScene.ActivationAction(
    title: "新窗口",
    image: UIImage(systemName: "plus.rectangle")
) { _ in
    let activity = NSUserActivity(activityType: "mailbox.message")
    return UIWindowScene.ActivationConfiguration(userActivity: activity)
}

スクロール位置は、システムスクロールビュー自身の保存を使う。あるいは model に置いた識別子を使う。閉じたときに scrollTo で先頭へ戻さない。再生は同じプレーヤーオブジェクトを使う。寸法が変わるときに replace して捨てない。

確認する事実

  • 状態の引き上げ。 選択、パス、下書き、プレーヤーは、適応コンテナの外に置く。使うとき:compact と regular のあいだを切り替える、あらゆるページ。使わないとき:外側ディスプレイと内側ディスプレイのために、状態オブジェクトを一つずつ作らない。

  • Split View。 バーはその App の外側にある。左半分のバーは左、右半分のバーは右である。使うとき:Device Hub で、自分を左と右にそれぞれ置く。使わないとき:垂直バーが永遠に trailing にあると仮定しない。

  • UIWindowScene.ActivationAction(iOS 15)。 クロージャは UIWindowScene.ActivationConfiguration(userActivity:) を返す。プラットフォームが複数ウィンドウをサポートしないときは alternate を使う。使うとき:iPad ですでに複数ウィンドウをサポートしていて、Duo の内側ディスプレイでも同じようにしたいとき。使わないとき:外側ディスプレイで新しいシーンを強制的に要求し、失敗を処理しない。新しいウィンドウは外側ディスプレイでは作れない。

  • UIRequiresFullScreen。 全画面の要求は依然として守られる。使うとき:もともと画面を独占する必要があるなら、元の設定を保つ。使わないとき:開閉がもたらす寸法変化を止められると思わない。レイアウトは、やはり変化についていく。

チェックポイント

  1. 内側ディスプレイを開いたあと、下書きはどの状態から戻すべきか。

  2. App が Split View の右側にあるとき、垂直バーはどちらの側にある可能性が高いか。

  3. 新しいウィンドウは、外側ディスプレイで作れるか。

この章ではまだやらない。 ヒンジの角度でナビゲーションをリセットしない。開閉は、すでに寸法と予約領域で表現されている。角度は、本当の物理インタラクションだけに残す。

出典:画面をまたぐ連続性と、Split View の外側バーについての HIG の説明。複数の寸法に対する Preparing のテスト要求。Tech Talk 111464。

第10章 · 角度が駆動するのは効果であり、バーや列数ではない

読み終えると、次ができる。 ヒンジがあるとき、角度を音高、レンズ、または遊び方に変えられる。ヒンジがないとき、またはインタラクションが階層を離れたとき、安全な値へ戻せる。

壊れる例

if hinge.angle > someRadians { showSidebar = true }

平らに置いたとき、Split View のとき、普通の iPhone は、期待どおりの角度を渡してくれない。サイドバーを出すべきかどうかは、第3章と第4章がすでに空間で答えている。

レイアウトは空間を見て、インタラクションだけがヒンジを見る

角度に向いているのは、物理入力として扱うこと、ゲームの仕掛け、楽器、カメラの姿勢、机の上のインタラクション、または開閉に明示的に結んだ効果である。向いていないのは、サイドバー、列数、グリッドの個数、ナビゲーションを折りたたむかどうかである。

SwiftUI では、DeviceHingeContext.hinge が nil なのは、このデバイスにヒンジがないことを表す。「閉じた」ではない。閉じたのは .closed である。angle は Angle である。status は .closed、.partiallyOpen、.fullyOpen である。

UIKit の nil の意味は異なる。インタラクションが、ヒンジの更新を渡せる階層を離れたとき、update.hinge は nil になる。角度はラジアンである。UIHinge.Status には .unknown が一つ多い。更新の頻度と精度はシステムが決める。特定のフレームレートに依存しない。開いているか、半開きか、全開かだけを知りたいなら、status を読む。

半開きのとき角度を音高にし、それ以外はゼロに戻す

.onHingeChange { _, context in
    if let hinge = context.hinge, hinge.status == .partiallyOpen {
        pitch = bend(angle: hinge.angle)
    } else {
        pitch = 0
    }
}
let interaction = UIHingeInteraction { [weak self] _, update in
    guard let self else { return }
    guard let hinge = update.hinge, hinge.status == .partiallyOpen else {
        self.pitch = 0
        return
    }
    self.pitch = self.bend(radians: hinge.angle)
}
view.addInteraction(interaction)

onHingeChange(isEnabled:_:) の isEnabled は既定で true である。UIKit は interaction.isEnabled を使う。切っているあいだはコールバックせず、逃した更新をキューにも積まない。再び開いたとき、今の階層にまだヒンジがあるなら、現在の状態をもう一度すぐに渡す。このクロージャはエスケープする。循環参照を避けるために [weak self] を使う。

API

  • onHingeChange(iOS 27.1)。 (isEnabled:_:)。引数は古いものと新しいものの二つの DeviceHingeContext。使うとき:効果がヒンジに連続してついて変わらなければならないとき。使わないとき:折り目を避けること、分割、グリッド。それらは予約領域と Arrangement を使う。

  • DeviceHinge(iOS 27.1)。 status に angle: Angle が加わる。文脈上の hinge は Optional である。使うとき:nil のときは効果をゼロに戻し、クラッシュさせない。使わないとき:nil を「閉じた」と読まない。閉じたのは .closed である。

  • UIHingeInteraction(iOS 27.1)。 init { interaction, update in }。update.hinge は Optional の UIHinge。angle はラジアン。状態には .unknown が含まれる。使うとき:ヒンジを観察したいビューに足す。公式の例は viewDidLoad のなかで addInteraction する。使わないとき:ここでの nil は、インタラクションが更新を渡す階層を離れたことを表す。必ずしも「この端末にヒンジがない」ではない。

  • 更新の頻度。 ヘッダは明記している。速度と粒度はシステムの状態とともに変わる。使うとき:角度を連続入力として扱い、フレーム落ちを許容する。使わないとき:「毎秒必ず N 回」で較正やレイアウトのアニメーションをしない。

チェックポイント

  1. サイドバーを出すべきかどうかは、hinge.angle を見てよいか。

  2. SwiftUI で hinge == nil は何を表すか。UIKit の nil は何か。

  3. 半開きかどうかだけを知りたいとき、読むのは角度か、status か。

この章ではまだやらない。 外側ディスプレイを、自分で配置する第二のウィンドウだと思わない。それは次の章であり、しかも撮影中にだけ成り立つ。

第11章 · 外側ディスプレイの追加内容は、撮影中にシステムが表示するだけ

読み終えると、次ができる。 メインシーンが二つの画面のあいだをどう移動するかを説明でき、外側ディスプレイに補足内容を置いてよいのがいつかも説明できる。

壊れる例

開いたときに UI を二つの UIWindow に裂き、一つは内側ディスプレイを描き、一つは外側ディスプレイを描く。システムは、自分の第二のキャンバスを、閉じたほうの面に残してくれない。シミュレータにもカメラはない。このコードは Device Hub では検証できない。

主体験は、唯一のメインシーンについていく

開閉のとき、メインシーンは自分で反対の面へ移る。重要なボタンはすべてメインの画面に残す。外側ディスプレイの補足内容はインタラクションできるが、少なくする。

サポートされている唯一の方法は、カメラ撮影のアクセサリである。デバイスが開いていて、App が前面にあり、内側ディスプレイが全画面で、進行中の撮影セッションが一度あるとき、システムはそれを外側ディスプレイへ表示することがある。いつ、どこへ出すかはシステムが決め、いつでも取り戻せる。切るには isEnabled を使う。登録解除でオンオフしない。状態は同じ model を通る。

シミュレータにカメラはない。この節は実機が要る。カメラの向きは、開閉と回転のあと逆になることがある。カメラを選ぶときは「どちら側を向いているか」で選ぶ。Choosing a camera by the direction it faces を参照する。

ExternalNonInteractiveAccessory は外付けディスプレイと AirPlay であり、Duo の外側ディスプレイではない。

プロンプター。メインの画面でも同じように撮れる

CameraCaptureAccessory は SDK で、macOS、tvOS、watchOS、visionOS、Mac Catalyst では使えないと明記されている。sceneAccessory と registerSceneAccessory(_:) は iOS 27.0 から存在する。cameraCapture(sceneConfiguration:) とセッションロール .windowCameraCaptureAccessory は iOS 27.1 である。文脈をシーンデリゲートへ渡す必要があるときは、userInfo 付きのファクトリメソッドを使い、UISceneConnectionOptions.sceneAccessoryUserInfo から取り出す。

CameraView(model: model)
    .sceneAccessory {
        CameraCaptureAccessory(isEnabled: $model.prompterEnabled) {
            Prompter(model: model)
        }
        .onAvailabilityChange { model.prompterAvailable = $0 }
    }
let configuration = UISceneConfiguration(
    name: "Capture Accessory",
    sessionRole: .windowCameraCaptureAccessory)
let accessory = UISceneAccessory.cameraCapture(
    sceneConfiguration: configuration)
registration = registerSceneAccessory(accessory)
// 配件场景的 delegate 画提词器。
// 快门仍在主场景里。

API

  • CameraCaptureAccessory(iOS 27.1)。 init(isEnabled:content:)。sceneAccessory に掛ける。onAvailabilityChange は、システムがまだそれを表示したいかを報告する。使うとき:プロンプター、カウントダウン、撮られる人に画面を見せること。登録は、撮影 UI と同じビューに置く。使わないとき:主経路のシャッター、フォーカス、設定。外側ディスプレイはいつでも消えうる。

  • registerSceneAccessory(iOS 27.0)。 UIKit:UISceneAccessory.cameraCapture(sceneConfiguration:)。ロールは .windowCameraCaptureAccessory。返る registration は unregisterSceneAccessory に使う。使うとき:撮影セッションがあるあいだの補足シーン。使わないとき:一時的に隠すために unregister しない。有効状態を使う。

  • ExternalNonInteractiveAccessory。 接続されたディスプレイ / AirPlay。使うとき:もともと外付けの大画面をサポートしていて、内容にインタラクションが不要なとき。使わないとき:Duo を閉じたときの外側ディスプレイ。

  • シミュレータ。 Device Hub は開閉、回転、折りたたみができる。カメラはない。多くの App 拡張も Duo シミュレータでは動かない。使うとき:レイアウト、バー、sheet、Split View。使わないとき:シミュレータのなかで、撮影アクセサリを検証済みだと宣言しない。

チェックポイント

  1. 撮影していないとき、ダッシュボードを一枚、外側ディスプレイにピン留めできるか。

  2. プロンプターが使えないとき、シャッターはどこにあるべきか。

  3. なぜこの段を、シミュレータだけで締めくくれないのか。

ここまでである。 製品にカメラがなければ、この章はコードを書かなくてよい。第0章から第9章までが、設計から適応レイアウトまでの主経路をすでに覆っている。

出典:Preparing · Improve your app’s camera handling。Tech Talk 111465。

付録 · 対照、危険信号、答え

前の十二章は、順に判断を組み立ててきた。ここでは三層の API、現れてはいけない書き方、チェックポイントの答えを一箇所に置く。一章終えたあと、戻って突き合わせるためである。

三層。飛ばさない

層 いつ書くか SwiftUI UIKit
1 · あらゆる寸法 第3章から第6章。すべての Apple のウィンドウが恩恵を受ける NavigationSplitView、TabView、GridItem(.adaptive)、ViewThatFits、AnyLayout、size class、onGeometryChange、システムの .toolbar UISplitViewController、UINavigationController、UITabBarController、コンテナ幅、trait、layoutSubviews、viewWillTransition
2 · 折り目と垂直バーを知っている 第5章から第8章で、システムコンテナでは足りないとき reservedRegions、ArrangementView、toolbarVerticalBehavior、axisBehavior、visibilityPriority、presentationPlacement、toolbarVerticalEdge reservedRegions(kind:options:)、UIArrangementViewController、preferredVerticalBarBehavior、axisBehavior、verticalBarEdge、preferredPlacement
3 · ハードウェアそのものだけ 第10章から第11章。製品が本当に必要とするとき onHingeChange、CameraCaptureAccessory、sceneAccessory UIHingeInteraction、UISceneAccessory.cameraCapture、ロール .windowCameraCaptureAccessory

垂直バーの項目規則が第5章にあるのは、27.1 SDK がシステムバーを立てるからであり、折り目を問い合わせたいからではない。折り目の問い合わせは第7章から始まる。

可用性。覚え違えない

システム この段階で初めてあるもの
iOS 26 ConcentricRectangle、backgroundExtensionEffect()、UIBackgroundExtensionView、UICornerConfiguration
iOS 27.0 visibilityPriority、ToolbarOverflowMenu、topBarPinnedTrailing、presentationPlacement、defaultTabBarPlacement、isTabViewSidebarAvailable、sceneAccessory / registerSceneAccessory
iOS 27.1 垂直バーの挙動と辺、axisBehavior、圧縮の挙動、予約領域、Arrangement、ヒンジ、CameraCaptureAccessory

UIKit の leadingItemGroups、pinnedTrailingGroup、additionalOverflowItems は iOS 16 から使える。Duo では垂直バーのなかの順序とオーバーフローを担うが、プロパティそのものは 27.1 で初めて現れたものではない。

SwiftUI の可視性の優先度は .automatic / .low / .high である。UIKit の対応する値は .standard / .low / .high である。圧縮の挙動も名前が一つ違う。SwiftUI は .prefersToolbarItems、UIKit は .prefersBarItems である。

ファイルを開いたら、まずこれらを検索する

普通のレイアウトに使っているなら、直すべきである。

  • UIScreen.main.bounds、UIScreen.main.scale、保存してある UIScreen

  • UIWindow(frame: UIScreen.main.bounds)

  • userInterfaceIdiom == .pad または .phone

  • UIDevice.current.orientation、statusBarOrientation、interfaceOrientation。effectiveGeometry.interfaceOrientation を含む

  • horizontalSizeClass == .regular で「iPad のみ」のページへ入る

  • bounds.height == 844、または既知の機種の高さ表

  • safeAreaInsets.left * 2

  • if isDuo、if isFolded、ヒンジ角でサイドバーや列数を決める

  • 書き固定した大きな frame、書き固定したサイドバー幅、書き固定したグリッド列数

  • 自前のトップバー、または別に作った UIToolbar / UINavigationBar / UITabBar

  • タイトルだけでアイコンがないツールバーボタン。自分で描いた「…」メニュー

  • 重要な内容が中央で折り目に乗っている。compact と展開で状態を一式ずつ保存している

  • .fill のメインビジュアルに焦点がなく、比率に合わせて fill / fit を選んでもいない

  • 文書に書いた例外の外で toolbarVerticalBehavior(.disabled) を使う、またはページの状態に合わせて行き来させる

Device Hub で、少なくとも一度は見た姿勢

姿勢 期待すること
閉じた縦 外側ディスプレイ。compact 幅 + regular 高さ。垂直バー
閉じた横 両方向 compact。垂直バー
開いた縦 内側ディスプレイ regular × regular。水平バー。上下のバーを保つ、よくある唯一の姿勢
開いた横 regular × regular。垂直バー
半折り 同じ一式の階層。精密な内容は折り目にまたがらない。スクロールする内容はページ全体の進路を変えない
内側ディスプレイの Split View。自分が左、自分が右 バーは自分の外側に付く。状態はまだある

各 sheet と popover は、これらの姿勢のなかで一度ずつ開く。ストアのスクリーンショットは、App Store Connect の Screenshot specifications に従う。よくある整理は、外側ディスプレイ 1398×2034、内側ディスプレイ 2007×2853 で、横向きは両辺を入れ替える。この数字の組をレイアウトへ書き込まない。資料に「2027 年 4 月から提出に Duo のスクリーンショットが必要」とあるなら、正確な日付は当時の Apple の告知に従う。

チェックポイントの答え

第0章。 内側ディスプレイを縦にしたときのシステムバーは上下にある。6つの姿勢でも UI は一式である。開いたあとも iPhone アプリのままである。内側ディスプレイが regular × regular になるだけである。

第1章。 900pt 幅の設定フォームは、文を端まで引き伸ばしている。ラベルとスイッチが空白で裂かれる。幅を制限し、余った空間を次の階層に残すべきである。グラデーションは中央に残してよい。支払うボタンは残してはいけない。リストのすべて選択はリストについていき、いちばん右のシステムバーにはついていかない。

第2章。 デプロイメントターゲットは iOS 18 のままでよい。27.1 SDK でコンパイルしたあと、垂直バーは現れる。周囲が空いているときはコンパイル SDK を替える。機種を検出して frame を引き伸ばさない。新しいシンボルは古いシステムに存在しない。だから #available の中に入れなければならない。

第3章。 regular 幅は Duo の内側ディスプレイで成り立つ。だから、もはや iPad と等しくない。内側ディスプレイは向きの宣言を無視する。広いか高いかは、コンテナの幅と高さを比べる。左右の inset は等しくないことがある。left * 2 はもう一方を誤って計算する。

第4章。 選択項目は、分割の外にある同じ一つの状態に置く。列数はコンテナ幅を項目の最小幅で割る。ここでは 2 に落ちる。ViewThatFits が答えるのは、どの配置が収まるかである。

第5章。 タイトルだけのボタンは垂直バーに入らない。分割では詳細列のバーだけが垂直になる。ナビゲーション型のページは、既定で先にツール項目をオーバーフローへ収め、タブバーを守る。別に作った UIToolbar はナビゲーションコンテナが提供するバーではない。システムはそれを立てない。

第6章。 内側ディスプレイでは、trailing の sheet のツールバーは垂直である。左右のセーフエリアは非対称であり、鏡写しにできない。自分で描いた全画面の覆いは、システムの sheet のように折り目を避けない。

第7章。 完全に平らにしたあと、division は有効にならず、幅は 0 である。スクロールできる文章は、段落ごとにずらさない。内側ディスプレイのカメラは普段 occlusion を占めず、使用中だけ占める。

第8章。 ZStack は overlay に対応づける。Arrangement はナビゲーションに包まれる。逆にしない。ScrollView に入れると、そのなかの一塊に手が届かなくなる。

第9章。 下書きは同じ一つの状態から戻る。別のビューツリーからではない。Split View の右側にある垂直バーは右側、つまりその App の外側にある。新しいウィンドウは外側ディスプレイでは作れない。

第10章。 サイドバーはヒンジ角を見ない。SwiftUI の nil は、このデバイスにヒンジがないことを表す。UIKit の nil は、インタラクションが更新を渡せる階層を離れたことを表す。半開きかどうかだけを知りたいなら、status を読む。

第11章。 撮影していないときに、ダッシュボードを外側ディスプレイへピン留めすることはできない。シャッターはメインの画面に残す。シミュレータにカメラはない。撮影アクセサリは実機が要る。

公式の入口

ベストプラクティス · 下部の入口は動かさず、幅は内容に渡す

読み終えると、次ができる。 この App を前の規則と突き合わせ、どれがすでに Apple のやり方で着地しているか、どれが製品の選択か、どれを検証済みだとまだ言えないかを分けられる。

この App に Duo 版はない。デプロイメントターゲットは iOS 18 のままである。27.1 SDK でコンパイルして初めて画面を埋め、システムバーが垂直になれる。開閉でルートを替えず、ページの model も替えない。余った regular 幅は、「今日」と「呼吸」の内部の左右の欄に使う。三つの第一階層の入口をサイドバーに変えるのではない。ヒンジ角、カメラの外側ディスプレイ、複数ウィンドウには製品としての理由がない。だから書かない。

対照

チュートリアルの判断 この App のやり方
第0章から第2章:UI は一式。スイッチは SDK デバイス名で分岐しない。新しい API は #available で包む。古いシステムには、同じ関係の HStack / VStack を残す
第3章から第4章:コンテナ幅で次の階層を見せる compact の殻は、さらに広くても一列である。regular は 800pt では依然として一欄、801pt から左右に並べる。最大の主列 480pt と、読みやすい詳細 720pt(合計 1201pt)が収まって初めて、より疎な一段階へ入る。1024pt でも並べたままである。文を端まで引き伸ばすのではない
第5章:システムバー。ただしサイドバーは必須ではない .sidebarAdaptable に defaultTabBarPlacement(.tabBar) を足し、三つの Tab は下部に残す
第6章:Sheet は位置を宣言する。自前の覆いは折り目を避けない システムの sheet は regular では意図に沿って trailing または center へ進む。compact の課金レイヤーは依然としてルート上の覆いであり、折り目を自動では避けない
第7章から第8章:対になった二つの内容だけを動かす 呼吸、課金の横並び、等幅の主副欄は ArrangementView の split を使う。スクロールするホームと詳細は、折り目のために進路を変えない
第9章から第11章:状態は連続する。ハードウェア API は任意 選択とルートはレイアウトの外にある。onHingeChange、撮影アクセサリ、複数ウィンドウは作らない

ルートのナビゲーションは、三つの入口を保つ

内側ディスプレイはサイドバーになれる。この製品はその選択をしない。「今日」、トレンド、呼吸は、依然として下部の Tab である。設定は「今日」からプッシュする。iPad の経路は defaultAdaptableTabBarPlacement(.tabBar) を使い、iOS 27 ではさらに defaultTabBarPlacement(.tabBar) を明示する。内側ディスプレイを開いたあと、余った空間はページの内側に現れる。今日は左がカードで右が詳細、呼吸は左がモードで右が練習である。

並べるには、二つのことが同時に成り立たなければならない。このページの殻が regular であり、今の水平 size class も regular である。そうすると、狭いほうのツリーが共有の選択項目を奪わない。殻が compact なら、コンテナがとても広くても、段階は一列のままである。

TabView(selection: adaptiveSectionSelection) {
    // 三个 Tab
}
.tabViewStyle(.sidebarAdaptable)
.defaultAdaptableTabBarPlacement(.tabBar)
// iOS 27:content.defaultTabBarPlacement(.tabBar)
adaptiveShellRole == .regular && horizontalSizeClass == .regular

Arrangement が記述するのは、すでにあった一つの関係だけ

ナビゲーションは外に残す。低いバージョンのレイアウトと 27.1 の Arrangement は同じ一つの関係であり、別のページではない。

場面 元の関係 27.1
呼吸の練習 メインビジュアルが上、コントロールが下 .split。高ければ上下、広ければ左右になり、折り目を避ける。軸は固定せず、ヒンジ角も見ない
課金ページの横向き メインビジュアルと購入が並ぶ .split。古いシステムでは依然として HStack
等幅の主副欄 二欄とも専用の空間が要る .split.axes(.horizontal)。比率は 0.5。背景は折り目をまたいでよい。タップできる内容は Arrangement の中にある

システムが行うこと、そしてまだ完了とは言えないこと

セーフエリアは四辺で読む。スクロールするリストと、詳細のなかの図は、半折りだからといって変位しない。システムの sheet は、compact では .automatic へ進み、regular では意図に沿って trailing または center を選ぶ。

明確にしないこと。ヒンジ角で呼吸やサイドバーを配置しない。カメラがないので、撮影アクセサリは登録しない。外側ディスプレイで新しいウィンドウは作らない。6つの姿勢のためにページ model を複製しない。製品が本当に開閉を入力にしたいとき、または撮影画面が現れたときだけ、あらためて考える。

まだ完了とは言えないこと。6つの姿勢は、まだ Device Hub で手を動かして見る必要がある。公開されているシミュレータのコマンドでは、開閉も半折りもできない。compact の課金の覆いはシステムの sheet ではない。閉じるボタンが一つだけの説明 sheet で、垂直バーを切るかどうかは未定である。共有要素の遷移に使う固定のトップバーは、まだ拡大縮小の層の外に残っている。コンパイルが通ることは、折り目と垂直バーを見たことにはならない。

新しいページのつなぎ方。まず既存の Tab かナビゲーションスタックに入れ、状態は外に残す。次にコンテナの段階で一欄か並べるかを決め、長い文章はおよそ 720pt で止める。二つの内容がもともと入れ替わるべきときだけ Arrangement を使い、#available(iOS 27.1, *) で古いレイアウトを残す。先にシステムの toolbar、size class、読みやすい幅を書く。予約領域と Arrangement はあとで書く。ヒンジと副画面は、製品が必要とするまで書き続けない。

この App で自分を測る

  1. 内側ディスプレイが広くなったあと、三つの Tab はサイドバーへ上がるべきか、下部に残すべきか。

  2. regular のコンテナが 800pt しかないとき、「今日」は一欄か、並べるか。

  3. 呼吸の練習の球体とコントロールは、なぜ onHingeChange ではなく .split を使うのか。

  4. compact の課金の覆いが半折りで折り目に乗っているとき、「システムの sheet は避ける」と言ってよいか。

答え。 三つの Tab は下部に残す。800pt は依然として一欄であり、801pt で初めて並べる。球体とコントロールは同じ一つの練習の上下の関係であり、split に渡す。角度はレイアウトを決めない。課金の覆いはシステムの sheet ではない。折り目を自動では避けない。

続きを読む

Stress HRV でこれらの考えを試す

App Store でダウンロード

これらのノートはウェルネス向けのプロダクト思考であり、医療助言ではありません。