Section 9/131 menit
9. Real Use Cases
9. Real Use Cases
Use Case 1: MVVM dengan SwiftUI — E-Commerce Cart
swift
import Observation
struct CartItem: Identifiable, Sendable {
let id: UUID
let product: Product
var quantity: Int
var totalPrice: Double { product.price * Double(quantity) }
}
@Observable
@MainActor
final class CartViewModel {
private(set) var items: [CartItem] = []
var couponCode: String = ""
var isCheckingOut: Bool = false
var checkoutError: String? = nil
// Computed — otomatis di-track, hanya dihitung ulang jika items atau couponCode berubah
var subtotal: Double {
items.reduce(0) { $0 + $1.totalPrice }
}
var discount: Double {
couponCode == "SWIFT6" ? subtotal * 0.1 : 0
}
var total: Double {
subtotal - discount
}
var itemCount: Int {
items.reduce(0) { $0 + $1.quantity }
}
func addItem(_ product: Product) {
if let index = items.firstIndex(where: { $0.product.id == product.id }) {
items[index].quantity += 1
} else {
items.append(CartItem(id: UUID(), product: product, quantity: 1))
}
}
func removeItem(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
}
func checkout() async {
guard !isCheckingOut else { return }
isCheckingOut = true
checkoutError = nil
do {
try await OrderService.shared.placeOrder(items: items, coupon: couponCode)
items.removeAll()
couponCode = ""
} catch {
checkoutError = error.localizedDescription
}
isCheckingOut = false
}
}
// Views — masing-masing hanya re-render berdasarkan property yang dibaca
struct CartSummaryView: View {
var viewModel: CartViewModel // hanya baca total, subtotal, discount
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack { Text("Subtotal"); Spacer(); Text(viewModel.subtotal.currencyFormatted) }
if viewModel.discount > 0 {
HStack { Text("Diskon"); Spacer(); Text("-\(viewModel.discount.currencyFormatted)") }
}
Divider()
HStack { Text("Total").bold(); Spacer(); Text(viewModel.total.currencyFormatted).bold() }
}
// Re-render hanya ketika subtotal, discount, atau total berubah
// (yang terjadi ketika items atau couponCode berubah)
}
}
struct CartBadgeView: View {
var viewModel: CartViewModel // hanya baca itemCount
var body: some View {
Text("\(viewModel.itemCount)")
.font(.caption2)
.padding(4)
.background(.red)
.foregroundStyle(.white)
.clipShape(.circle)
// Re-render HANYA ketika itemCount berubah
// Tidak peduli dengan couponCode, isCheckingOut, dll
}
}
struct CheckoutButton: View {
@Bindable var viewModel: CartViewModel // butuh binding untuk isCheckingOut (jika perlu toggle)
var body: some View {
Button {
Task { await viewModel.checkout() }
} label: {
if viewModel.isCheckingOut {
ProgressView()
} else {
Text("Checkout — \(viewModel.total.currencyFormatted)")
}
}
.disabled(viewModel.isCheckingOut || viewModel.items.isEmpty)
// Re-render hanya ketika isCheckingOut atau items.isEmpty berubah
}
}
Use Case 2: Multi-Screen State Sharing via @Environment
swift
// AuthViewModel di-share ke seluruh app via @Environment
@Observable
@MainActor
final class AuthViewModel {
private(set) var currentUser: AppUser? = nil
private(set) var isAuthenticated: Bool = false
var isLoading: Bool = false
func login(email: String, password: String) async throws {
isLoading = true
defer { isLoading = false }
let user = try await AuthService.shared.login(email: email, password: password)
currentUser = user
isAuthenticated = true
}
func logout() async {
await AuthService.shared.logout()
currentUser = nil
isAuthenticated = false
}
}
@main
struct MyApp: App {
@State private var auth = AuthViewModel()
var body: some Scene {
WindowGroup {
RootView()
.environment(auth)
}
}
}
struct RootView: View {
@Environment(AuthViewModel.self) private var auth
var body: some View {
if auth.isAuthenticated {
MainTabView()
} else {
LoginView()
}
// Re-render hanya ketika isAuthenticated berubah
}
}
struct ProfileView: View {
@Environment(AuthViewModel.self) private var auth
var body: some View {
VStack {
Text(auth.currentUser?.displayName ?? "Guest")
// Re-render hanya ketika currentUser berubah
Button("Logout") { Task { await auth.logout() } }
}
}
}
struct NavigationBarBadge: View {
@Environment(AuthViewModel.self) private var auth
var body: some View {
if auth.isLoading {
ProgressView().scaleEffect(0.7)
}
// Re-render hanya ketika isLoading berubah — tidak peduli currentUser dll
}
}
Use Case 3: Integrasi UIKit dengan withObservationTracking
swift
// ViewModel yang digunakan bersama SwiftUI dan UIKit
@Observable
@MainActor
final class FeedViewModel {
var posts: [Post] = []
var isRefreshing: Bool = false
var unreadCount: Int = 0
private let service: FeedService
init(service: FeedService = .shared) {
self.service = service
}
func refresh() async {
isRefreshing = true
posts = (try? await service.fetchPosts()) ?? posts
isRefreshing = false
}
}
// UIKit ViewController yang mengobservasi FeedViewModel
@MainActor
final class FeedViewController: UIViewController {
private let viewModel: FeedViewModel
private var observationTask: Task<Void, Never>?
private let tableView = UITableView()
private let refreshControl = UIRefreshControl()
private let badgeLabel = UILabel()
init(viewModel: FeedViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
startObserving()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
observationTask?.cancel()
}
private func startObserving() {
observationTask = Task { @MainActor in
// Bungkus withObservationTracking dalam AsyncStream untuk continuous observation
let changes = AsyncStream<Void> { continuation in
func track() {
withObservationTracking {
// Track property yang kita butuhkan
_ = viewModel.posts
_ = viewModel.isRefreshing
_ = viewModel.unreadCount
} onChange: {
continuation.yield(())
Task { @MainActor in track() } // daftar ulang
}
}
track()
}
for await _ in changes {
guard !Task.isCancelled else { break }
applySnapshot()
}
}
}
private func applySnapshot() {
tableView.reloadData() // atau gunakan diffable data source
if viewModel.isRefreshing {
refreshControl.beginRefreshing()
} else {
refreshControl.endRefreshing()
}
badgeLabel.text = viewModel.unreadCount > 0 ? "\(viewModel.unreadCount)" : nil
badgeLabel.isHidden = viewModel.unreadCount == 0
}
@objc private func handleRefresh() {
Task { await viewModel.refresh() }
}
private func setupUI() {
refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
tableView.refreshControl = refreshControl
// ... layout setup
}
}
Use Case 4: Nested Observable — Master-Detail
swift
@Observable
final class FilterState {
var priceRange: ClosedRange<Double> = 0...1000
var selectedBrands: Set<String> = []
var sortOrder: SortOrder = .relevance
var showInStock: Bool = true
var isDefault: Bool {
priceRange == 0...1000 && selectedBrands.isEmpty
&& sortOrder == .relevance && showInStock
}
func reset() {
priceRange = 0...1000
selectedBrands = []
sortOrder = .relevance
showInStock = true
}
}
@Observable
@MainActor
final class ProductListViewModel {
var products: [Product] = []
var isLoading: Bool = false
// Nested @Observable — perubahan di filter.priceRange
// akan otomatis di-track oleh View yang membaca filter.priceRange
var filter = FilterState()
var filteredProducts: [Product] {
products.filter { product in
filter.priceRange.contains(product.price)
&& (filter.selectedBrands.isEmpty || filter.selectedBrands.contains(product.brand))
&& (!filter.showInStock || product.isInStock)
}
}
func loadProducts() async {
isLoading = true
products = (try? await ProductService.shared.fetchAll()) ?? []
isLoading = false
}
}
// View yang membaca filter.priceRange akan re-render jika priceRange berubah,
// tapi TIDAK jika filter.selectedBrands berubah (karena tidak membacanya di body ini)
struct PriceFilterView: View {
@Bindable var filter: FilterState
var body: some View {
RangeSlider(value: $filter.priceRange, in: 0...10000)
// Re-render hanya ketika filter.priceRange berubah
}
}