SwiftUI =「状態がUIを決める」
この原則さえ掴めば、最短ルートで実践投入いける(はず)。
本記事では、初心者〜中級者がつまずきやすいポイントを一気に整理。コードはそのまま Xcode に貼って動かせます。
1. はじめに
対象読者: SwiftUI をこれから学ぶ/学び直す iOS 開発者(UIKit 経験の有無は不問)
前提環境: Xcode 15 以降 / Swift 5.9+ / iOS 17+(#Preview, Observation, NavigationStack を使用)
ゴール: この記事のサンプルを理解・改変できるようになること。
本記事は最短学習のため、広く使う API に絞って説明します。より深掘りは最後の参考リンクへ。
2. SwiftUIの考え方を最短理解
- 宣言的UI: 「どう描くか」ではなく「何を描くか」を書く。View は入力(状態)→ 出力(見た目)の関数のように振る舞う。
- Viewは値型(struct):
body
は必要に応じて再計算され、差分だけが描画される。 - 単一ソースオブトゥルース: 状態の持ち場所を1か所に決め、そこから下流へ流す。二重管理は不具合の元。
import SwiftUI
struct ContentView: View {
@State private var count = 0 // 単一ソースオブトゥルース(この画面のローカル状態)
var body: some View {
VStack(spacing: 16) {
Text("Count: \(count)").font(.largeTitle)
HStack {
Button("-1") { count -= 1 }
Button("+1") { count += 1 }
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
#Preview {
ContentView()
}

3. まずはこれだけ:基本コンポーネント
コンポーネント | 役割・用途 | 特徴・備考 |
---|---|---|
Text | 文字列を表示 | .font() , .bold() などで装飾可能 |
Label | アイコンと文字を組み合わせた表示 | systemImage: で SF Symbols 使用 |
Image | 画像やアイコンの表示 | .font() と組み合わせてアイコン調整可能 |
TextField | テキスト入力欄(通常) | .textFieldStyle() で外観カスタマイズ可能 |
SecureField | パスワード入力欄(非表示) | TextField とほぼ同じAPI |
Toggle | ON/OFFスイッチ | バインディングされた状態(isOn: )で状態を制御 |
Slider | スライダーによる数値入力 | value: で Double バインディング |
Stepper | 「+」「−」ボタンで値を増減 | in: で最小・最大値を指定可能 |
HStack | 横並びレイアウト | 子Viewを水平に配置 |
VStack | 縦並びレイアウト(この例のメインコンテナ) | alignment や spacing 指定可能 |
.padding() | 外側の余白を設定 | 全体に適用することで見た目を整える |
import SwiftUI
struct ContentView: View {
@State private var name = ""
@State private var password = ""
@State private var isNotificationOn = true
@State private var sliderValue = 0.5
@State private var quantity = 1
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("タイトル").font(.title).bold()
Label("星付き", systemImage: "star.fill").foregroundStyle(.yellow)
Image(systemName: "bolt.fill").font(.system(size: 40)).foregroundStyle(.mint)
HStack {
TextField("名前", text: $name)
.textFieldStyle(.roundedBorder)
SecureField("パスワード", text: $password)
.textFieldStyle(.roundedBorder)
}
Toggle("通知をオン", isOn: $isNotificationOn)
Slider(value: $sliderValue)
Stepper("数量 1", value: $quantity, in: 1...10)
}
.padding()
}
}
#Preview {
ContentView()
}

List
/ ScrollView
/ ForEach
/ Section
は「縦に並べる」基本。
import SwiftUI
struct ContentView: View {
@State private var fruits = ["🍎 りんご", "🍌 バナナ", "🍇 ぶどう"]
@State private var vegetables = ["🥕 にんじん", "🥦 ブロッコリー"]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("縦に並べるUIの基本").font(.title).bold()
Text("List + Section + ForEach:")
List {
Section("フルーツ") {
ForEach(fruits, id: \.self) { item in
Text(item)
}
}
Section("野菜") {
ForEach(vegetables, id: \.self) { item in
Text(item)
}
}
}
.frame(height: 200)
Text("ScrollView + ForEach:")
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(fruits + vegetables, id: \.self) { item in
Text(item)
.padding(.vertical, 4)
.padding(.horizontal)
.background(.mint.opacity(0.2))
.cornerRadius(8)
}
}
.padding(.horizontal)
}
.frame(height: 200)
}
.padding()
}
}
#Preview {
ContentView()
}
4. レイアウトの基礎を一気に押さえる
- VStack/HStack/ZStack/Spacer を使い分ける。まずはこれだけで8割いける。
- 修飾子順序が効く(後から書いたものが外側/後段で適用されやすい)。
HStack(spacing: 8) {
Image(systemName: "person.circle.fill")
.font(.system(size: 40))
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text("Arthur Morgan").font(.headline)
Text("iOS Developer").font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
Button("Follow") {}
.buttonStyle(.borderedProminent)
}
.padding() // この padding は HStack 全体に効く
// サイズ・余白・配置の代表的Recipe:
Text("Hello")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
// GeometryReader は最小限で。親のサイズに応じたレイアウトが必要なときのみ使う:
GeometryReader { geo in
Rectangle()
.fill(.blue.gradient)
.frame(width: geo.size.width * 0.4)
}
.ignoresSafeArea(edges: .bottom) // Safe Area を跨ぐ時
// Grid (グリッド)
let col = [GridItem(.flexible()), GridItem(.flexible())]
LazyVGrid(columns: col, spacing: 12) {
ForEach(0..<6) { i in
RoundedRectangle(cornerRadius: 16).fill(.teal).frame(height: 80)
.overlay(Text("#\(i)").bold().foregroundStyle(.white))
}
}
.padding()
5. 状態管理とデータフロー(コアの概念)
5.1 @State と @Binding
@State
は View 内の局所状態。- 子へ渡すときは Binding(双方向)にすることでソースを増やさない。
struct Parent: View {
@State private var name = "Arthur"
var body: some View { Editor(name: $name) }
}
struct Editor: View {
@Binding var name: String
var body: some View { TextField("名前", text: $name) }
}
5.2 Observation(iOS 17+):@Observable
/ @Bindable
アプリ全体で共有したいモデルを @Observable
で宣言。下流の View からは @Bindable
で安全に編集できる。
import SwiftUI
import Observation
@Observable
final class TodoStore {
struct Todo: Identifiable, Hashable {
let id = UUID()
var title: String
var isDone = false
}
var items: [Todo] = [
.init(title: "買い物"),
.init(title: "散歩")
]
func add(_ title: String) {
items.append(.init(title: title))
}
}
// Environment 経由で注入(iOS 17+ の型ベース Environment):
import SwiftUI
@main
struct MyApp: App {
private let store = TodoStore()
var body: some Scene {
WindowGroup {
ContentView()
}.environment(store) // 型から自動解決
}
}
// View 側
import SwiftUI
struct ContentView: View {
@Environment(TodoStore.self) private var store // 参照
@State private var newTitle = ""
var body: some View {
NavigationStack {
List {
ForEach($store.items) { $item in // iOS 17+: 配列の Binding 反復
HStack {
Toggle("", isOn: $item.isDone).labelsHidden()
TextField("タイトル", text: $item.title)
}
}
.onDelete { store.items.remove(atOffsets: $0) }
Section("追加") {
HStack {
TextField("新規タスク", text: $newTitle)
Button("追加") { guard !newTitle.isEmpty else { return }; store.add(newTitle); newTitle.removeAll() }
}
}
}
.navigationTitle("Todos")
}
}
}
旧来の @StateObject/@ObservedObject/@EnvironmentObject
も引き続き使えるが、新規実装は Observation を優先するとシンプル。
5.3 @Environment / @AppStorage / @SceneStorage
@Environment(\.colorScheme)
など、システムや祖先 View が提供する値。@AppStorage("key")
は UserDefaults バックの小さな設定を簡易保存。@SceneStorage("key")
はシーン単位で一時保持(シーン破棄で失われ得る)。
アンチパターン:
- グローバル変数で共有状態を持つ。
- 何でも
AnyView
に包む(型消去の乱用は最適化・可読性を損ねる)。 - 無闇な
@Published
(Observation では不要)。
6. ナビゲーション & 画面遷移
6.1 NavigationStack(value ベース)
import SwiftUI
enum Route: Hashable {
case settings,
case detail(TodoStore.Todo)
}
struct RootView: View {
@Environment(TodoStore.self) private var store
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
List(store.items) { item in
NavigationLink(value: Route.detail(item)) { Label(item.title, systemImage: item.isDone ? "checkmark.circle.fill" : "circle") }
}
// Routeで遷移先切り分け
.navigationDestination(for: Route.self) { route in
switch route {
case .settings: SettingsView()
case .detail(let item): DetailView(item: item)
}
}
.toolbar { ToolbarItem(placement: .topBarTrailing) { Button(systemName: "gearshape") { path.append(.settings) } } }
.navigationTitle("Todos")
}
}
}
値渡しの destination は編集用の Binding が不要な画面に向いている。編集したい場合は ID 経由で Binding
を引く、または @Bindable
なモデルを直接渡す構成にする。
6.2 シート/フルスクリーン/ポップオーバー
import SwiftUI
struct SheetDemo: View {
@State private var show = false
var body: some View {
Button("シートを開く") { show.toggle() }
.sheet(isPresented: $show) {
VStack {
Text("Hello from Sheet").font(.title)
Button("閉じる") {
show = false }
}
.presentationDetents([.medium, .large])
}
}
}
6.3 タブ
struct Tabs: View {
@State private var selected = 0
var body: some View {
TabView(selection: $selected) {
RootView()
.tabItem {
Label("Home", systemImage: "house")
}
.tag(0)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(1)
}
}
}
7. リスト&フォーム実践
// リストの行編集・削除・並べ替え:
List {
ForEach($store.items) { $item in
HStack {
TextField("タイトル", text: $item.title)
Spacer()
Toggle("", isOn: $item.isDone).labelsHidden()
}
}
.onDelete { store.items.remove(atOffsets: $0) }
.onMove { store.items.move(fromOffsets: $0, toOffset: $1) }
}
.toolbar { EditButton() }
// 便利修飾子:
.searchable(text: $query)
.refreshable { await fetch() }
.swipeActions(edge: .trailing) {
Button(role: .destructive) { /* delete */ } label: { Label("削除", systemImage: "trash") }
}
// Form の定番:
struct SettingsView: View {
@AppStorage("enablePro") private var enablePro = false
@AppStorage("quality") private var quality = 1
var body: some View {
Form {
Section("一般") {
Toggle("Pro 機能", isOn: $enablePro)
Picker("品質", selection: $quality) { Text("標準").tag(0); Text("高").tag(1) }
}
}
.navigationTitle("Settings")
}
}
8. 非同期処理とライフサイクル
8.1 async/await
と .task
import SwiftUI
struct UsersView: View {
@State private var users: [String] = []
var body: some View {
List(users, id: \.self, rowContent: Text.init)
.task { // 表示と同時に一度だけ実行
do {
users = try await fetchUsers()
} catch {
users = ["読み込み失敗"]
}
}
}
private func fetchUsers() async throws -> [String] {
try await Task.sleep(nanoseconds: 300_000_000) // ダミーの遅延
return ["Arthur", "John", "Sadie"]
}
}
8.2 メインスレッドと UI 更新
// 非同期処理の結果で UI を更新するコードは @MainActor 文脈で実行。
@MainActor
final class ImageLoader: ObservableObject { /* UIKit 併用時などに */ }
8.3 画像読み込み
AsyncImage(url: URL(string: "https://picsum.photos/200")) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
.cornerRadius(16)
case .failure:
Image(systemName: "exclamationmark.triangle")
@unknown default:
EmptyView()
}
}
.frame(height: 160)
9. モディファイアの順序とカプセル化テクニック
9.1 順序が変われば見た目も変わる
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Text("Badge").padding().background(.yellow).clipShape(Capsule())
// ↓ vs
Text("Badge").background(.yellow).padding().clipShape(Capsule()) // 背景の当たりが変わる
}
}
}
#Preview {
ContentView()
}

