Mobile Development

SwiftUI Advanced Patterns

TOKEN

Pola lanjutan SwiftUI — MVVM dengan @Observable, NavigationStack, animasi, widget development, app clips, dan testing

📋 Daftar Isi
  1. MVVM Pattern
  2. @Observable Macro
  3. Navigation
  4. Animations
  5. Widget Development
  6. App Clips
  7. Testing SwiftUI
  8. Tips & Trik
  9. Quiz Pemahaman

1. MVVM Pattern di SwiftUI

SwiftUI secara natural mendukung pola MVVM (Model-View-ViewModel). Dengan @Observable macro (iOS 17+), binding antara View dan ViewModel menjadi sangat elegan.

MVVM Architecture
📊
Model
Data structures
Business entities
🧠
ViewModel
@Observable
Business logic
🖼️
View
SwiftUI Views
Declarative UI
Swift — @Observable ViewModel
import SwiftUI

// Model
struct Task: Identifiable, Codable {
    let id: UUID
    var title: String
    var isCompleted: Bool
    var dueDate: Date?
}

// ViewModel dengan @Observable (iOS 17+)
@Observable
class TaskViewModel {
    var tasks: [Task] = []
    var isLoading = false
    var errorMessage: String?

    private let repository: TaskRepository

    init(repository: TaskRepository = TaskRepository()) {
        self.repository = repository
    }

    var completedTasks: [Task] {
        tasks.filter { $0.isCompleted }
    }

    var pendingTasks: [Task] {
        tasks.filter { !$0.isCompleted }
    }

    func loadTasks() async {
        isLoading = true
        defer { isLoading = false }

        do {
            tasks = try await repository.fetchTasks()
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func toggleTask(_ task: Task) {
        guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return }
        tasks[index].isCompleted.toggle()
    }

    func addTask(title: String) {
        let newTask = Task(id: UUID(), title: title, isCompleted: false)
        tasks.append(newTask)
    }

    func deleteTask(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets)
    }
}
Swift — SwiftUI View
struct TaskListView: View {
    @State private var viewModel = TaskViewModel()
    @State private var newTaskTitle = ""

    var body: some View {
        NavigationStack {
            List {
                // Pending tasks
                Section("Tertunda (\(viewModel.pendingTasks.count))") {
                    ForEach(viewModel.pendingTasks) { task in
                        TaskRow(task: task) {
                            viewModel.toggleTask(task)
                        }
                    }
                }

                // Completed tasks
                if !viewModel.completedTasks.isEmpty {
                    Section("Selesai (\(viewModel.completedTasks.count))") {
                        ForEach(viewModel.completedTasks) { task in
                            TaskRow(task: task) {
                                viewModel.toggleTask(task)
                            }
                        }
                        .onDelete { offsets in
                            viewModel.deleteTask(at: offsets)
                        }
                    }
                }
            }
            .navigationTitle("Tugas Saya")
            .overlay {
                if viewModel.isLoading {
                    ProgressView("Memuat...")
                }
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button(action: { /* show add dialog */ }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .task {
                await viewModel.loadTasks()
            }
        }
    }
}

2. @Observable Macro

iOS 17 memperkenalkan @Observable macro yang menggantikan ObservableObject. Perubahan properti otomatis memicu update View tanpa perlu @Published.

Fitur@ObservableObject (lama)@Observable (baru)
Property wrapper@Published, @ObservedObjectTidak perlu
ObservationSeluruh objectProperty-level granularity
PerformanceRe-render semuaHanya view yang berubah
Injection@StateObject, @EnvironmentObject@State, @Environment
Swift — Migration ke @Observable
// LAMA: ObservableObject
class OldViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var isLoading = false
}

struct OldView: View {
    @StateObject var viewModel = OldViewModel()  // di root
    @ObservedObject var viewModel = viewModel    // di child
}

// BARU: @Observable
@Observable
class NewViewModel {
    var items: [Item] = []
    var isLoading = false
}

struct NewView: View {
    @State var viewModel = NewViewModel()  // di root
    var viewModel: NewViewModel            // di child, tanpa wrapper
}

SwiftUI menyediakan NavigationStack dan NavigationSplitView untuk navigasi modern berbasis data.

Swift — NavigationStack
struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List(departments) { dept in
                NavigationLink(value: dept) {
                    Label(dept.name, systemImage: dept.icon)
                }
            }
            .navigationTitle("Departemen")
            .navigationDestination(for: Department.self) { dept in
                EmployeeListView(department: dept)
            }
            .navigationDestination(for: Employee.self) { emp in
                EmployeeDetailView(employee: emp)
            }
        }
    }
}

