| name | swiftui-patterns |
| description | SwiftUI開発パターン・ベストプラクティス。状態管理、ナビゲーション、レイアウト、アニメーション、パフォーマンス最適化など、モダンなSwiftUIアプリケーション開発の実践的なガイド。 |
SwiftUI Patterns Skill
📋 目次
概要
SwiftUIアプリケーション開発における実践的なパターンとベストプラクティスを提供します。
対象:
- SwiftUIアプリケーション開発者
- iOSエンジニア
- モバイルアプリアーキテクト
このSkillでできること:
- 適切な状態管理パターンの選択と実装
- スケーラブルなナビゲーション設計
- パフォーマンスの高いUIの構築
- 保守性の高いコードベースの維持
状態管理パターン
@State - ローカル状態
使用場面:
- 単一Viewに閉じた状態
- シンプルな値型の管理
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}
ベストプラクティス:
- private修飾子を付ける
- 値型(struct, enum, Int, String等)に使用
- View階層外に公開しない
@Binding - 状態の共有
使用場面:
- 親Viewから状態を受け取る
- 双方向データバインディング
struct ToggleView: View {
@Binding var isOn: Bool
var body: some View {
Toggle("Setting", isOn: $isOn)
}
}
struct ParentView: View {
@State private var setting = false
var body: some View {
ToggleView(isOn: $setting)
}
}
ベストプラクティス:
- 状態の所有権を明確にする
- データの流れを一方向に保つ
- プレビューでは.constant()を使用
@StateObject - 参照型の状態管理
使用場面:
- ObservableObjectのライフサイクル管理
- View所有の複雑な状態
class TimerManager: ObservableObject {
@Published var seconds = 0
private var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.seconds += 1
}
}
func stop() {
timer?.invalidate()
}
}
struct TimerView: View {
@StateObject private var timerManager = TimerManager()
var body: some View {
VStack {
Text("\(timerManager.seconds)")
Button("Start") { timerManager.start() }
Button("Stop") { timerManager.stop() }
}
}
}
ベストプラクティス:
- View所有のオブジェクトに使用
- 初期化は@StateObjectで行う
- 親から受け取る場合は@ObservedObject使用
@ObservedObject - 外部所有の状態
使用場面:
- 親から受け取ったObservableObject
- 複数Viewで共有される状態
struct SettingsView: View {
@ObservedObject var settings: AppSettings
var body: some View {
Form {
Toggle("Notifications", isOn: $settings.notificationsEnabled)
Toggle("Dark Mode", isOn: $settings.darkModeEnabled)
}
}
}
@EnvironmentObject - グローバル状態
使用場面:
- アプリ全体で共有される状態
- 深い階層への状態の伝播
class UserSession: ObservableObject {
@Published var isLoggedIn = false
@Published var username = ""
}
@main
struct MyApp: App {
@StateObject private var session = UserSession()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(session)
}
}
}
struct ProfileView: View {
@EnvironmentObject var session: UserSession
var body: some View {
Text("Hello, \(session.username)")
}
}
ベストプラクティス:
- 本当に必要な場合のみ使用
- プレビューでの注入を忘れない
- 依存関係を明示的にする
ナビゲーション
NavigationStack(iOS 16+)
基本パターン:
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
NavigationLink("Settings", value: Route.settings)
NavigationLink("Profile", value: Route.profile)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .settings:
SettingsView()
case .profile:
ProfileView()
}
}
}
}
}
enum Route: Hashable {
case settings
case profile
}
プログラマティックナビゲーション:
struct MainView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack {
Button("Go to Detail") {
path.append(DetailRoute.detail(id: 1))
}
Button("Go Deep") {
path.append(DetailRoute.detail(id: 1))
path.append(DetailRoute.subDetail(id: 2))
}
Button("Pop to Root") {
path.removeLast(path.count)
}
}
.navigationDestination(for: DetailRoute.self) { route in
DetailView(route: route)
}
}
}
}
Modal Presentation
Sheet:
struct ContentView: View {
@State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet = true
}
.sheet(isPresented: $showingSheet) {
SheetView()
}
}
}
FullScreenCover:
struct ContentView: View {
@State private var showingFullScreen = false
var body: some View {
Button("Show Full Screen") {
showingFullScreen = true
}
.fullScreenCover(isPresented: $showingFullScreen) {
FullScreenView()
}
}
}
レイアウトシステム
Stack Layouts
VStack - 垂直配置:
VStack(alignment: .leading, spacing: 16) {
Text("Title")
.font(.headline)
Text("Subtitle")
.font(.subheadline)
Text("Body")
.font(.body)
}
HStack - 水平配置:
HStack(alignment: .center, spacing: 8) {
Image(systemName: "star.fill")
Text("Favorite")
Spacer()
Text("100")
}
ZStack - 重ね配置:
ZStack(alignment: .bottomTrailing) {
Image("background")
.resizable()
.aspectRatio(contentMode: .fill)
Text("Overlay")
.padding()
.background(.ultraThinMaterial)
}
Custom Layout(iOS 16+)
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
let height = rows.reduce(0) { $0 + $1.height } + CGFloat(rows.count - 1) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY
for row in rows {
var x = bounds.minX
for index in row.indices {
subviews[index].place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += subviews[index].sizeThatFits(.unspecified).width + spacing
}
y += row.height + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[Int]] {
// Flow layout implementation
[]
}
}
GeometryReader
適切な使用例:
struct AdaptiveView: View {
var body: some View {
GeometryReader { geometry in
if geometry.size.width > 600 {
HStack {
Sidebar()
Content()
}
} else {
VStack {
Content()
}
}
}
}
}
避けるべきパターン:
// ❌ 不必要なGeometryReader
GeometryReader { geometry in
Text("Hello")
.frame(width: geometry.size.width) // .frame(maxWidth: .infinity)で十分
}
// ✅ より良い方法
Text("Hello")
.frame(maxWidth: .infinity)
データフロー
MVVM パターン
// Model
struct User: Identifiable {
let id: UUID
var name: String
var email: String
}
// ViewModel
class UserListViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var error: Error?
private let repository: UserRepository
init(repository: UserRepository = .shared) {
self.repository = repository
}
@MainActor
func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await repository.fetchUsers()
} catch {
self.error = error
}
}
}
// View
struct UserListView: View {
@StateObject private var viewModel = UserListViewModel()
var body: some View {
List(viewModel.users) { user in
UserRow(user: user)
}
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
.task {
await viewModel.loadUsers()
}
.alert(error: $viewModel.error)
}
}
Unidirectional Data Flow
// State
struct AppState {
var users: [User] = []
var isLoading = false
}
// Action
enum AppAction {
case loadUsers
case usersLoaded([User])
case usersFailed(Error)
}
// Reducer
func appReducer(state: inout AppState, action: AppAction) {
switch action {
case .loadUsers:
state.isLoading = true
case .usersLoaded(let users):
state.users = users
state.isLoading = false
case .usersFailed:
state.isLoading = false
}
}
// Store
class Store: ObservableObject {
@Published private(set) var state = AppState()
func send(_ action: AppAction) {
appReducer(state: &state, action: action)
}
}
パフォーマンス最適化
不要な再描画を避ける
EquatableView:
struct ExpensiveView: View, Equatable {
let data: ComplexData
var body: some View {
// 重い描画処理
ComplexRenderingView(data: data)
}
static func == (lhs: ExpensiveView, rhs: ExpensiveView) -> Bool {
lhs.data.id == rhs.data.id
}
}
struct ParentView: View {
@State private var counter = 0
let data: ComplexData
var body: some View {
VStack {
Text("Counter: \(counter)")
Button("Increment") { counter += 1 }
// dataが変わらない限り再描画されない
EquatableView(data: data)
.equatable()
}
}
}
LazyStack の活用
// ✅ 大量のアイテムにはLazyVStack
ScrollView {
LazyVStack {
ForEach(0..<1000) { index in
RowView(index: index)
}
}
}
// ❌ 全て一度に描画される
ScrollView {
VStack {
ForEach(0..<1000) { index in
RowView(index: index)
}
}
}
@Published の最適化
class ViewModel: ObservableObject {
// ✅ 必要なプロパティのみPublished
@Published var displayText: String = ""
// ❌ 頻繁に変わる内部状態をPublishedにしない
private var internalCounter = 0
func updateDisplay() {
internalCounter += 1
// 10回に1回だけUIを更新
if internalCounter % 10 == 0 {
displayText = "Count: \(internalCounter)"
}
}
}
アニメーション
基本アニメーション
struct AnimatedView: View {
@State private var scale: CGFloat = 1.0
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
scale = scale == 1.0 ? 1.5 : 1.0
}
}
}
}
カスタムトランジション
extension AnyTransition {
static var slideAndFade: AnyTransition {
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
)
}
}
struct ContentView: View {
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.slideAndFade)
}
}
.animation(.easeInOut, value: showDetail)
}
}
Matched Geometry Effect
struct MatchedView: View {
@State private var isExpanded = false
@Namespace private var animation
var body: some View {
if isExpanded {
VStack {
Circle()
.fill(.blue)
.matchedGeometryEffect(id: "circle", in: animation)
.frame(width: 200, height: 200)
Text("Expanded")
}
} else {
Circle()
.fill(.blue)
.matchedGeometryEffect(id: "circle", in: animation)
.frame(width: 50, height: 50)
}
}
}
再利用可能なコンポーネント
View Modifiers
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.white)
.cornerRadius(12)
.shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardStyle())
}
}
// 使用例
Text("Card Content")
.cardStyle()
Custom Containers
struct Section<Content: View, Header: View>: View {
let header: Header
let content: Content
init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header) {
self.content = content()
self.header = header()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
.font(.headline)
content
}
.padding()
.cardStyle()
}
}
// 使用例
Section {
Text("Content here")
} header: {
Text("Title")
}
テスト戦略
ViewInspector でのテスト
import XCTest
import ViewInspector
@testable import MyApp
final class CounterViewTests: XCTestCase {
func testInitialState() throws {
let view = CounterView()
let text = try view.inspect().find(text: "Count: 0")
XCTAssertNotNil(text)
}
func testIncrement() throws {
let view = CounterView()
try view.inspect().find(button: "Increment").tap()
let text = try view.inspect().find(text: "Count: 1")
XCTAssertNotNil(text)
}
}
Snapshot Testing
import SnapshotTesting
import XCTest
final class SnapshotTests: XCTestCase {
func testUserCard() {
let view = UserCard(user: .mock)
assertSnapshot(matching: view, as: .image(layout: .device(config: .iPhone13)))
}
}
よくある問題と解決策
問題1: Viewが予期せず再描画される
原因: 親Viewの状態変更
解決策:
// ❌ 問題のあるコード
struct ParentView: View {
@State private var counter = 0
var body: some View {
VStack {
Text("\(counter)")
ExpensiveChildView() // 毎回再作成される
}
}
}
// ✅ 改善したコード
struct ParentView: View {
@State private var counter = 0
var body: some View {
VStack {
Text("\(counter)")
ExpensiveChildView()
.equatable() // Equatableに準拠させる
}
}
}
問題2: リストのパフォーマンスが悪い
解決策:
// ✅ LazyVStackとonAppear活用
ScrollView {
LazyVStack {
ForEach(items) { item in
RowView(item: item)
.onAppear {
if item == items.last {
loadMore()
}
}
}
}
}
問題3: NavigationStackでメモリリークする
解決策:
// ✅ pathを明示的に管理
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
// ...
}
.onDisappear {
// 必要に応じてクリーンアップ
path = NavigationPath()
}
}
}
関連Skills:
- ios-development - iOS開発全般
- ios-project-setup - プロジェクト初期設定
- testing-strategy - テスト戦略
- frontend-performance - パフォーマンス最適化の考え方
更新履歴:
- 2025-12-24: 初版作成