iPhoneDuoTest · SwiftUI · iOS 27.1

iPhone Duo サンプルアプリ解説

iPhone Duo の画面サイズと、iOS 27.1 で増えた折りたたみ向けの API を 1 つずつ確かめるためのサンプルアプリです。5 つのタブそれぞれで別の機能を実装しています。このページでは、各タブが何を確かめるものか、シミュレータでどう動いたか、どんなコードで書いたかをまとめます。

Xcode
27.1 beta
SDK / Deployment
iOS 27.1
確認環境
iPhone Duo シミュレータ
00 · App

アプリの構成

5 つのタブに、それぞれ別の機能のサンプルが入っています。

  1. 1Hingeヒンジの角度をリアルタイムに表示する
  2. 2Layout画面サイズ、セーフエリア、サイズクラスを色分けして表示する
  3. 3ArrangementArrangementView で 2 つの View を重ねる/並べる(プレイヤー、歌詞表示)
  4. 4Regionsヒンジやカメラに隠される領域を画面上に描く
  5. 5Toolbar縦のバーに移るツールバー項目の扱いを見比べる
01 · Hinge

ヒンジの角度を表示する

概要

端末をどれだけ開いているかを、角度(度)で受け取れるかを確かめます。

動き

  • 画面中央に「Hinge Angle」の見出しと、角度を小数第 1 位まで大きく表示します。
  • シミュレータで開き具合を変えると値が変わり、途中の角度では 127.8°、平らに開くと 180.0° になりました。
  • 数字は等幅にしているので、値が変わっても表示位置がぶれません。

コード

onHingeChange は変化の前後の状態を受け取ります。newContext.hinge はヒンジのない端末では nil になるので、guard で抜けます。

@State private var foldingAngle: Double = 0

Text("\(foldingAngle, format: .number.precision(.fractionLength(1)))°")
    .font(.system(size: 96, weight: .bold, design: .rounded))
    .monospacedDigit()
    .onHingeChange { oldContext, newContext in
        guard let hinge = newContext.hinge else { return }
        foldingAngle = hinge.angle.degrees
    }

ヒンジには角度のほか、状態 hinge.status(.closed / .partiallyOpen / .fullyOpen)もあります。このサンプルでは角度だけを使っています。

全文を表示
import SwiftUI

struct HingeAngleView: View {
    @State private var foldingAngle: Double = 0

    var body: some View {
        VStack(spacing: 16) {
            Text("Hinge Angle")
                .font(.largeTitle)
                .bold()
                .foregroundStyle(.secondary)

            Text("\(foldingAngle, format: .number.precision(.fractionLength(1)))°")
                .font(.system(size: 96, weight: .bold, design: .rounded))
                .monospacedDigit()
        }
        .onHingeChange { oldContext, newContext in
            guard let hinge = newContext.hinge else { return }
            foldingAngle = hinge.angle.degrees
        }
    }
}

#Preview {
    HingeAngleView()
}
02 · Layout

画面サイズとセーフエリアを見る

概要

閉じた状態と開いた状態で、画面全体の大きさ、セーフエリア(コンテンツを置いてよい範囲)、サイズクラスがどう変わるかを確かめます。

動き

  • セーフエリアの外を赤、内側を薄い青と太い青枠で塗り分けます。
  • 左上に Window(赤)と Safe Area(青)のサイズのカードを出します。
  • セーフエリアの上下左右の辺の中央に、その辺の余白の大きさを出します。
  • 画面中央にサイズクラスを大きく出します。
開いた状態(横向き)
Safe Area 867 × 635 pt Window 951 × 669 pt · 2853 × 2007 px @3x → 84 ↓ 34 ヒンジ H regular V regular
右の 84pt はステータスバーとタブバーの列、下の 34pt はホームインジケータの分です。上と左の余白は 0 です。
閉じた状態
Safe Area 382 × 644 pt Window 466 × 678 pt 1398 × 2034 px @3x →84 ↓ 34 H compact V regular
余白の付き方は開いたときと同じで、右 84pt・下 34pt です。横方向のサイズクラスが compact に変わります。
項目閉じた状態開いた状態(横向き)
Window466 × 678 pt951 × 669 pt
Window(px)1398 × 2034 px2853 × 2007 px
Scale@3x@3x
Safe Area382 × 644 pt867 × 635 pt
余白(上 / 下 / 左 / 右)0 / 34 / 0 / 840 / 34 / 0 / 84
Size Class(横 / 縦)compact / regularregular / regular