9.2 共通スタイルは View 拡張で再利用
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Text("Hello")
Text("SwiftUI")
}
.cardStyle()
}
}
extension View {
func cardStyle() -> some View {
self
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(radius: 4)
}
}
#Preview {
ContentView()
}

10. Preview & Xcode活用術
10.1 #Preview
マクロで複数バリアントを並べる
#Preview("Light") {
ContentView()
}
#Preview("Dark") {
ContentView().preferredColorScheme(.dark)
}
#Preview("JP/大きい文字") {
ContentView()
.environment(\.locale, Locale(identifier: "ja_JP"))
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) }

10.2 依存の差し替え
#Preview("Mock Store") {
let store = TodoStore(); store.items = [
.init(title: "デモ1")
.init(title: "デモ2", isDone: true)
]
return RootView()
.environment(store)
}
10.3 プレビューが重い/落ちるとき
#Preview
を最小にする/SomeView_Previews
を消す。- 複雑な View はプレースホルダーデータに。
11. アクセシビリティ&ローカライズの最短ルート
最低限の 3 点:
- ラベル
- Dynamic Type
- コントラスト
Image(systemName: "paperplane.fill")
.accessibilityLabel("送信") // VoiceOver 用
ローカライズは String Catalog を使用。Text("key")
に対してローカライズ文字列を定義。
プレビューで environment(\.locale, Locale(identifier: "ja_JP"))
を切り替えて確認。
12. パフォーマンスとよくあるハマり
- 再計算を減らす:大きい View は小さく分解。計算コストの高い処理は
@MainActor
外 or.task
に逃がす。 - Equatable にする:行ごとに変化を局所化。
struct EquatableRow: View, Equatable {
let value: Int
var body: some View { Text("Row: \(value)") }
static func == (l: Self, r: Self) -> Bool { l.value == r.value }
}
無限再描画の兆候:onAppear
内で状態を毎回変更/body
から副作用を呼ぶ。→ 副作用は .task
や onAppear
で一度だけにする。
14. よくあるエラーとデバッグTips
- “Modifying state during view update”:
body
評価中に状態を変更している。→ アクション or.task
に移す。 - “Unable to infer complex closure return type”:
ViewBuilder
の分岐が複雑。→some View
を小さな補助 View に切り出す。 - プレビューが真っ白: 依存が未注入/クラッシュを飲み込んでいる。→ コンソールのエラー確認、
fatalError
の可能性を疑う。
参考リンク
- Apple Developer Documentation — SwiftUI: https://developer.apple.com/documentation/swiftui
- Apple — Observation(WWDC23 セッション/ドキュメント): https://developer.apple.com/documentation/observation
- Apple — Data essentials in SwiftUI: https://developer.apple.com/videos/play/wwdc2023/10148/
- Apple — Meet SwiftUI (入門セッション): https://developer.apple.com/videos/swiftui
- Swift by Sundell(実践的Tips多数): https://www.swiftbysundell.com
- Hacking with Swift — SwiftUI 100 Days: https://www.hackingwithswift.com/100
- Point-Free — SwiftUI/Composable Architecture(設計の参考): https://www.pointfree.co