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
    }
}