
Swiftui Coding Guidelines
- 7 installs
- 3 repo stars
- Updated June 5, 2026
- xtone/ai_development_tools
Helps with ai & agent building tasks.
About
swiftui-coding-guidelines is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- swiftui-coding-guidelines
- AI & Agent Building
- AI-coding skill
Swiftui Coding Guidelines by the numbers
- 7 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #12,520 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xtone/ai_development_tools --skill swiftui-coding-guidelinesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 5, 2026 |
| Repository | xtone/ai_development_tools ↗ |
What it does
Helps with ai & agent building tasks.
Files
SwiftUI Coding Guidelines
SwiftUIの日常的な開発で参照するコアガイドライン。
ディレクトリ構成
swiftui-coding-guidelines/
├── SKILL.md (このファイル)
└── references/
├── best-practices.md # ベストプラクティス
├── patterns-list.md # 1-x: リスト・データ表示系
├── patterns-navigation.md # 2-x: ナビゲーション・画面遷移系
├── patterns-forms.md # 3-x: フォーム・入力系
├── patterns-views.md # 4-x: View構築・レイアウト系
└── anti-patterns.md # アンチパターンリファレンスファイル
references/best-practices.md
SwiftUIのベストプラクティス集:
- 状態管理: プロパティラッパー(@State、@Binding、@StateObject等)
- パフォーマンス最適化: View構造、LazyスタックとGrid、Equatable
- コンポーネント設計: 責務分離、Root Views vs Content Views、ViewModifier
- 非同期処理: .taskモディファイア、async/await、@MainActor
- ナビゲーション: NavigationStack(iOS 16+)、型安全ルーティング
- Layoutプロトコル: GeometryReaderの代替(iOS 16+)
- Preview駆動開発: 複数状態プレビュー
references/patterns-list.md(1-x: リスト・データ表示系)
| 番号 | パターン |
|---|---|
| 1-1 | 無限スクロール(Pagination) |
| 1-2 | プルトゥリフレッシュ |
| 1-3 | フィルタリング・ソート |
| 1-4 | スワイプアクション |
| 1-5 | コンテキストメニュー |
references/patterns-navigation.md(2-x: ナビゲーション・画面遷移系)
| 番号 | パターン |
|---|---|
| 2-1 | タブビュー |
| 2-2 | ナビゲーション(NavigationStack) |
| 2-3 | モーダル表示(Sheet / FullScreenCover) |
| 2-4 | アラート・ダイアログ |
references/patterns-forms.md(3-x: フォーム・入力系)
| 番号 | パターン |
|---|---|
| 3-1 | 検索機能(デバウンス付き) |
| 3-2 | フォーム入力(バリデーション付き) |
| 3-3 | 認証画面(ログイン / 新規登録 / OTP) |
references/patterns-views.md(4-x: View構築・レイアウト系)
| 番号 | パターン |
|---|---|
| 4-1 | AsyncImage(キャッシュ付き) |
| 4-2 | 空状態・エラー状態 |
| 4-3 | アニメーション |
| 4-4 | View重なり(ZStack / overlay / background) |
| 4-5 | ContentUnavailableView(iOS 17+) |
references/anti-patterns.md
アンチパターンと解決策:
- 状態管理、パフォーマンス、ビジネスロジック
- 非同期処理、ナビゲーション、メモリ管理
- ViewのIdentity: 不安定なID、ForEachでのインデックス使用
- AnyView: 型消去によるパフォーマンス低下
- コンポーネント設計: スペーシング管理、opacity vs if文
使用方法
新規実装時
1. 該当するpatterns-*.mdで実装パターン確認 2. best-practices.mdで関連ベストプラクティス確認 3. anti-patterns.mdで避けるべきパターン確認
問題解決時
1. anti-patterns.mdで該当パターン検索 2. best-practices.mdで推奨実装確認
重要な原則
1. 状態の最小化: 導出可能な状態は持たない 2. 責務の分離: Root Views(ロジック)とContent Views(UI)を分離 3. パフォーマンス: Lazy、Equatable、非同期処理の適切な使用 4. ViewのIdentity: 安定したIDを使用、ForEachでインデックス禁止 5. スペーシングは親が管理: 子コンポーネントはpaddingでスペースを作らない
関連スキル
- swiftui-ssot: 状態管理(SSOT)の詳細設計
- swiftui-code-review-checklist: PRレビュー用チェックリスト
- swift-ios-migration: iOS 17/18、Swift 6移行
- swiftui-accessibility: アクセシビリティ対応
SwiftUI アンチパターン集
1. 状態管理のアンチパターン
1.1 @Stateで参照型を使用
// ❌ アンチパターン: @Stateで参照型
@State private var viewModel = MyViewModel() // classの場合
// 問題点:
// - 変更が検知されない
// - メモリリーク、パフォーマンス低下
// - 予期しない動作
// ✅ 解決策: @StateObjectを使用
@StateObject private var viewModel = MyViewModel()1.2 導出可能な状態の保持
// ❌ アンチパターン: 導出可能な状態
@State private var items: [Item] = []
@State private var count: Int = 0 // items.countで計算可能
@State private var isEmpty: Bool = true // items.isEmptyで計算可能
// 問題点:
// - 状態の同期が必要
// - バグの温床
// - SSOT違反
// ✅ 解決策: 計算プロパティを使用
@State private var items: [Item] = []
private var count: Int { items.count }
private var isEmpty: Bool { items.isEmpty }1.3 @StateObjectと@ObservedObjectの混同
// ❌ アンチパターン: 親で@ObservedObject
struct ParentView: View {
@ObservedObject var viewModel = MyViewModel() // 新規作成
// ビュー再描画のたびに再作成される可能性
}
// ✅ 解決策: 所有者は@StateObject
struct ParentView: View {
@StateObject private var viewModel = MyViewModel()
}
struct ChildView: View {
@ObservedObject var viewModel: MyViewModel // 受け取る場合はOK
}1.4 状態の過剰な分散
// ❌ アンチパターン: 状態があちこちに
struct View1: View {
@State private var user: User?
}
struct View2: View {
@State private var user: User? // 重複
}
// ✅ 解決策: 共有状態はEnvironmentまたは親から渡す
class UserStore: ObservableObject {
@Published var user: User?
}
struct RootView: View {
@StateObject private var userStore = UserStore()
var body: some View {
ContentView()
.environmentObject(userStore)
}
}2. パフォーマンスのアンチパターン
2.1 body内での重い計算
// ❌ アンチパターン: 毎回実行される
var body: some View {
let sortedItems = items.sorted { $0.date > $1.date } // 毎回ソート
let filteredItems = sortedItems.filter { $0.isActive } // 毎回フィルタ
List(filteredItems) { item in
ItemRow(item: item)
}
}
// ✅ 解決策: 計算を事前に実行
@State private var items: [Item] = []
@State private var processedItems: [Item] = []
var body: some View {
List(processedItems) { item in
ItemRow(item: item)
}
.onChange(of: items) { _, newItems in
processedItems = newItems
.sorted { $0.date > $1.date }
.filter { $0.isActive }
}
}2.2 Lazyを使わない大量データ
// ❌ アンチパターン: 全アイテムを即座にレンダリング
ScrollView {
VStack {
ForEach(items) { item in // 1000アイテムすべて即座に
ItemRow(item: item)
}
}
}
// ✅ 解決策: LazyVStackを使用(30-40%メモリ削減)
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}2.3 GeometryReaderの過剰使用
// ❌ アンチパターン: リスト内でGeometryReader
List(items) { item in
GeometryReader { geo in // 各行で再計算
ItemRow(item: item, width: geo.size.width)
}
}
// ✅ 解決策: 外側で1回だけ使用
GeometryReader { geo in
List(items) { item in
ItemRow(item: item, width: geo.size.width)
}
}2.4 不要な再レンダリング
// ❌ アンチパターン: 関係ない変更で再レンダリング
class ViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var searchText = "" // 変更で全ビューが再描画
}
// ✅ 解決策 (iOS 17+): @Observableで選択的監視
@Observable
class ViewModel {
var items: [Item] = [] // 使用するビューのみ再描画
var searchText = ""
}3. ビジネスロジックのアンチパターン
3.1 View内にビジネスロジック
// ❌ アンチパターン: View内でロジック
struct ArticleListView: View {
@State private var articles: [Article] = []
var body: some View {
List(articles) { ... }
.onAppear {
// ネットワーク処理がView内に
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data {
let decoded = try? JSONDecoder().decode([Article].self, from: data)
DispatchQueue.main.async {
articles = decoded ?? []
}
}
}.resume()
}
}
}
// ✅ 解決策: ViewModelに分離
@MainActor
class ArticleListViewModel: ObservableObject {
@Published var articles: [Article] = []
func fetchArticles() async {
do {
articles = try await articleService.fetch()
} catch {
// エラーハンドリング
}
}
}
struct ArticleListView: View {
@StateObject private var viewModel = ArticleListViewModel()
var body: some View {
List(viewModel.articles) { ... }
.task {
await viewModel.fetchArticles()
}
}
}3.2 ハードコードされた依存
// ❌ アンチパターン: 直接依存
class ViewModel: ObservableObject {
private let service = ArticleService() // テスト不可
}
// ✅ 解決策: プロトコルと依存性注入
protocol ArticleServiceProtocol {
func fetch() async throws -> [Article]
}
class ViewModel: ObservableObject {
private let service: ArticleServiceProtocol
init(service: ArticleServiceProtocol = ArticleService()) {
self.service = service
}
}4. 非同期処理のアンチパターン
4.1 onAppearでの非同期処理
// ❌ アンチパターン: onAppear + Task
.onAppear {
Task {
await viewModel.fetchData()
}
// ビュー消失時にキャンセルされない
}
// ✅ 解決策: .taskモディファイア(自動キャンセル)
.task {
await viewModel.fetchData()
}4.2 メインスレッドでの重い処理
// ❌ アンチパターン: UIスレッドをブロック
func processImages() {
let processed = images.map { image in
heavyImageProcessing(image) // UIが固まる
}
self.processedImages = processed
}
// ✅ 解決策: バックグラウンドで実行
func processImages() async {
let processed = await Task.detached(priority: .userInitiated) {
images.map { heavyImageProcessing($0) }
}.value
await MainActor.run {
self.processedImages = processed
}
}4.3 逐次的なawait
// ❌ アンチパターン: 順番に待機
func fetchAllData() async {
let users = await fetchUsers() // 待機
let posts = await fetchPosts() // 待機
let comments = await fetchComments() // 待機
// 合計: 3つの待機時間の和
}
// ✅ 解決策: 並列実行
func fetchAllData() async {
async let users = fetchUsers()
async let posts = fetchPosts()
async let comments = fetchComments()
let (u, p, c) = await (users, posts, comments)
// 合計: 最長の待機時間のみ
}5. ナビゲーションのアンチパターン
5.1 NavigationViewの使用(iOS 16+)
// ❌ アンチパターン: 非推奨API
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
}
}
}
// ✅ 解決策: NavigationStack
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}5.2 複数のナビゲーション状態
// ❌ アンチパターン: 分散したナビゲーション状態
@State private var showDetail = false
@State private var showSettings = false
@State private var showProfile = false
// ✅ 解決策: NavigationPathで一元管理
enum Route: Hashable {
case detail(Item)
case settings
case profile
}
@State private var path = NavigationPath()6. モディファイアのアンチパターン
6.1 スタイルの重複
// ❌ アンチパターン: 同じスタイルを繰り返し
Text("Title1")
.font(.headline)
.foregroundColor(.primary)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
Text("Title2")
.font(.headline)
.foregroundColor(.primary)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
// ✅ 解決策: ViewModifierで共通化
struct CardTitleStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(.headline)
.foregroundColor(.primary)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
}
}
extension View {
func cardTitleStyle() -> some View {
modifier(CardTitleStyle())
}
}
Text("Title1").cardTitleStyle()
Text("Title2").cardTitleStyle()6.2 モディファイア順序の無視
// 順序で結果が変わる
Text("Hello")
.padding()
.background(Color.red) // paddingの外側が赤
Text("Hello")
.background(Color.red)
.padding() // テキストの背景のみ赤、paddingは透明7. エラーハンドリングのアンチパターン
7.1 エラーの無視
// ❌ アンチパターン: エラーを握りつぶす
func fetchData() async {
do {
data = try await service.fetch()
} catch {
// 何もしない
}
}
// ✅ 解決策: エラー状態を管理
@Published var error: Error?
func fetchData() async {
do {
data = try await service.fetch()
error = nil
} catch {
self.error = error
}
}
// View側
if let error = viewModel.error {
ErrorView(error: error, onRetry: { Task { await viewModel.fetchData() } })
}8. メモリ管理のアンチパターン
8.1 循環参照
// ❌ アンチパターン: クロージャで強参照
class ViewModel: ObservableObject {
var onComplete: (() -> Void)?
func setup() {
onComplete = {
self.doSomething() // 循環参照
}
}
}
// ✅ 解決策: [weak self]を使用
func setup() {
onComplete = { [weak self] in
self?.doSomething()
}
}8.2 Combineのキャンセル忘れ
// ❌ アンチパターン: キャンセルしない
class ViewModel: ObservableObject {
func subscribe() {
publisher.sink { value in
// 処理
}
// AnyCancellableを保持していない
}
}
// ✅ 解決策: cancellablesで保持
private var cancellables = Set<AnyCancellable>()
func subscribe() {
publisher
.sink { [weak self] value in
self?.handle(value)
}
.store(in: &cancellables)
}9. AnyViewのアンチパターン
9.1 AnyViewによる型消去
// ❌ アンチパターン: AnyViewの使用
func makeView(for type: ViewType) -> AnyView {
switch type {
case .text:
return AnyView(Text("Hello"))
case .image:
return AnyView(Image(systemName: "star"))
case .button:
return AnyView(Button("Tap") { })
}
}
var body: some View {
makeView(for: currentType)
}問題点:
- 型情報が消去され、SwiftUIの差分検出(Diffing)が非効率になる
- パフォーマンス低下(SwiftUIがView階層を正しく追跡できない)
- アニメーションが正しく動作しない可能性
// ✅ 解決策1: @ViewBuilderを使用
@ViewBuilder
func makeView(for type: ViewType) -> some View {
switch type {
case .text:
Text("Hello")
case .image:
Image(systemName: "star")
case .button:
Button("Tap") { }
}
}
// ✅ 解決策2: Groupを使用
var body: some View {
Group {
switch currentType {
case .text:
Text("Hello")
case .image:
Image(systemName: "star")
case .button:
Button("Tap") { }
}
}
}
// ✅ 解決策3: ジェネリクスを使用
struct ContainerView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
}
}9.2 冗長なビューラッピング
// ❌ アンチパターン: 不要なラッピング
VStack {
Color.red
}
// ✅ 解決策: 直接使用
Color.red10. ViewのIdentity(同一性)のアンチパターン
SwiftUIは「ある時点のView」と「次の時点のView」が同じか判断するためにIdentityを使用。
Identity の種類
明示的Identity (Explicit Identity)
id()モディファイア、ForEachの引数で明示的に指定- データベースの主キーのように機能
構造的Identity (Structural Identity)
- View階層内での位置(パス)によって決定
if-else分岐は異なる構造的IDを持つ
10.1 ForEachでのインデックス使用(🔴 重大)
// ❌ アンチパターン: インデックスをIDとして使用
struct ItemListView: View {
@State private var items = ["A", "B", "C"]
var body: some View {
List {
ForEach(0..<items.count, id: \.self) { index in
Text(items[index])
}
}
}
}
// 問題: 要素削除時にインデックスとデータがずれる
// → 状態消失、クラッシュ、アニメーション崩壊
// ✅ 解決策: 安定した一意のIDを持つモデルを使用
struct Item: Identifiable {
let id = UUID()
var name: String
}
struct ItemListView: View {
@State private var items = [Item(name: "A"), Item(name: "B"), Item(name: "C")]
var body: some View {
List {
ForEach(items) { item in
Text(item.name)
}
}
}
}10.2 不安定なIDによる状態リセット
// ❌ アンチパターン: 毎回新しいIDを生成
struct ContentView: View {
@State private var text = ""
var body: some View {
TextField("Input", text: $text)
.id(UUID()) // 毎回新しいID → 状態がリセットされる
}
}
// ✅ 解決策: 安定したIDを使用
struct ContentView: View {
@State private var text = ""
let textFieldId = "mainTextField"
var body: some View {
TextField("Input", text: $text)
.id(textFieldId) // 安定したID
}
}10.3 条件分岐による意図しないIdentity変更
// ⚠️ 注意: if-elseは構造的に異なるViewを生成
var body: some View {
if isLoggedIn {
HomeView() // 構造的ID: "true分岐"
} else {
LoginView() // 構造的ID: "false分岐"
}
}
// → isLoggedInが変わると完全に別のViewとして扱われる
// → @Stateなどの状態は保持されない(これは通常は期待通りの動作)
// 同じViewで状態を保持したい場合
var body: some View {
ContentView(isLoggedIn: isLoggedIn)
.id("content") // 明示的IDで同一性を維持
}10.4 リスト内でのIdentity問題
// ❌ アンチパターン: Hashableだが不安定
struct Article {
var title: String
var content: String
}
extension Article: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(title) // titleが変わるとIDが変わる
}
}
// ✅ 解決策: Identifiableで安定したID
struct Article: Identifiable {
let id = UUID() // 不変のID
var title: String
var content: String
}11. その他のアンチパターン
11.1 Computed Propertyでの重い処理
// ❌ アンチパターン: body内で毎回実行
var body: some View {
let formatter = DateFormatter() // 毎回生成(高コスト)
formatter.dateStyle = .medium
return Text(formatter.string(from: date))
}
// ✅ 解決策: staticでキャッシュ
struct DateView: View {
let date: Date
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()
var body: some View {
Text(Self.formatter.string(from: date))
}
}11.2 onAppearへの過度な依存
// ❌ アンチパターン: NavigationStackで重複実行
struct DetailView: View {
@State private var data: Data?
var body: some View {
content
.onAppear {
// 戻る→進むで毎回実行される
loadData()
}
}
}
// ✅ 解決策: taskで初回のみ、または冪等性を担保
struct DetailView: View {
@State private var data: Data?
var body: some View {
content
.task {
// Viewのライフサイクルに連動、自動キャンセル
guard data == nil else { return } // 冪等性
data = await fetchData()
}
}
}12. コンポーネント設計のアンチパターン
12.1 🔴 子コンポーネントでスペーシングを管理
子Viewでpaddingを使って間隔を設定すると、再利用性が低下し、レイアウトの一貫性が失われる。
// ❌ アンチパターン: 子がスペーシングを持つ
VStack(spacing: 0) {
Image(systemName: "swift")
.padding(.bottom, 8) // 子がスペーシングを制御
Text("Swift")
.padding(.bottom, 8) // 毎回padding指定が必要
Text("Programming")
}
// ❌ 問題点:
// - 再利用時に余計なスペースが付いてくる
// - 最後の要素だけpaddingを外す条件分岐が必要
// - レイアウト変更時に全ての子を修正する必要がある
// ✅ 解決策: 親がスペーシングを管理
VStack(spacing: 8) { // 親が一括管理
Image(systemName: "swift")
Text("Swift")
Text("Programming")
}
// ✅ 異なるスペーシングが必要な場合
VStack(spacing: 0) {
// グループ1
VStack(spacing: 4) {
Image(systemName: "swift")
Text("Swift")
}
Spacer().frame(height: 16) // グループ間のスペース
// グループ2
VStack(spacing: 4) {
Image(systemName: "apple.logo")
Text("Apple")
}
}原則: スペーシングは親コンポーネントの責務。子コンポーネントは自身のコンテンツのみに責任を持つ。
12.2 🟡 if文でViewを表示/非表示(Identity変更)
条件分岐でViewを切り替えると、Structural Identityが変わり状態がリセットされる。
// ❌ アンチパターン: if文で切り替え(Identityが変わる)
struct ProblematicView: View {
@State private var isVisible = true
var body: some View {
VStack {
if isVisible {
// isVisibleがfalse→trueになると、TextFieldの入力内容がリセット
InputView()
}
Toggle("表示", isOn: $isVisible)
}
}
}
struct InputView: View {
@State private var text = "" // 親のif文でIdentityが変わると消える
var body: some View {
TextField("入力", text: $text)
}
}
// ✅ 解決策1: opacityで非表示(Identityを維持)
struct BetterView: View {
@State private var isVisible = true
var body: some View {
VStack {
InputView()
.opacity(isVisible ? 1 : 0) // 状態を維持したまま非表示
Toggle("表示", isOn: $isVisible)
}
}
}
// ✅ 解決策2: hidden()モディファイアを使用
InputView()
.hidden(!isVisible) // iOS 17.5+、hidden(true)で非表示
// ✅ 解決策3: カスタムモディファイア
extension View {
@ViewBuilder
func visible(_ isVisible: Bool) -> some View {
if isVisible {
self
} else {
self.hidden()
}
}
}使い分け:
| パターン | 状態 | 用途 |
|---|---|---|
if 文 | リセット | 状態リセットが望ましい場合 |
opacity(0) | 維持 | フォーカス・入力状態を保持したい場合 |
hidden() | 維持 | レイアウトスペースも維持したい場合 |
12.3 ユーザー入力の勝手な書き換え
TextFieldなどの入力コンポーネントで、ユーザーの入力値を親が勝手に変更するとUXが悪化。
// ❌ アンチパターン: 入力中に値を強制変更
struct BadInputView: View {
@Binding var text: String
var body: some View {
TextField("金額", text: $text)
.onChange(of: text) { _, newValue in
// 入力中にフォーマットすると、カーソル位置がずれる
text = formatCurrency(newValue)
}
}
}
// ✅ 解決策: フォーカスが外れたときにフォーマット
struct GoodInputView: View {
@Binding var text: String
@FocusState private var isFocused: Bool
var body: some View {
TextField("金額", text: $text)
.focused($isFocused)
.onChange(of: isFocused) { _, focused in
if !focused {
// 編集完了時にのみフォーマット
text = formatCurrency(text)
}
}
}
}SwiftUI Best Practices
1. 状態管理
Property Wrapperの使い分け(iOS 13-16)
| ラッパー | 用途 | 所有権 |
|---|---|---|
@State | 値型のローカル状態(Int, String, Bool, struct)。必ずprivateにする | 所有 |
@StateObject | 参照型の所有者。1度だけ初期化、再描画でも維持 | 所有 |
@ObservedObject | 外部から受け取る参照型。親から渡される場合 | 非所有 |
@EnvironmentObject | アプリ全体で共有する状態。依存性注入として使用 | 非所有 |
@Binding | 親ビューの状態への双方向参照 | 非所有 |
// ✅ 正しい使い分け
struct ParentView: View {
@StateObject private var viewModel = MyViewModel() // 所有者
var body: some View {
ChildView(viewModel: viewModel)
}
}
struct ChildView: View {
@ObservedObject var viewModel: MyViewModel // 外部から受け取る
var body: some View {
GrandchildView(text: $viewModel.text) // Bindingで渡す
}
}Single Source of Truth (SSOT)
データは1箇所でのみ管理。複数ビューで共有時は@Bindingや@ObservedObject使用。
// ❌ 状態の重複
struct BadView: View {
@State private var items: [Item] = []
@State private var count: Int = 0 // itemsから導出可能
}
// ✅ 導出プロパティを使用
struct GoodView: View {
@State private var items: [Item] = []
private var count: Int { items.count }
}2. パフォーマンス最適化
不要な再レンダリング防止
// デバッグ: なぜ再レンダリングされたか確認
var body: some View {
let _ = Self._printChanges()
// ...
}ポイント:
@StateObjectは所有者で使用、@ObservedObjectは注入時のみbody内で重い計算を避ける →onChange等で事前計算- 小さなビューに分割してSwiftUIが必要な部分のみ更新
Lazyコンテナの活用
// ✅ オンデマンドレンダリング(30-40%のメモリ削減)
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
// ❌ 全アイテムを即座にレンダリング
ScrollView {
VStack {
ForEach(items) { ... }
}
}EquatableViewの活用
// 15%のレンダリング時間削減(高コストなビューに効果的)
struct ExpensiveView: View, Equatable {
let data: ExpensiveData
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.data.id == rhs.data.id
}
var body: some View {
// 複雑なレンダリング
}
}GeometryReaderの使用を最小限に
- レイアウト変更毎に再計算、パフォーマンス影響
- 動的レイアウト計算が本当に必要な場合のみ使用
- リスト/グリッド内での使用を避ける
3. ビュー構成(View Composition)
Root Views vs Content Views
// Root View: ビジネスロジック、ナビゲーション管理
struct ArticleListView: View {
@StateObject private var viewModel = ArticleListViewModel()
var body: some View {
ArticleListContent(
articles: viewModel.articles,
isLoading: viewModel.isLoading,
onRefresh: { await viewModel.refresh() }
)
}
}
// Content View: 純粋なUI表示、プリミティブ型のみ受け取る
struct ArticleListContent: View {
let articles: [Article]
let isLoading: Bool
let onRefresh: () async -> Void
var body: some View {
// UIのみ
}
}ViewModifierの活用
// カスタムモディファイア定義
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 4)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardStyle())
}
}
// 使用
Text("Hello").cardStyle()コンポーネントライブラリ構造
Design/
├── Colors.swift # カラーパレット
├── Typography.swift # フォントスタイル
└── Spacing.swift # 間隔定数
Components/
├── Buttons/
├── Cards/
└── TextFields/
Modifiers/
└── CardStyle.swift4. ナビゲーション(iOS 16+)
NavigationStackの使用
// 型安全なルート定義
enum Route: Hashable {
case detail(Article)
case settings
case profile(userId: String)
}
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(articles) { article in
NavigationLink(value: Route.detail(article)) {
ArticleRow(article: article)
}
}
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let article):
ArticleDetailView(article: article)
case .settings:
SettingsView()
case .profile(let userId):
ProfileView(userId: userId)
}
}
}
}
// プログラマティックナビゲーション
func navigateToSettings() {
path.append(Route.settings)
}
func popToRoot() {
path.removeLast(path.count)
}
}5. 非同期処理(Async/Await)
.taskモディファイアの使用(iOS 15+)
struct ArticleListView: View {
@StateObject private var viewModel = ArticleListViewModel()
var body: some View {
List(viewModel.articles) { article in
ArticleRow(article: article)
}
.task {
// async/await直接使用、ビュー消失時に自動キャンセル
await viewModel.fetchArticles()
}
.task(id: viewModel.selectedCategory) {
// idが変わると再実行
await viewModel.fetchArticles()
}
}
}.task vs .onAppear
| 特徴 | .task | .onAppear |
|---|---|---|
| async/await | 直接使用可能 | Task{}で囲む必要 |
| キャンセル | 自動 | 手動 |
| 優先度設定 | 可能 | 不可 |
| iOS | 15+ | 13+ |
@MainActorの使用
@MainActor
class ArticleListViewModel: ObservableObject {
@Published var articles: [Article] = []
@Published var isLoading = false
func fetchArticles() async {
isLoading = true
defer { isLoading = false }
do {
articles = try await articleService.fetch()
} catch {
// エラーハンドリング
}
}
}6. Preview駆動開発
複数状態のプレビュー
#Preview("Loading") {
ArticleListContent(articles: [], isLoading: true, onRefresh: {})
}
#Preview("Empty") {
ArticleListContent(articles: [], isLoading: false, onRefresh: {})
}
#Preview("With Data") {
ArticleListContent(articles: Article.samples, isLoading: false, onRefresh: {})
}
#Preview("Error") {
ArticleListContent(articles: [], isLoading: false, error: .networkError, onRefresh: {})
}
// iOS 17+ @Previewableマクロ
#Preview {
@Previewable @State var isOn = false
Toggle("Switch", isOn: $isOn)
}プレビューのベストプラクティス
- 異なるデバイスサイズ、Dynamic Type設定でプレビュー
- ダークモード、ライトモード両方
- 空、ロード中、エラー、データありの各状態
7. プロジェクト構造
Sources/
├── App/ # メインアプリファイル
├── Features/ # 機能別
│ ├── Home/
│ │ ├── HomeView.swift
│ │ └── HomeViewModel.swift
│ └── Profile/
├── Shared/ # 共通コンポーネント
│ ├── Components/
│ └── Modifiers/
├── Models/ # データモデル
├── Services/ # Network, Persistence
└── Utilities/ # Extensions, Constants
Resources/
├── Assets/ # 画像、色
├── Localization/ # ローカライズ
└── Fonts/ # カスタムフォント8. MVVM vs MV パターン
SwiftUIではMVVMは必須ではない。Apple Developer Forumsでも議論されている。
// MVパターン(Store使用)
@MainActor
class ArticleStore: ObservableObject {
@Published private(set) var articles: [Article] = []
func fetch() async { /* ... */ }
}
// Viewで直接使用
struct ArticleListView: View {
@StateObject private var store = ArticleStore()
// ...
}ポイント:
- ViewModelという名称は混乱を招く可能性
- テスタビリティのために過度な抽象化を避ける
- Storeパターンも有効な選択肢
9. Layoutプロトコル(iOS 16+)
GeometryReaderの代替として、カスタムレイアウトコンテナを作成可能。パフォーマンスに優れ、レイアウトサイクルのリスクが低い。
基本構造
struct EqualWidthHStack: Layout {
var spacing: CGFloat = 8
// 1. 必要なサイズを計算
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxWidth = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
let totalWidth = maxWidth * CGFloat(subviews.count) + spacing * CGFloat(subviews.count - 1)
let maxHeight = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
return CGSize(width: totalWidth, height: maxHeight)
}
// 2. 子Viewを配置
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let maxWidth = subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0
var x = bounds.minX
for subview in subviews {
subview.place(
at: CGPoint(x: x, y: bounds.minY),
proposal: ProposedViewSize(width: maxWidth, height: bounds.height)
)
x += maxWidth + spacing
}
}
}
// 使用
EqualWidthHStack(spacing: 12) {
Button("Short") { }
Button("Medium Text") { }
Button("Very Long Button") { }
}フローレイアウト(折り返し配置)
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let result = arrange(subviews: subviews, in: proposal.width ?? 0)
return result.size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = arrange(subviews: subviews, in: bounds.width)
for (index, position) in result.positions.enumerated() {
subviews[index].place(
at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
proposal: .unspecified
)
}
}
private func arrange(subviews: Subviews, in width: CGFloat) -> (size: CGSize, positions: [CGPoint]) {
var positions: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
var maxWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > width && currentX > 0 {
currentX = 0
currentY += lineHeight + spacing
lineHeight = 0
}
positions.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + spacing
maxWidth = max(maxWidth, currentX - spacing)
}
return (CGSize(width: maxWidth, height: currentY + lineHeight), positions)
}
}
// 使用(タグ表示など)
FlowLayout(spacing: 8) {
ForEach(tags, id: \.self) { tag in
TagView(text: tag)
}
}GeometryReaderとの比較
| 特徴 | Layout | GeometryReader |
|---|---|---|
| パフォーマンス | ◎ 優秀 | △ レイアウトサイクルのリスク |
| 再利用性 | ◎ 高い | △ 低い |
| 複雑さ | やや高い | 低い |
| iOS | 16+ | 13+ |
| 用途 | カスタムコンテナ | サイズ取得・相対配置 |
GeometryReaderを使う場合の注意
// ❌ 避ける: リスト内でGeometryReader
List(items) { item in
GeometryReader { geo in
ItemRow(item: item, width: geo.size.width)
}
}
// ✅ 推奨: background/overlayで使用
Text("Hello")
.background(
GeometryReader { geo in
Color.clear.onAppear {
size = geo.size
}
}
)
// ✅ 推奨: 外側で1回だけ
GeometryReader { geo in
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item, width: geo.size.width)
}
}
}
}
SwiftUI フォーム・入力パターン
3-1. 検索機能(デバウンス付き)
iOS 15+ searchableモディファイア
struct SearchableListView: View {
@StateObject private var viewModel = SearchViewModel()
@State private var searchText = ""
var body: some View {
NavigationStack {
List(viewModel.results) { item in
ItemRow(item: item)
}
.navigationTitle("検索")
.searchable(text: $searchText, prompt: "キーワードを入力")
.onChange(of: searchText) { _, newValue in
viewModel.searchTextChanged(newValue)
}
}
}
}
@MainActor
class SearchViewModel: ObservableObject {
@Published var results: [Item] = []
private var searchTask: Task<Void, Never>?
func searchTextChanged(_ text: String) {
// 前の検索をキャンセル
searchTask?.cancel()
guard !text.isEmpty else {
results = []
return
}
// デバウンス: 300ms後に検索実行
searchTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
do {
results = try await searchService.search(query: text)
} catch {
// エラーハンドリング
}
}
}
}---
3-2. フォーム入力(バリデーション付き)
struct RegistrationForm: View {
@StateObject private var viewModel = RegistrationViewModel()
var body: some View {
Form {
Section("アカウント情報") {
TextField("メールアドレス", text: $viewModel.email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
if let error = viewModel.emailError {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
SecureField("パスワード", text: $viewModel.password)
.textContentType(.newPassword)
if let error = viewModel.passwordError {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
Section {
Button("登録") {
Task {
await viewModel.register()
}
}
.disabled(!viewModel.isValid)
}
}
}
}
@MainActor
class RegistrationViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
var emailError: String? {
guard !email.isEmpty else { return nil }
let emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/
return email.uppercased().wholeMatch(of: emailRegex) == nil
? "有効なメールアドレスを入力してください"
: nil
}
var passwordError: String? {
guard !password.isEmpty else { return nil }
return password.count < 8
? "パスワードは8文字以上必要です"
: nil
}
var isValid: Bool {
!email.isEmpty &&
!password.isEmpty &&
emailError == nil &&
passwordError == nil
}
func register() async {
// 登録処理
}
}---
3-3. 認証画面(ログインフォーム)
完全な実装例
FocusState、textContentType、キーボード設定を組み合わせた認証画面の推奨パターン。
struct LoginView: View {
enum Field: Hashable {
case email
case password
}
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
@State private var errorMessage: String?
@FocusState private var focusedField: Field?
var body: some View {
Form {
Section {
// メールアドレス入力欄
TextField("メールアドレス", text: $email)
.focused($focusedField, equals: .email)
.textContentType(.username) // パスワードマネージャー対応
.keyboardType(.emailAddress) // メール用キーボード
.textInputAutocapitalization(.never) // 自動大文字化を無効
.autocorrectionDisabled() // 自動修正を無効
.submitLabel(.next) // Returnキーを「次へ」に
.onSubmit {
focusedField = .password // パスワード欄にフォーカス移動
}
// パスワード入力欄
SecureField("パスワード", text: $password)
.focused($focusedField, equals: .password)
.textContentType(.password) // パスワードマネージャー対応
.submitLabel(.go) // Returnキーを「Go」に
.onSubmit {
login() // ログイン実行
}
} header: {
Text("アカウント情報")
} footer: {
if let errorMessage {
Text(errorMessage)
.foregroundColor(.red)
}
}
Section {
Button {
login()
} label: {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("ログイン")
.frame(maxWidth: .infinity)
}
}
.disabled(!isFormValid || isLoading)
}
}
.onAppear {
// 画面表示時に最初のフィールドにフォーカス
focusedField = .email
}
}
private var isFormValid: Bool {
!email.isEmpty && !password.isEmpty
}
private func login() {
// 未入力フィールドがあればフォーカス移動
if email.isEmpty {
focusedField = .email
return
}
if password.isEmpty {
focusedField = .password
return
}
// キーボードを閉じる
focusedField = nil
isLoading = true
errorMessage = nil
Task {
// ログイン処理...
}
}
}新規登録画面
新規パスワード作成時は .newPassword を使用。
struct SignUpView: View {
enum Field: Hashable {
case email
case password
case confirmPassword
}
@State private var email = ""
@State private var password = ""
@State private var confirmPassword = ""
@FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("メールアドレス", text: $email)
.focused($focusedField, equals: .email)
.textContentType(.username)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.next)
.onSubmit { focusedField = .password }
SecureField("パスワード", text: $password)
.focused($focusedField, equals: .password)
.textContentType(.newPassword) // ✅ 新規パスワード
.submitLabel(.next)
.onSubmit { focusedField = .confirmPassword }
SecureField("パスワード(確認)", text: $confirmPassword)
.focused($focusedField, equals: .confirmPassword)
.textContentType(.newPassword) // ✅ 新規パスワード
.submitLabel(.done)
.onSubmit { signUp() }
}
}
private func signUp() {
// 登録処理
}
}ワンタイムパスワード(OTP)入力
SMSやメールで送信されたコードの入力。
struct OTPInputView: View {
@State private var code = ""
@FocusState private var isFocused: Bool
var body: some View {
VStack(spacing: 24) {
Text("認証コードを入力")
.font(.headline)
TextField("000000", text: $code)
.focused($isFocused)
.textContentType(.oneTimeCode) // ✅ OTP自動入力対応
.keyboardType(.numberPad) // 数字キーボード
.multilineTextAlignment(.center)
.font(.title.monospaced())
.frame(maxWidth: 200)
.onChange(of: code) { _, newValue in
// 6桁入力で自動送信
if newValue.count == 6 {
verifyCode()
}
}
Text("SMSで送信されたコードを入力してください")
.font(.caption)
.foregroundColor(.secondary)
}
.onAppear {
isFocused = true
}
}
private func verifyCode() {
// 検証処理
}
}textContentType 一覧
| 用途 | textContentType | 説明 |
|---|---|---|
| ユーザー名/メール | .username | ログイン時のID入力 |
| 既存パスワード | .password | ログイン時のパスワード |
| 新規パスワード | .newPassword | 登録・変更時のパスワード |
| ワンタイムコード | .oneTimeCode | SMS/メール認証コード |
| メールアドレス | .emailAddress | メール専用入力 |
| 電話番号 | .telephoneNumber | 電話番号入力 |
| 名前 | .name | フルネーム |
| 姓 | .familyName | 姓のみ |
| 名 | .givenName | 名のみ |
FocusStateのベストプラクティス
// ✅ 推奨: enumでフォーカス状態を管理
enum Field: Hashable {
case username
case password
}
@FocusState private var focusedField: Field?
// フォーカス移動
focusedField = .password
// キーボードを閉じる
focusedField = nil
// ❌ 避けるべき: 複数のBool FocusState
@FocusState private var isUsernameFocused: Bool
@FocusState private var isPasswordFocused: Bool
// → 状態管理が複雑になり、同時に複数がtrueになる可能性キーボードのReturnキー設定
| submitLabel | 表示 | 用途 |
|---|---|---|
.next | 次へ | 次のフィールドへ移動 |
.done | 完了 | 入力完了 |
.go | Go | アクション実行 |
.send | 送信 | メッセージ送信 |
.search | 検索 | 検索実行 |
.continue | 続ける | 次のステップへ |
.join | 参加 | 参加アクション |
.return | 改行 | デフォルト |
アクセシビリティ対応
TextField("メールアドレス", text: $email)
.textContentType(.username)
.accessibilityLabel("メールアドレス入力欄")
.accessibilityHint("ログインに使用するメールアドレスを入力してください")
SecureField("パスワード", text: $password)
.textContentType(.password)
.accessibilityLabel("パスワード入力欄")
// パスワードの内容は読み上げられない(セキュリティ)SwiftUI リスト・データ表示パターン
1-1. 無限スクロール(Pagination)
基本実装
@MainActor
class ArticleListViewModel: ObservableObject {
@Published var articles: [Article] = []
@Published var isLoading = false
@Published var hasMorePages = true
private var currentPage = 1
private let pageSize = 20
func loadMoreIfNeeded(currentItem: Article?) async {
guard !isLoading, hasMorePages else { return }
// 最後から5番目に到達したら次を読み込み
guard let currentItem = currentItem,
let index = articles.firstIndex(where: { $0.id == currentItem.id }),
index >= articles.count - 5 else {
return
}
await loadMore()
}
func loadMore() async {
isLoading = true
defer { isLoading = false }
do {
let newArticles = try await articleService.fetch(
page: currentPage,
limit: pageSize
)
articles.append(contentsOf: newArticles)
currentPage += 1
hasMorePages = newArticles.count == pageSize
} catch {
// エラーハンドリング
}
}
}
struct ArticleListView: View {
@StateObject private var viewModel = ArticleListViewModel()
var body: some View {
List {
ForEach(viewModel.articles) { article in
ArticleRow(article: article)
.task {
await viewModel.loadMoreIfNeeded(currentItem: article)
}
}
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
}
}
.task {
await viewModel.loadMore()
}
}
}---
1-2. プルトゥリフレッシュ
struct ArticleListView: View {
@StateObject private var viewModel = ArticleListViewModel()
var body: some View {
List(viewModel.articles) { article in
ArticleRow(article: article)
}
.refreshable {
await viewModel.refresh()
}
}
}
// ViewModel
func refresh() async {
currentPage = 1
hasMorePages = true
do {
articles = try await articleService.fetch(page: 1, limit: pageSize)
currentPage = 2
} catch {
// エラーハンドリング
}
}---
1-3. フィルタリング・ソート
struct FilterableListView: View {
@StateObject private var viewModel = ItemListViewModel()
@State private var selectedFilter: Filter = .all
@State private var sortOrder: SortOrder = .dateDescending
enum Filter: String, CaseIterable {
case all = "すべて"
case active = "アクティブ"
case completed = "完了"
}
enum SortOrder: String, CaseIterable {
case dateDescending = "新しい順"
case dateAscending = "古い順"
case nameAscending = "名前順"
}
var filteredItems: [Item] {
var items = viewModel.items
// フィルタ適用
switch selectedFilter {
case .active:
items = items.filter { !$0.isCompleted }
case .completed:
items = items.filter { $0.isCompleted }
case .all:
break
}
// ソート適用
switch sortOrder {
case .dateDescending:
items.sort { $0.date > $1.date }
case .dateAscending:
items.sort { $0.date < $1.date }
case .nameAscending:
items.sort { $0.name < $1.name }
}
return items
}
var body: some View {
List(filteredItems) { item in
ItemRow(item: item)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Picker("フィルタ", selection: $selectedFilter) {
ForEach(Filter.allCases, id: \.self) { filter in
Text(filter.rawValue).tag(filter)
}
}
Picker("並び替え", selection: $sortOrder) {
ForEach(SortOrder.allCases, id: \.self) { order in
Text(order.rawValue).tag(order)
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}
}
}---
1-4. スワイプアクション
List {
ForEach(items) { item in
ItemRow(item: item)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteItem(item)
} label: {
Label("削除", systemImage: "trash")
}
Button {
archiveItem(item)
} label: {
Label("アーカイブ", systemImage: "archivebox")
}
.tint(.orange)
}
.swipeActions(edge: .leading) {
Button {
toggleFavorite(item)
} label: {
Label(
item.isFavorite ? "お気に入り解除" : "お気に入り",
systemImage: item.isFavorite ? "star.slash" : "star"
)
}
.tint(.yellow)
}
}
}---
1-5. コンテキストメニュー
ArticleRow(article: article)
.contextMenu {
Button {
shareArticle(article)
} label: {
Label("共有", systemImage: "square.and.arrow.up")
}
Button {
toggleFavorite(article)
} label: {
Label(
article.isFavorite ? "お気に入り解除" : "お気に入り",
systemImage: article.isFavorite ? "star.slash" : "star"
)
}
Divider()
Button(role: .destructive) {
deleteArticle(article)
} label: {
Label("削除", systemImage: "trash")
}
}SwiftUI ナビゲーション・画面遷移パターン
2-1. タブビュー
struct MainTabView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("ホーム", systemImage: "house")
}
.tag(0)
SearchView()
.tabItem {
Label("検索", systemImage: "magnifyingglass")
}
.tag(1)
ProfileView()
.tabItem {
Label("プロフィール", systemImage: "person")
}
.tag(2)
}
}
// プログラマティックにタブ切り替え
func switchToSearch() {
selectedTab = 1
}
}---
2-2. ナビゲーション(NavigationStack)
enum Route: Hashable {
case articleDetail(Article)
case userProfile(userId: String)
case settings
}
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
ArticleListView(onSelectArticle: { article in
path.append(Route.articleDetail(article))
})
.navigationDestination(for: Route.self) { route in
switch route {
case .articleDetail(let article):
ArticleDetailView(article: article)
case .userProfile(let userId):
UserProfileView(userId: userId)
case .settings:
SettingsView()
}
}
}
}
// ディープリンク対応
func handleDeepLink(url: URL) {
guard let route = parseDeepLink(url) else { return }
path.append(route)
}
func popToRoot() {
path.removeLast(path.count)
}
}---
2-3. モーダル表示
Sheet
struct ContentView: View {
@State private var showSheet = false
@State private var selectedItem: Item?
var body: some View {
List(items) { item in
Button(item.name) {
selectedItem = item
}
}
.sheet(item: $selectedItem) { item in
ItemDetailView(item: item)
}
// または isPresented
Button("新規作成") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
CreateItemView()
}
}
}FullScreenCover
.fullScreenCover(isPresented: $showFullScreen) {
FullScreenView()
}閉じる処理
struct SheetView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Content()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("キャンセル") {
dismiss()
}
}
}
}
}
}---
2-4. アラート・ダイアログ
struct ContentView: View {
@State private var showAlert = false
@State private var showConfirmation = false
var body: some View {
VStack {
Button("アラート表示") {
showAlert = true
}
.alert("タイトル", isPresented: $showAlert) {
Button("OK") { }
} message: {
Text("メッセージ内容")
}
Button("確認ダイアログ") {
showConfirmation = true
}
.confirmationDialog("選択してください", isPresented: $showConfirmation) {
Button("編集") { }
Button("削除", role: .destructive) { }
Button("キャンセル", role: .cancel) { }
}
}
}
}SwiftUI View構築・レイアウトパターン
4-1. AsyncImage(キャッシュ付き)
基本使用
AsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "photo")
.foregroundColor(.gray)
@unknown default:
EmptyView()
}
}
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8))キャッシュ付き実装
struct CachedAsyncImage: View {
let url: URL?
@State private var image: Image?
var body: some View {
Group {
if let image = image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
ProgressView()
.task {
await loadImage()
}
}
}
}
private func loadImage() async {
guard let url = url else { return }
// キャッシュチェック
if let cached = ImageCache.shared.get(for: url) {
image = cached
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let uiImage = UIImage(data: data) {
let loadedImage = Image(uiImage: uiImage)
ImageCache.shared.set(loadedImage, for: url)
image = loadedImage
}
} catch {
// エラーハンドリング
}
}
}
actor ImageCache {
static let shared = ImageCache()
private var cache: [URL: Image] = [:]
func get(for url: URL) -> Image? {
cache[url]
}
func set(_ image: Image, for url: URL) {
cache[url] = image
}
}---
4-2. 空状態・エラー状態
struct ContentStateView<Content: View, Empty: View, Loading: View, Error: View>: View {
let state: ContentState
let content: () -> Content
let empty: () -> Empty
let loading: () -> Loading
let error: (Swift.Error) -> Error
var body: some View {
switch state {
case .idle:
empty()
case .loading:
loading()
case .loaded:
content()
case .error(let err):
error(err)
}
}
}
enum ContentState {
case idle
case loading
case loaded
case error(Error)
}
// 使用例
struct ArticleListView: View {
@StateObject private var viewModel = ArticleListViewModel()
var body: some View {
ContentStateView(state: viewModel.state) {
List(viewModel.articles) { article in
ArticleRow(article: article)
}
} empty: {
EmptyStateView(
icon: "doc.text",
title: "記事がありません",
message: "新しい記事を追加してください"
)
} loading: {
ProgressView("読み込み中...")
} error: { error in
ErrorStateView(
error: error,
onRetry: {
Task { await viewModel.fetch() }
}
)
}
}
}
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
var body: some View {
VStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 60))
.foregroundColor(.gray)
Text(title)
.font(.title2)
.fontWeight(.semibold)
Text(message)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
}
struct ErrorStateView: View {
let error: Error
let onRetry: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 60))
.foregroundColor(.orange)
Text("エラーが発生しました")
.font(.title2)
.fontWeight(.semibold)
Text(error.localizedDescription)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("再試行") {
onRetry()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}---
4-3. アニメーション
struct AnimatedView: View {
@State private var isExpanded = false
var body: some View {
VStack {
Button("トグル") {
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
isExpanded.toggle()
}
}
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue)
.frame(
width: isExpanded ? 200 : 100,
height: isExpanded ? 200 : 100
)
}
}
}
// Reduce Motion対応
struct AccessibleAnimatedView: View {
@Environment(\.accessibilityReduceMotion) var reduceMotion
@State private var isVisible = false
var body: some View {
Text("Hello")
.opacity(isVisible ? 1 : 0)
.animation(reduceMotion ? nil : .easeInOut, value: isVisible)
}
}---
4-4. View重なりパターン(ZStack / overlay / background)
使い分けガイド
| 方法 | サイズ決定 | 用途 |
|---|---|---|
ZStack | 最大の子に合わせる | 複数の独立したViewを重ねる |
.overlay | ベースViewに合わせる | 前面に装飾・バッジを追加 |
.background | ベースViewに合わせる | 背景として配置 |
ZStack: 複数の独立したViewを重ねる
// 複数の独立した要素を重ねる
ZStack(alignment: .topTrailing) {
Image("photo")
.resizable()
.aspectRatio(contentMode: .fill)
// お気に入りボタン
Button(action: toggleFavorite) {
Image(systemName: "heart.fill")
.foregroundColor(.red)
}
.padding(8)
}overlay: ベースViewの前面に装飾
// バッジ付きアイコン
Image(systemName: "bell")
.font(.title)
.overlay(alignment: .topTrailing) {
// バッジはベースアイコンのサイズに影響しない
Text("3")
.font(.caption2)
.padding(4)
.background(Color.red)
.clipShape(Circle())
.offset(x: 8, y: -8)
}
// ローディングオーバーレイ
List(items) { item in
ItemRow(item: item)
}
.overlay {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.3))
}
}background: ベースViewの背景
// カード背景
Text("Hello, World!")
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white)
.shadow(radius: 4)
}
// グラデーション背景
Text("Gradient")
.padding()
.background {
LinearGradient(
colors: [.blue, .purple],
startPoint: .leading,
endPoint: .trailing
)
}選択の指針
// ✅ ZStack: 両方のサイズが重要、または複数要素を対等に扱う
ZStack {
BackgroundView()
ContentView()
OverlayView()
}
// ✅ overlay: メインコンテンツに付加的な要素を追加
MainContent()
.overlay(alignment: .bottomTrailing) {
FloatingActionButton()
}
// ✅ background: メインコンテンツに背景を追加
MainContent()
.background {
BackgroundEffect()
}---
4-5. ContentUnavailableView パターン(iOS 17+)
基本的な使い方
// システム提供のスタイル
ContentUnavailableView.search // 検索結果なし
ContentUnavailableView.search(text: query) // 検索クエリ付き
// カスタムメッセージ
ContentUnavailableView(
"データがありません",
systemImage: "tray",
description: Text("新しいアイテムを追加してください")
)overlayを使った責務分離パターン
メインコンテンツと空/エラー状態を分離して可読性を向上。
// ✅ 推奨: overlayで分離
struct ItemListView: View {
@State private var items: [Item] = []
@State private var searchText = ""
@State private var error: Error?
var filteredItems: [Item] {
if searchText.isEmpty {
return items
}
return items.filter { $0.name.contains(searchText) }
}
var body: some View {
NavigationStack {
List(filteredItems) { item in
ItemRow(item: item)
}
.searchable(text: $searchText)
// 空状態・エラー状態をoverlayで分離
.overlay {
if let error {
ContentUnavailableView(
"読み込みエラー",
systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription)
)
} else if items.isEmpty {
ContentUnavailableView(
"アイテムがありません",
systemImage: "tray",
description: Text("右上の+ボタンから追加できます")
)
} else if filteredItems.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
}
}
}
// ❌ 避けるべき: if-elseで分岐(可読性低下)
var body: some View {
if let error {
ErrorView(error: error)
} else if items.isEmpty {
EmptyView()
} else if filteredItems.isEmpty {
SearchEmptyView()
} else {
List(filteredItems) { ... } // メインロジックが深くネスト
}
}アクションボタン付き
ContentUnavailableView {
Label("接続エラー", systemImage: "wifi.slash")
} description: {
Text("インターネット接続を確認してください")
} actions: {
Button("再試行") {
Task { await retry() }
}
.buttonStyle(.borderedProminent)
}iOS 16以前との互換性
struct ContentUnavailableViewCompat<Label: View, Description: View, Actions: View>: View {
let label: Label
let description: Description
let actions: Actions
init(
@ViewBuilder label: () -> Label,
@ViewBuilder description: () -> Description = { EmptyView() },
@ViewBuilder actions: () -> Actions = { EmptyView() }
) {
self.label = label()
self.description = description()
self.actions = actions()
}
var body: some View {
VStack(spacing: 16) {
label
.font(.title)
.foregroundColor(.secondary)
description
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
actions
}
.padding()
}
}