コード

GeometryReader の proxy.size はセーフエリアの内側の大きさです。proxy.safeAreaInsets を足し戻すと画面全体の大きさになります。px は displayScale を掛けて求めています。

GeometryReader { proxy in
    let insets = proxy.safeAreaInsets
    // proxy.size はセーフエリアの内側。インセットを足し戻すと画面全体になる
    let fullSize = CGSize(
        width: proxy.size.width + insets.leading + insets.trailing,
        height: proxy.size.height + insets.top + insets.bottom
    )
    ...
}
.background(windowColor.opacity(0.25).ignoresSafeArea())

@Environment(\.displayScale) private var displayScale          // @3x
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
全文を表示
import SwiftUI

struct ScreenLayoutView: View {
    @Environment(\.displayScale) private var displayScale
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @Environment(\.verticalSizeClass) private var verticalSizeClass

    private let windowColor = Color.red
    private let safeAreaColor = Color.blue

    var body: some View {
        GeometryReader { proxy in
            let insets = proxy.safeAreaInsets
            let fullSize = CGSize(
                width: proxy.size.width + insets.leading + insets.trailing,
                height: proxy.size.height + insets.top + insets.bottom
            )

            ZStack {
                sizeClassLabel

                VStack(alignment: .leading, spacing: 16) {
                    sizeCard(
                        title: "Window",
                        size: fullSize,
                        detail: "\(format(fullSize * displayScale)) px  @\(format(displayScale))x",
                        color: windowColor
                    )
                    sizeCard(
                        title: "Safe Area",
                        size: proxy.size,
                        detail: nil,
                        color: safeAreaColor
                    )
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                .padding(24)

                insetLabel("↑ \(format(insets.top))")
                    .frame(maxHeight: .infinity, alignment: .top)
                insetLabel("↓ \(format(insets.bottom))")
                    .frame(maxHeight: .infinity, alignment: .bottom)
                insetLabel("← \(format(insets.leading))")
                    .frame(maxWidth: .infinity, alignment: .leading)
                insetLabel("→ \(format(insets.trailing))")
                    .frame(maxWidth: .infinity, alignment: .trailing)
            }
            .background {
                // 下地を不透明にしておかないと、セーフエリア外の塗りと混ざって境界が見えなくなる
                Rectangle()
                    .fill(Color(.systemBackground))
                    .overlay(safeAreaColor.opacity(0.12))
                    .overlay(Rectangle().strokeBorder(safeAreaColor, lineWidth: 6))
            }
        }
        .background(windowColor.opacity(0.25).ignoresSafeArea())
    }

    private var sizeClassLabel: some View {
        VStack(spacing: 4) {
            Text("Size Class")
                .font(.title3.bold())
                .foregroundStyle(.secondary)
            Grid(alignment: .leading, horizontalSpacing: 16) {
                GridRow {
                    Text("H")
                        .foregroundStyle(.secondary)
                    Text(describe(horizontalSizeClass))
                }
                GridRow {
                    Text("V")
                        .foregroundStyle(.secondary)
                    Text(describe(verticalSizeClass))
                }
            }
            .font(.system(size: 40, weight: .bold, design: .rounded))
            .minimumScaleFactor(0.5)
        }
        // 左右端のインセットラベルと重ならないよう、その分の幅を空けておく
        .padding(.horizontal, 64)
    }

    private func sizeCard(title: String, size: CGSize, detail: String?, color: Color) -> some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(title)
                .font(.headline)
            Text("\(format(size)) pt")
                .font(.system(size: 32, weight: .bold, design: .rounded))
            if let detail {
                Text(detail)
                    .font(.subheadline)
            }
        }
        .monospacedDigit()
        .foregroundStyle(color)
        .padding(12)
        .background(color.opacity(0.15), in: .rect(cornerRadius: 12))
    }

    private func insetLabel(_ text: String) -> some View {
        Text(text)
            .font(.headline)
            .monospacedDigit()
            .foregroundStyle(.white)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(safeAreaColor, in: .capsule)
            .padding(4)
    }

    private func format(_ value: CGFloat) -> String {
        Double(value).formatted(.number.precision(.fractionLength(0...1)))
    }

    private func format(_ size: CGSize) -> String {
        "\(format(size.width)) × \(format(size.height))"
    }

    private func describe(_ sizeClass: UserInterfaceSizeClass?) -> String {
        switch sizeClass {
        case .compact: "compact"
        case .regular: "regular"
        default: "unknown"
        }
    }
}

