Section 12/181 menit

12. Integrasi dengan UIKit

12. Integrasi dengan UIKit

SwiftData tidak memiliki equivalent @Query untuk UIKit — developer perlu melakukan fetch manual dan update UI secara eksplisit.

ViewModel Pattern dengan ModelContext

swift
// MARK: - ViewModel

@MainActor
final class BookListViewModel {
    private let modelContext: ModelContext
    private(set) var books: [Book] = []

    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }

    func loadBooks(filter: String = "") throws {
        var descriptor = FetchDescriptor<Book>(
            sortBy: [SortDescriptor(\Book.title)]
        )
        if !filter.isEmpty {
            descriptor.predicate = #Predicate {
                $0.title.localizedStandardContains(filter) ||
                $0.author.localizedStandardContains(filter)
            }
        }
        books = try modelContext.fetch(descriptor)
    }

    func addBook(title: String, author: String, year: Int) throws {
        let book = Book(title: title, author: author, publishedYear: year)
        modelContext.insert(book)
        try modelContext.save()
        try loadBooks()
    }

    func deleteBook(at indexPath: IndexPath) throws {
        let book = books[indexPath.row]
        modelContext.delete(book)
        try modelContext.save()
        books.remove(at: indexPath.row)
    }

    func toggleRead(_ book: Book) throws {
        book.isRead.toggle()
        try modelContext.save()
    }
}

// MARK: - UIViewController

final class BookListViewController: UIViewController {
    private var viewModel: BookListViewModel!
    private lazy var tableView = UITableView(frame: .zero, style: .insetGrouped)

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        setupNavigationBar()

        // Inject ViewModel (dari AppDelegate atau Coordinator)
        let context = (UIApplication.shared.delegate as! AppDelegate).modelContainer.mainContext
        viewModel = BookListViewModel(modelContext: context)
        loadData()
    }

    private func loadData() {
        try? viewModel.loadBooks()
        tableView.reloadData()
    }

    @objc private func addBookTapped() {
        let alert = UIAlertController(title: "Tambah Buku", message: nil, preferredStyle: .alert)
        alert.addTextField { $0.placeholder = "Judul" }
        alert.addTextField { $0.placeholder = "Penulis" }
        alert.addAction(UIAlertAction(title: "Simpan", style: .default) { [weak self] _ in
            guard let title = alert.textFields?[0].text, !title.isEmpty,
                  let author = alert.textFields?[1].text, !author.isEmpty else { return }
            try? self?.viewModel.addBook(title: title, author: author, year: 2024)
            self?.tableView.reloadData()
        })
        alert.addAction(UIAlertAction(title: "Batal", style: .cancel))
        present(alert, animated: true)
    }

    private func setupNavigationBar() {
        title = "Koleksi Buku"
        navigationItem.rightBarButtonItem = UIBarButtonItem(
            systemItem: .add,
            primaryAction: UIAction { [weak self] _ in self?.addBookTapped() }
        )
    }

    private func setupTableView() {
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    }
}

extension BookListViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        viewModel.books.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        let book = viewModel.books[indexPath.row]
        var config = cell.defaultContentConfiguration()
        config.text = book.title
        config.secondaryText = "\(book.author) (\(book.publishedYear))"
        config.image = UIImage(systemName: book.isRead ? "checkmark.circle.fill" : "circle")
        config.imageProperties.tintColor = book.isRead ? .systemGreen : .systemGray
        cell.contentConfiguration = config
        return cell
    }

    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle,
                   forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            try? viewModel.deleteBook(at: indexPath)
            tableView.deleteRows(at: [indexPath], with: .automatic)
        }
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let book = viewModel.books[indexPath.row]
        try? viewModel.toggleRead(book)
        tableView.reloadRows(at: [indexPath], with: .automatic)
    }
}

// MARK: - AppDelegate Setup

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    var modelContainer: ModelContainer!

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        do {
            modelContainer = try ModelContainer(for: Book.self, Author.self)
        } catch {
            fatalError("SwiftData setup failed: \(error)")
        }
        return true
    }
}