iPhone Duo の画面サイズと、iOS 27.1 で増えた折りたたみ向けの API を 1 つずつ確かめるためのサンプルアプリです。5 つのタブそれぞれで別の機能を実装しています。このページでは、各タブが何を確かめるものか、シミュレータでどう動いたか、どんなコードで書いたかをまとめます。
5 つのタブに、それぞれ別の機能のサンプルが入っています。
端末をどれだけ開いているかを、角度(度)で受け取れるかを確かめます。
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()
}閉じた状態と開いた状態で、画面全体の大きさ、セーフエリア(コンテンツを置いてよい範囲)、サイズクラスがどう変わるかを確かめます。
| 項目 | 閉じた状態 | 開いた状態(横向き) |
|---|---|---|
| Window | 466 × 678 pt | 951 × 669 pt |
| Window(px) | 1398 × 2034 px | 2853 × 2007 px |
| Scale | @3x | @3x |
| Safe Area | 382 × 644 pt | 867 × 635 pt |
| 余白(上 / 下 / 左 / 右) | 0 / 34 / 0 / 84 | 0 / 34 / 0 / 84 |
| Size Class(横 / 縦) | compact / regular | regular / 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 verticalSizeClassimport 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()
}ArrangementView は、primary と secondary の 2 つの View を、画面の広さや端末の状態に合わせて配置するコンテナです。配置のしかたは .arrangementViewStyle で選びます。スタイルを指定しない場合(automatic)は split 扱いになります。
画面上部のセグメントで、次の 3 つのサンプルを切り替えます。
閉じた状態と平らに開いた状態では、primary が secondary の上に重なります。半開きにすると左右に分かれ、primary が右、secondary が左に来ます。境目はヒンジの領域(x 455.5〜495.5pt)で、どちらの View もヒンジをまたぎません。
閉じた状態と平らに開いた状態では、動画(secondary)の下端に操作パネル(primary)が重なります。半開きにすると動画が左の画面いっぱいに移り、操作パネルは右の画面の下端に置かれます。
閉じた状態では上下に分かれ、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()
}ヒンジの折り目やカメラなど、ハードウェアに隠される画面上の領域を取得します。取得できる種類は 2 つです。
| kind | 意味 |
|---|---|
.division | コンテンツを 2 つに分けるべき領域。ヒンジの折り目 |
.occlusion | 何かに遮られる領域。Dynamic Island、カメラ、ウィンドウの操作ボタンなどの総称で、どれにあたるかは API からは分からない |
各領域は位置とサイズ(frame)、余白(margins)、有効かどうか(isActive)を持ちます。isActive は読み取り専用で、アプリから切り替える API はありません。.includeInactive を付けると、今は有効でない領域も取得できます。
| 状態 | 領域 | active | frame(x, y, 幅 × 高さ) | 画面上の実体 |
|---|---|---|---|---|
| 開いた | division | inactive | 455.5, 0, 40 × 669 | ヒンジの折り目 |
| 開いた | occlusion 1 | inactive | 677.3, 21, 58 × 37 | 画面上に対応するものは見当たらない |
| 開いた | occlusion 2 | active | 867, 0, 84 × 120 | 時間と Wi-Fi の列 |
| 閉じた | occlusion 1 | active | 399.7, 29.3, 37 × 37 | 前面カメラ |
| 閉じた | occlusion 2 | active | 382, 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()
}iPhone Duo を横向きに開くと、タブバーが右端に縦に並びます。ツールバーも縦のバーに移ることがあり、項目ごとの扱いを細かく指定できます。同じ見た目のボタンを設定違いで並べて、違いを見比べます。
| API | 指定できること |
|---|---|
.axisBehavior(_:) | 項目を置けるバーの向き。.automatic(おまかせ)、.horizontalOnly(横のバーだけ)、.verticalPreferred(縦のバーを優先) |
.visibilityPriority(_:) | バーに入りきらないとき、どの項目を先にあふれメニューへ送るか。.high / .low |
placement: .topBarPinnedTrailing | 縦のバーでも端に固定しておく項目 |
ToolbarOverflowMenu | あふれメニューに自分で項目を足す |
.toolbarVerticalBehavior(_:) | 縦のバーへの移動を許すか(.automatic / .disabled) |
.toolbarVerticalCompressionBehavior(_:) | 場所が足りないとき、ツールバーとタブバーのどちらを優先して残すか |
\.toolbarVerticalEdge | 縦のバーが左右どちらの端にあるか(Environment の値) |
画面には、Status(縦のバーの位置と最後にタップしたボタン)、Settings(縦のバーへの移動のオン/オフと Compression の切り替え)、Items(各ボタンの設定の一覧)を並べています。ツールバーの項目がどこに置かれたかを、シミュレータのスクリーンショットをもとに図にしました。
.axisBehavior(.automatic)↔ = .horizontalOnly↕ = .verticalPreferredShare = .visibilityPriority(.high)Delete(ごみ箱)= .visibilityPriority(.low)✓ = .topBarPinnedTrailing··· = あふれメニュー四角のアイコンの列 = タブバー(青が選択中のタブ)オン: ツールバーは右端の縦のバーに移り、Vertical edge は trailing になります。.horizontalOnly の ↔ は縦のバーに入らず、上部に単独のボタンとして残ります。.topBarPinnedTrailing の Done(✓)は、縦のバーの先頭に独立して固定されます。縦のバーのグループに入るのは Share(.high)と ··· だけで、A、↕、Delete(.low)はあふれメニューに回りました。開いた状態では、これに A(.automatic)も加わります。
オフ(.toolbarVerticalBehavior(.disabled)): ツールバーは上部の横のバーに戻り、Vertical edge は none になります。タブバーも縦のバーをやめて、画面下の横並びに移りました。横のバーに並ぶのは A、Share、···、Done の 4 つで、↔ も含めた残りはあふれメニューに回りました。開いた状態では 7 項目すべてが並びます。
.toolbarVerticalCompressionBehavior).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 edgeimport 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()
}CameraCaptureAccessory が動くかは、実機でしか確かめられません。