private extension CGSize {
    static func * (size: CGSize, scale: CGFloat) -> CGSize {
        CGSize(width: size.width * scale, height: size.height * scale)
    }
}

#Preview {
    ScreenLayoutView()
}
03 · Arrangement

ArrangementView で 2 つの View を配置する

概要

ArrangementView は、primary と secondary の 2 つの View を、画面の広さや端末の状態に合わせて配置するコンテナです。配置のしかたは .arrangementViewStyle で選びます。スタイルを指定しない場合(automatic)は split 扱いになります。

スタイル配置向いている用途
.overlayprimary を secondary の上に重ねる。端末を折るなど環境が変わると、横に並ぶ配置へ切り替わることがある動画の上に操作パネルを重ねるプレイヤー
.splitprimary と secondary を並べる。縦並びか横並びかは画面の広さとサイズクラスで決まる再生中の曲と歌詞を同時に見せる

動き

画面上部のセグメントで、次の 3 つのサンプルを切り替えます。

  • Basic (overlay): primary に「Primary View」(赤)、secondary に「Secondary View」(青)を置いた最小構成です。
  • Player (overlay): 公式ドキュメントの例です。グラデーションの動画面の下端に、半透明の操作パネル(シークバー、10 秒戻す、再生、10 秒送る)を重ねます。
  • Lyrics (split): 公式ドキュメントの例です。左にジャケット・曲名・再生ボタン、右に歌詞を並べます。
セーフエリアの外赤の点線 = ヒンジ(半開きでは帯 = ヒンジの領域)

Basic (overlay)

閉じた状態
secondary(背面)primaryPrimary View
半開き
secondarySecondary ViewprimaryPrimary View
平らに開いた状態
secondary(背面)primaryPrimary View

閉じた状態と平らに開いた状態では、primary が secondary の上に重なります。半開きにすると左右に分かれ、primary が右、secondary が左に来ます。境目はヒンジの領域(x 455.5〜495.5pt)で、どちらの View もヒンジをまたぎません。

Player (overlay)

閉じた状態
secondaryprimary
半開き
secondaryprimary
平らに開いた状態
secondaryprimary

閉じた状態と平らに開いた状態では、動画(secondary)の下端に操作パネル(primary)が重なります。半開きにすると動画が左の画面いっぱいに移り、操作パネルは右の画面の下端に置かれます。

Lyrics (split)

閉じた状態
♪Sample SongSample Artist◀◀ ❚❚ ▶▶primaryMorning light taps on thewindowWalking through a sleepytownA view that opens pastthe hingeOne screen turning intotwoOpen, close, and openagainThings unseen come intoviewWords on the left, melodyon the rightStill playing somewheretodaysecondary
半開き
♪Sample SongSample Artist◀◀ ❚❚ ▶▶primaryMorning light taps onthe windowWalking through a sleepytownA view that opens pastthe hingeOne screen turning intotwoOpen, close, and openagainThings unseen come intoviewWords on the left,melody on the rightStill playing somewheretodaysecondary
平らに開いた状態
♪Sample SongSample Artist◀◀ ❚❚ ▶▶primaryMorning light taps on thewindowWalking through a sleepytownA view that opens past thehingeOne screen turning into twoOpen, close, and open againThings unseen come into viewWords on the left, melody onthe rightStill playing somewheretodaysecondary

閉じた状態では上下に分かれ、primary が上に来ます。半開きにするとヒンジの領域を避けて左右に分かれます(primary が左 0〜455.5pt、secondary が右 495.5〜867pt)。平らに開くとヒンジに関係なくセーフエリアを半分に分け、境目は 433.5pt になります。

