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.
Business entities
Business logic
Declarative UI
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)
}
}
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, @ObservedObject | Tidak perlu |
| Observation | Seluruh object | Property-level granularity |
| Performance | Re-render semua | Hanya view yang berubah |
| Injection | @StateObject, @EnvironmentObject | @State, @Environment |
// 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
}
3. Navigation
SwiftUI menyediakan NavigationStack dan NavigationSplitView untuk navigasi modern berbasis data.
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()
}
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
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.
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.
| Aspek | App Clip | Full App |
|---|---|---|
| Ukuran | ≤ 15 MB | Tidak terbatas |
| Install | Automatic (via link) | Manual via App Store |
| Notifications | 8 jam setelah launch | Unlimited |
| Data sharing | Shared container | Full access |
| Use case | Quick action | Full experience |
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
}
}
}
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
// 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
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
| Tips | Penjelasan |
|---|---|
| @State di root, tanpa wrapper di child | @Observable tidak perlu @ObservedObject |
| Gunakan .task {} bukan .onAppear | Support async/await & auto-cancel |
| .sensoryFeedback untuk haptic | Native haptic feedback di iOS 17+ |
| ContentUnavailableView | Standard empty state view |
| .containerRelativeFrame | Responsive sizing tanpa GeometryReader |