// Programmatic navigation
func goToDetail() {
    path.append(selectedDepartment)
}

func goBack() {
    path.removeLast()
}

func goToRoot() {
    path = NavigationPath()
}
Swift — NavigationSplitView (iPad/Mac)
struct SplitView: View {
    @State private var selection: Category?
    @State private var detail: Item?

    var body: some View {
        NavigationSplitView {
            List(categories, selection: $selection) { cat in
                Text(cat.name)
            }
            .navigationTitle("Kategori")
        } content: {
            if let selection {
                List(items(for: selection), selection: $detail) { item in
                    Text(item.name)
                }
            } else {
                Text("Pilih kategori")
            }
        } detail: {
            if let detail {
                ItemDetailView(item: detail)
            } else {
                Text("Pilih item")
            }
        }
    }
}

4. Animations

Swift — SwiftUI Animations
struct AnimatedDemo: View {
    @State private var isExpanded = false
    @State private var rotation = 0.0

    var body: some View {
        VStack(spacing: 30) {
            // Implicit animation
            Circle()
                .fill(.blue.gradient)
                .frame(width: isExpanded ? 200 : 100)
                .animation(.spring(response: 0.5, dampingFraction: 0.6), value: isExpanded)
                .onTapGesture { isExpanded.toggle() }

            // Phase animation
            PhaseAnimator([0, 1, 0]) { phase in
                Image(systemName: "heart.fill")
                    .font(.system(size: 60))
                    .foregroundStyle(.red)
                    .scaleEffect(phase)
                    .opacity(phase)
            }

            // Transition
            if isExpanded {
                Text("Hello!")
                    .font(.title)
                    .transition(.asymmetric(
                        insertion: .scale.combined(with: .opacity),
                        removal: .move(edge: .bottom).combined(with: .opacity)
                    ))
            }

            // Keyframe animation
            Image(systemName: "star.fill")
                .font(.system(size: 50))
                .keyframeAnimator(
                    initialValue: KeyframeValues(),
                    repeating: true
                ) { view, value in
                    view
                        .scaleEffect(value.scale)
                        .rotationEffect(.degrees(value.rotation))
                } keyframes: { _ in
                    KeyframeTrack(\.scale) {
                        SpringKeyframe(1.5, duration: 0.3)
                        SpringKeyframe(1.0, duration: 0.3)
                    }
                    KeyframeTrack(\.rotation) {
                        LinearKeyframe(360, duration: 0.6)
                    }
                }
        }
    }
}

5. Widget Development

App Widgets menampilkan informasi ringkas di Home Screen menggunakan WidgetKit dan App Intents.

Swift — Widget Implementation
import WidgetKit
import SwiftUI

// Timeline Provider
struct TaskWidgetProvider: AppIntentTimelineProvider {
    func timeline(for config: TaskWidgetIntent, in context: Context) async -> Timeline {
        let tasks = TaskRepository.shared.fetchPendingTasks()
        let entry = TaskEntry(
            date: .now,
            pendingCount: tasks.count,
            nextTask: tasks.first?.title ?? "Tidak ada tugas"
        )
        let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
        return Timeline(entries: [entry], policy: .after(nextUpdate))
    }
}

struct TaskEntry: TimelineEntry {
    let date: Date
    let pendingCount: Int
    let nextTask: String
}