コード

// Basic: スライドと同じ最小構成
ArrangementView {
    LabeledPane(title: "Primary View", color: .red)
} secondary: {
    LabeledPane(title: "Secondary View", color: .blue)
}
.arrangementViewStyle(.overlay)

// Player: 公式ドキュメントの overlay の例
ArrangementView {
    PlayerControls()
} secondary: {
    VideoSurface()
}
.arrangementViewStyle(.overlay)

// Lyrics: 公式ドキュメントの split の例
ArrangementView {
    NowPlayingView()
} secondary: {
    LyricsView()
}
.arrangementViewStyle(.split)

関連する modifier として、子の View に付けて「横並びに切り替わるときにどちらの辺へ寄せるか」を指定する .overlayArrangementEdge(.top / .bottom) や、分割の比率を指定する .splitArrangementLayoutRatio(_:) もあります。このサンプルでは使っていません。

全文を表示
import SwiftUI

struct ArrangementSampleView: View {
    enum Sample: String, CaseIterable, Identifiable {
        case basic = "Basic (overlay)"
        case mediaPlayer = "Player (overlay)"
        case lyrics = "Lyrics (split)"

        var id: Self { self }
    }

    @State private var sample: Sample = .basic

    var body: some View {
        Group {
            switch sample {
            case .basic:
                ArrangementView {
                    LabeledPane(title: "Primary View", color: .red)
                } secondary: {
                    LabeledPane(title: "Secondary View", color: .blue)
                }
                .arrangementViewStyle(.overlay)
            case .mediaPlayer:
                ArrangementView {
                    PlayerControls()
                } secondary: {
                    VideoSurface()
                }
                .arrangementViewStyle(.overlay)
            case .lyrics:
                ArrangementView {
                    NowPlayingView()
                } secondary: {
                    LyricsView()
                }
                .arrangementViewStyle(.split)
            }
        }
        .safeAreaInset(edge: .top) {
            Picker("Sample", selection: $sample) {
                ForEach(Sample.allCases) { sample in
                    Text(sample.rawValue).tag(sample)
                }
            }
            .pickerStyle(.segmented)
            .fixedSize()
            .padding(8)
        }
    }
}

private struct LabeledPane: View {
    let title: String
    let color: Color

    var body: some View {
        Text(title)
            .font(.title2.bold())
            .foregroundStyle(.white)
            .padding(.horizontal, 12)
            .padding(.vertical, 8)
            .background(color)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(color.opacity(0.1), ignoresSafeAreaEdges: [])
    }
}

private struct VideoSurface: View {
    var body: some View {
        LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing)
            .overlay {
                Image(systemName: "film")
                    .font(.system(size: 64))
                    .foregroundStyle(.white.opacity(0.4))
            }
    }
}

private struct PlayerControls: View {
    @State private var isPlaying = false
    @State private var progress = 0.3

    var body: some View {
        VStack(spacing: 12) {
            Text("Sample Movie")
                .font(.headline)
            Slider(value: $progress)
            HStack(spacing: 40) {
                Button("Back", systemImage: "gobackward.10") {
                    progress = max(progress - 0.1, 0)
                }
                Button(isPlaying ? "Pause" : "Play", systemImage: isPlaying ? "pause.fill" : "play.fill") {
                    isPlaying.toggle()
                }
                .font(.largeTitle)
                Button("Forward", systemImage: "goforward.10") {
                    progress = min(progress + 0.1, 1)
                }
            }
            .labelStyle(.iconOnly)
            .font(.title2)
        }
        .foregroundStyle(.white)
        .tint(.white)
        .padding()
        .background(.black.opacity(0.5), in: .rect(cornerRadius: 16))
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    }
}

private struct NowPlayingView: View {
    @State private var isPlaying = true

