[Xcode/SwiftUI] これだけ覚えておけばOK、SwiftUIの基本

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
ToggleON/OFFスイッチバインディングされた状態(isOn:)で状態を制御
Sliderスライダーによる数値入力value: で Double バインディング
Stepper「+」「−」ボタンで値を増減in: で最小・最大値を指定可能
HStack横並びレイアウト子Viewを水平に配置
VStack縦並びレイアウト(この例のメインコンテナ)alignmentspacing 指定可能
.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 点:

  1. ラベル
  2. Dynamic Type
  3. コントラスト
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 から副作用を呼ぶ。→ 副作用は .taskonAppear一度だけにする。

14. よくあるエラーとデバッグTips

  • “Modifying state during view update”: body 評価中に状態を変更している。→ アクション or .task に移す。
  • “Unable to infer complex closure return type”: ViewBuilder の分岐が複雑。→ some View を小さな補助 View に切り出す。
  • プレビューが真っ白: 依存が未注入/クラッシュを飲み込んでいる。→ コンソールのエラー確認、fatalError の可能性を疑う。

参考リンク