// Widget View
struct TaskWidget: Widget {
    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: "TaskWidget",
            intent: TaskWidgetIntent.self,
            provider: TaskWidgetProvider()
        ) { entry in
            TaskWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Tugas")
        .description("Lihat tugas yang tertunda")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

struct TaskWidgetView: View {
    let entry: TaskEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Label("Tugas", systemImage: "checklist")
                .font(.headline)
            Text("\(entry.pendingCount) tugas tertunda")
                .font(.title2.bold())
            Text(entry.nextTask)
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(1)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

6. App Clips

App Clips adalah bagian kecil dari app yang bisa di-launch tanpa install full app — ideal untuk pembayaran, pemesanan, dan akses informasi cepat.

AspekApp ClipFull App
Ukuran≤ 15 MBTidak terbatas
InstallAutomatic (via link)Manual via App Store
Notifications8 jam setelah launchUnlimited
Data sharingShared containerFull access
Use caseQuick actionFull experience
Swift — App Clip Entry Point
import SwiftUI

@main
struct MyAppClip: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    handleURL(activity.webpageURL)
                }
        }
    }

    func handleURL(_ url: URL?) {
        guard let url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
        // Parse path: /menu/item/123
        let pathComponents = components.path.split(separator: "/")
        if pathComponents.count >= 2, pathComponents[0] == "menu" {
            let itemId = String(pathComponents[1])
            // Navigate to item detail
        }
    }
}
💡 App Clip Tips

Gunakan ASWebAuthenticationSession untuk login di App Clip. Data yang disimpan di shared App Group container akan tersedia di full app jika user menginstall.

7. Testing SwiftUI

Swift — SwiftUI Preview & Testing
// Preview dengan berbagai state
#Preview("Empty State") {
    TaskListView()
        .environment(TaskViewModel.previewEmpty)
}

#Preview("With Data") {
    TaskListView()
        .environment(TaskViewModel.previewWithData)
}

// Unit Test ViewModel
import Testing

@Suite("TaskViewModel Tests")
struct TaskViewModelTests {
    @Test("Add task increases count")
    func addTask() {
        let vm = TaskViewModel()
        vm.addTask(title: "Test")
        #expect(vm.tasks.count == 1)
        #expect(vm.tasks.first?.title == "Test")
    }

    @Test("Toggle task changes state")
    func toggleTask() {
        let vm = TaskViewModel()
        vm.addTask(title: "Test")
        vm.toggleTask(vm.tasks[0])
        #expect(vm.tasks[0].isCompleted == true)
    }
}

7.1 Accessibility Testing

Swift — Accessibility
struct AccessibleCard: View {
    let title: String
    let value: String

    var body: some View {
        VStack(alignment: .leading) {
            Text(title).font(.headline)
            Text(value).font(.title.bold())
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(title): \(value)")
        .accessibilityAddTraits(.isHeader)
    }
}

8. Tips & Trik Lanjutan

TipsPenjelasan
@State di root, tanpa wrapper di child@Observable tidak perlu @ObservedObject
Gunakan .task {} bukan .onAppearSupport async/await & auto-cancel
.sensoryFeedback untuk hapticNative haptic feedback di iOS 17+
ContentUnavailableViewStandard empty state view
.containerRelativeFrameResponsive sizing tanpa GeometryReader

Quiz Pemahaman

Pertanyaan 1: Macro apa yang menggantikan ObservableObject di iOS 17?

a) @State
b) @Binding
c) @Observable
d) @Published

Pertanyaan 2: View apa untuk navigasi berbasis data di SwiftUI?

a) UINavigationController
b) NavigationStack
c) NavigationView
d) Router

Pertanyaan 3: Ukuran maksimum App Clip?

a) 5 MB
b) 10 MB
c) 15 MB
d) 25 MB

Pertanyaan 4: Widget provider protocol apa yang digunakan dengan App Intents?

a) TimelineProvider
b) IntentTimelineProvider
c) AppIntentTimelineProvider
d) WidgetProvider

Pertanyaan 5: Fungsi apa untuk animasi berbasis keyframe di SwiftUI?

a) .animation()
b) .transition()
c) .keyframeAnimator()
d) .withAnimation()
← SebelumnyaKembali ke Beranda Selanjutnya →Lihat Kategori
🔍 Zoom
100%
🎨 Tema