    var body: some View {
        VStack(spacing: 16) {
            RoundedRectangle(cornerRadius: 16)
                .fill(LinearGradient(colors: [.pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing))
                .aspectRatio(1, contentMode: .fit)
                .frame(maxWidth: 240)
                .overlay {
                    Image(systemName: "music.note")
                        .font(.system(size: 64))
                        .foregroundStyle(.white)
                }
            VStack(spacing: 4) {
                Text("Sample Song")
                    .font(.title2.bold())
                Text("Sample Artist")
                    .foregroundStyle(.secondary)
            }
            HStack(spacing: 40) {
                Button("Previous", systemImage: "backward.fill") {}
                Button(isPlaying ? "Pause" : "Play", systemImage: isPlaying ? "pause.fill" : "play.fill") {
                    isPlaying.toggle()
                }
                .font(.largeTitle)
                Button("Next", systemImage: "forward.fill") {}
            }
            .labelStyle(.iconOnly)
            .font(.title2)
            .tint(.primary)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

private struct LyricsView: View {
    private let lines = [
        "Morning light taps on the window",
        "Walking through a sleepy town",
        "A view that opens past the hinge",
        "One screen turning into two",
        "Open, close, and open again",
        "Things unseen come into view",
        "Words on the left, melody on the right",
        "Still playing somewhere today",
    ]
    private let currentIndex = 2

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                ForEach(lines.indices, id: \.self) { index in
                    Text(lines[index])
                        .font(.title3.bold())
                        .foregroundStyle(index == currentIndex ? .primary : .tertiary)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding()
        }
        .background(Color(.secondarySystemBackground), ignoresSafeAreaEdges: [])
    }
}

#Preview {
    ArrangementSampleView()
}
04 · Regions

予約領域(Reserved Regions)を描く

概要

ヒンジの折り目やカメラなど、ハードウェアに隠される画面上の領域を取得します。取得できる種類は 2 つです。

kind意味
.divisionコンテンツを 2 つに分けるべき領域。ヒンジの折り目
.occlusion何かに遮られる領域。Dynamic Island、カメラ、ウィンドウの操作ボタンなどの総称で、どれにあたるかは API からは分からない

各領域は位置とサイズ(frame)、余白(margins)、有効かどうか(isActive)を持ちます。isActive は読み取り専用で、アプリから切り替える API はありません。.includeInactive を付けると、今は有効でない領域も取得できます。

動き

  • 取得した領域を実際の位置に重ねて描きます。ヒンジは赤、occlusion は取得順に青・緑・オレンジ・紫です。
  • 有効な領域は半透明で塗り、無効な領域は点線の枠だけにします。
  • 中央のカードに、各領域の種類、active / inactive、位置とサイズを一覧で出します。
開いた状態(横向き)
division occlusion 1 occlusion 2
平らなのでヒンジは inactive です。active な occlusion 2 は時間と Wi-Fi の列で、カメラではありません。
閉じた状態
occlusion 1 occlusion 2
occlusion 1 は画面右上の前面カメラ(パンチホール)に重なります。occlusion 2 はカメラと時間・Wi-Fi をまとめて囲む列です。
divisionocclusion 1occlusion 2塗り = active / 点線 = inactive
状態領域activeframe(x, y, 幅 × 高さ)画面上の実体
開いたdivisioninactive455.5, 0, 40 × 669ヒンジの折り目
開いたocclusion 1inactive677.3, 21, 58 × 37画面上に対応するものは見当たらない
開いたocclusion 2active867, 0, 84 × 120時間と Wi-Fi の列
閉じたocclusion 1active399.7, 29.3, 37 × 37前面カメラ
閉じたocclusion 2active382, 0, 84 × 170カメラと時間・Wi-Fi の列

occlusion の列の幅 84pt は、タブ 2 で見た右側のセーフエリアの余白 84pt と一致します。ステータスバーとタブバーが、この列に寄せて置かれていると考えられます(数値の一致からの推測です)。

コード

領域の frame は画面全体を基準にした座標で返るので、GeometryReader に .ignoresSafeArea() を付けて同じ座標で描きます。

GeometryReader { proxy in
    let divisions = proxy.reservedRegions(kind: .division, options: .includeInactive)
    let occlusions = proxy.reservedRegions(kind: .occlusion, options: .includeInactive)

    ZStack {
        ForEach(entries) { entry in
            regionShape(entry)   // active は塗り、inactive は点線
                .frame(width: entry.region.frame.width, height: entry.region.frame.height)
                .position(x: entry.region.frame.midX, y: entry.region.frame.midY)
        }
        regionList(entries)
    }
}
// 領域の frame を画面全体の座標で扱うため
.ignoresSafeArea()
全文を表示
import SwiftUI

struct ReservedRegionsView: View {
    private struct Entry: Identifiable {
        let region: ReservedRegion
        let name: String
        let color: Color

        var id: ReservedRegion.ID { region.id }
    }

    private static let occlusionColors: [Color] = [.blue, .green, .orange, .purple]

    var body: some View {
        GeometryReader { proxy in
            let regions = entries(
                divisions: proxy.reservedRegions(kind: .division, options: .includeInactive),
                occlusions: proxy.reservedRegions(kind: .occlusion, options: .includeInactive)
            )

            ZStack {
                ForEach(regions) { entry in
                    regionShape(entry)
                        .frame(width: entry.region.frame.width, height: entry.region.frame.height)
                        .position(x: entry.region.frame.midX, y: entry.region.frame.midY)
                }

                regionList(regions)
            }
        }
        // occlusion はカメラやステータス表示など画面端にあるため、座標系を画面全体に合わせる
        .ignoresSafeArea()
    }

    private func entries(divisions: [ReservedRegion], occlusions: [ReservedRegion]) -> [Entry] {
        let hinges = divisions.map { Entry(region: $0, name: "division (hinge)", color: .red) }
        let occluded = occlusions.enumerated().map { index, region in
            Entry(
                region: region,
                name: "occlusion \(index + 1)",
                color: Self.occlusionColors[index % Self.occlusionColors.count]
            )
        }
        return hinges + occluded
    }

    @ViewBuilder
    private func regionShape(_ entry: Entry) -> some View {
        if entry.region.isActive {
            Rectangle()
                .fill(entry.color.opacity(0.6))
        } else {
            Rectangle()
                .strokeBorder(entry.color, style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
        }
    }

    private func regionList(_ regions: [Entry]) -> some View {
        VStack(alignment: .leading, spacing: 20) {
            Text("Reserved Regions")
                .font(.largeTitle.bold())
            if regions.isEmpty {
                Text("None")
                    .font(.title3)
                    .foregroundStyle(.secondary)
            }
            ForEach(regions) { entry in
                VStack(alignment: .leading, spacing: 4) {
                    HStack {
                        Circle()
                            .fill(entry.color)
                            .frame(width: 16, height: 16)
                        Text(entry.name)
                            .font(.title2.bold())
                        Text(entry.region.isActive ? "active" : "inactive")
                            .font(.headline)
                            .foregroundStyle(entry.region.isActive ? .primary : .secondary)
                    }
                    Text(describe(entry.region.frame))
                        .font(.body)
                        .monospacedDigit()
                        .foregroundStyle(.secondary)
                }
            }
            legend
        }
        .minimumScaleFactor(0.7)
        .padding(24)
        .background(.regularMaterial, in: .rect(cornerRadius: 20))
        .padding()
    }

    private var legend: some View {
        HStack(spacing: 20) {
            Label("active", systemImage: "square.fill")
            Label("inactive", systemImage: "square.dashed")
        }
        .font(.subheadline)
        .foregroundStyle(.secondary)
    }

    private func describe(_ frame: CGRect) -> String {
        func format(_ value: CGFloat) -> String {
            Double(value).formatted(.number.precision(.fractionLength(0...1)))
        }
        return "x: \(format(frame.minX)), y: \(format(frame.minY)), \(format(frame.width)) × \(format(frame.height))"
    }
}

#Preview {
    ReservedRegionsView()
}
05 · Toolbar

縦のバーでのツールバー項目を比べる

概要

iPhone Duo を横向きに開くと、タブバーが右端に縦に並びます。ツールバーも縦のバーに移ることがあり、項目ごとの扱いを細かく指定できます。同じ見た目のボタンを設定違いで並べて、違いを見比べます。

API指定できること
.axisBehavior(_:)項目を置けるバーの向き。.automatic(おまかせ)、.horizontalOnly(横のバーだけ)、.verticalPreferred(縦のバーを優先)
.visibilityPriority(_:)バーに入りきらないとき、どの項目を先にあふれメニューへ送るか。.high / .low
placement: .topBarPinnedTrailing縦のバーでも端に固定しておく項目
ToolbarOverflowMenuあふれメニューに自分で項目を足す
.toolbarVerticalBehavior(_:)縦のバーへの移動を許すか(.automatic / .disabled)
.toolbarVerticalCompressionBehavior(_:)場所が足りないとき、ツールバーとタブバーのどちらを優先して残すか
\.toolbarVerticalEdge縦のバーが左右どちらの端にあるか(Environment の値)

動き

画面には、Status(縦のバーの位置と最後にタップしたボタン)、Settings(縦のバーへの移動のオン/オフと Compression の切り替え)、Items(各ボタンの設定の一覧)を並べています。ツールバーの項目がどこに置かれたかを、シミュレータのスクリーンショットをもとに図にしました。

A = .axisBehavior(.automatic)↔ = .horizontalOnly↕ = .verticalPreferredShare = .visibilityPriority(.high)Delete(ごみ箱)= .visibilityPriority(.low)✓ = .topBarPinnedTrailing··· = あふれメニュー四角のアイコンの列 = タブバー(青が選択中のタブ)

閉じた状態での比較(Compression: automatic)

Vertical toolbar オン
Toolbar↔21:24
Vertical toolbar オフ
A21:24

オン: ツールバーは右端の縦のバーに移り、Vertical edge は trailing になります。.horizontalOnly の ↔ は縦のバーに入らず、上部に単独のボタンとして残ります。.topBarPinnedTrailing の Done(✓)は、縦のバーの先頭に独立して固定されます。縦のバーのグループに入るのは Share(.high)と ··· だけで、A、↕、Delete(.low)はあふれメニューに回りました。開いた状態では、これに A(.automatic)も加わります。
オフ(.toolbarVerticalBehavior(.disabled)): ツールバーは上部の横のバーに戻り、Vertical edge は none になります。タブバーも縦のバーをやめて、画面下の横並びに移りました。横のバーに並ぶのは A、Share、···、Done の 4 つで、↔ も含めた残りはあふれメニューに回りました。開いた状態では 7 項目すべてが並びます。

Compression の違い(閉じた状態、.toolbarVerticalCompressionBehavior)

automatic
Toolbar↔21:24
prefersToolbarItems
Toolbar↔21:24A↕
prefersToolbarItems(長押し)
Toolbar↔21:24A↕HingeLayoutArrangementRegionsToolbar
prefersTabBar
Toolbar↔21:24

.prefersToolbarItems では、タブバーが今のタブのボタン 1 つにたたまれ、空いた場所に A、↕、Delete も縦のバーへ入ります(7 項目のうち ↔ 以外がすべて表示される)。たたまれたボタンを長押しすると、ラベル付きのタブの一覧が縦のバーの上に拡大表示されます。.automatic と .prefersTabBar は同じ配置で、タブバーを残してツールバーの項目をあふれメニューに回しました。

コード

.toolbar {
    ToolbarItem(placement: .primaryAction) { button("Automatic", systemImage: "a.circle") }
        .axisBehavior(.automatic)
    ToolbarItem(placement: .primaryAction) { button("Horizontal Only", systemImage: "arrow.left.and.right") }
        .axisBehavior(.horizontalOnly)
    ToolbarItem(placement: .primaryAction) { button("Vertical Preferred", systemImage: "arrow.up.and.down") }
        .axisBehavior(.verticalPreferred)
    ToolbarItem(placement: .primaryAction) { button("Share", systemImage: "square.and.arrow.up") }
        .visibilityPriority(.high)
    ToolbarItem(placement: .primaryAction) { button("Delete", systemImage: "trash") }
        .visibilityPriority(.low)
    ToolbarItem(placement: .topBarPinnedTrailing) { button("Done", systemImage: "checkmark") }
    ToolbarOverflowMenu { button("Export", systemImage: "square.and.arrow.down") }
}
.toolbarVerticalBehavior(isVerticalEnabled ? .automatic : .disabled)
.toolbarVerticalCompressionBehavior(compression)

// 縦のバーが左右どちらにあるか(NavigationStack 配下の子 View で読む)
@Environment(\.toolbarVerticalEdge) private var edge
全文を表示
import SwiftUI

struct ToolbarSampleView: View {
    @State private var isVerticalEnabled = true
    @State private var compression: ToolbarVerticalCompressionBehavior = .automatic
    @State private var lastTapped = "-"

    var body: some View {
        NavigationStack {
            List {
                Section("Status") {
                    VerticalEdgeRow()
                    LabeledContent("Last tapped", value: lastTapped)
                }

                Section("Settings") {
                    Toggle("Vertical toolbar", isOn: $isVerticalEnabled)
                    Picker("Compression", selection: $compression) {
                        Text("automatic").tag(ToolbarVerticalCompressionBehavior.automatic)
                        Text("prefersToolbarItems").tag(ToolbarVerticalCompressionBehavior.prefersToolbarItems)
                        Text("prefersTabBar").tag(ToolbarVerticalCompressionBehavior.prefersTabBar)
                    }
                }

                Section("Items") {
                    itemRow("a.circle", "axisBehavior(.automatic)")
                    itemRow("arrow.left.and.right", "axisBehavior(.horizontalOnly)")
                    itemRow("arrow.up.and.down", "axisBehavior(.verticalPreferred)")
                    itemRow("square.and.arrow.up", "visibilityPriority(.high)")
                    itemRow("trash", "visibilityPriority(.low)")
                    itemRow("checkmark", "placement: .topBarPinnedTrailing")
                    itemRow("ellipsis", "ToolbarOverflowMenu")
                }
            }
            .navigationTitle("Toolbar")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    button("Automatic", systemImage: "a.circle")
                }
                .axisBehavior(.automatic)

                ToolbarItem(placement: .primaryAction) {
                    button("Horizontal Only", systemImage: "arrow.left.and.right")
                }
                .axisBehavior(.horizontalOnly)

                ToolbarItem(placement: .primaryAction) {
                    button("Vertical Preferred", systemImage: "arrow.up.and.down")
                }
                .axisBehavior(.verticalPreferred)

                ToolbarItem(placement: .primaryAction) {
                    button("Share", systemImage: "square.and.arrow.up")
                }
                .visibilityPriority(.high)

                ToolbarItem(placement: .primaryAction) {
                    button("Delete", systemImage: "trash")
                }
                .visibilityPriority(.low)

                ToolbarItem(placement: .topBarPinnedTrailing) {
                    button("Done", systemImage: "checkmark")
                }

                ToolbarOverflowMenu {
                    button("Export", systemImage: "square.and.arrow.down")
                }
            }
            .toolbarVerticalBehavior(isVerticalEnabled ? .automatic : .disabled)
            .toolbarVerticalCompressionBehavior(compression)
        }
    }

    private func button(_ title: String, systemImage: String) -> some View {
        Button(title, systemImage: systemImage) {
            lastTapped = title
        }
    }

    private func itemRow(_ systemImage: String, _ description: String) -> some View {
        Label {
            Text(description)
                .font(.callout.monospaced())
        } icon: {
            Image(systemName: systemImage)
        }
    }
}

// toolbarVerticalEdge は NavigationStack 配下で解決されるため、子 View で読む
private struct VerticalEdgeRow: View {
    @Environment(\.toolbarVerticalEdge) private var edge

    var body: some View {
        LabeledContent("Vertical edge", value: description)
    }

    private var description: String {
        switch edge {
        case .leading: "leading"
        case .trailing: "trailing"
        case nil: "none (horizontal)"
        }
    }
}

#Preview {
    ToolbarSampleView()
}

注意点

  • シミュレータにはカメラがありません。カメラの一覧を取得すると空になります。カメラを起動したときに予約領域の active が変わるか、撮影中に外側の画面へ内容を出す CameraCaptureAccessory が動くかは、実機でしか確かめられません。
  • 予約領域の取得結果は環境によって違うことがあります。外部の検証記事では Xcode 27.1 beta のシミュレータで 0 件と報告されていますが、今回の環境では上の表のとおり取得できました。