Section 7/211 menit

7. ViewController — @MainActor

7. ViewController — @MainActor

swift
import UIKit

// ViewController hanya bertanggung jawab untuk:
// 1. Menampilkan data yang sudah diformat oleh Presenter
// 2. Mengirim user action ke Interactor
// Tidak ada business logic. Tidak ada format string. Tidak ada network call.
@MainActor
final class UserListViewController: UIViewController {

    // MARK: - VIP References

    // Interactor disimpan sebagai protocol — loose coupling.
    // 'any' keyword karena ini existential type.
    var interactor: (any UserListBusinessLogic)?
    var router: (any UserListRoutingLogic)?

    // MARK: - UI Components

    private lazy var tableView: UITableView = {
        let table = UITableView(frame: .zero, style: .plain)
        table.register(UserCell.self, forCellReuseIdentifier: UserCell.reuseID)
        table.translatesAutoresizingMaskIntoConstraints = false
        table.delegate = self
        table.dataSource = self
        return table
    }()

    private lazy var activityIndicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView(style: .large)
        indicator.hidesWhenStopped = true
        indicator.translatesAutoresizingMaskIntoConstraints = false
        return indicator
    }()

    private lazy var refreshControl: UIRefreshControl = {
        let control = UIRefreshControl()
        // addTarget bisa pakai selector atau closure action
        control.addAction(
            UIAction { [weak self] _ in self?.handleRefresh() },
            for: .valueChanged
        )
        return control
    }()

    // State View — hanya diakses di @MainActor, selalu thread-safe.
    private var displayedUsers: [UserList.FetchUsers.ViewModel.DisplayedUser] = []

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        fetchUsers()
    }

    // MARK: - Setup

    private func setupUI() {
        title = "Users"
        view.backgroundColor = .systemBackground
        tableView.refreshControl = refreshControl

        view.addSubview(tableView)
        view.addSubview(activityIndicator)

        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }

    // MARK: - Actions

    private func fetchUsers() {
        // Task mewarisi isolation dari konteks pembuatannya (@MainActor).
        // Tapi setelah 'await interactor?.fetchUsers', eksekusi berpindah ke Interactor (actor).
        // Setelah Interactor selesai dan memanggil Presenter, Presenter langsung
        // memanggil displayUsers() di sini — kembali ke @MainActor.
        Task {
            await interactor?.fetchUsers(request: UserList.FetchUsers.Request())
        }
    }

    private func handleRefresh() {
        Task {
            await interactor?.fetchUsers(request: UserList.FetchUsers.Request())
        }
    }
}

// MARK: - UserListDisplayLogic

// Implementasi protocol di extension untuk keterbacaan kode.
// Semua method ini @MainActor (diwarisi dari class annotation).
extension UserListViewController: UserListDisplayLogic {

    func displayUsers(_ viewModel: UserList.FetchUsers.ViewModel) {
        displayedUsers = viewModel.displayedUsers
        // tableView.reloadData() harus di main thread — terjamin karena @MainActor.
        tableView.reloadData()
        // Hentikan refresh control jika aktif (pull-to-refresh).
        refreshControl.endRefreshing()
    }

    func displayLoading(_ isLoading: Bool) {
        if isLoading {
            activityIndicator.startAnimating()
        } else {
            activityIndicator.stopAnimating()
        }
    }

    func displayError(message: String) {
        refreshControl.endRefreshing()

        let alert = UIAlertController(
            title: "Oops!",
            message: message,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Coba Lagi", style: .default) { [weak self] _ in
            self?.fetchUsers()
        })
        alert.addAction(UIAlertAction(title: "Tutup", style: .cancel))
        present(alert, animated: true)
    }

    func displaySelectedUser(_ viewModel: UserList.SelectUser.ViewModel) {
        // Delegasikan navigasi ke Router — View tidak tahu cara navigate.
        router?.routeToUserDetail(userId: viewModel.userId, userName: viewModel.userName)
    }
}

// MARK: - UITableViewDataSource

extension UserListViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        displayedUsers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: UserCell.reuseID,
            for: indexPath
        ) as! UserCell
        cell.configure(with: displayedUsers[indexPath.row])
        return cell
    }
}

// MARK: - UITableViewDelegate

extension UserListViewController: UITableViewDelegate {

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        // Kirim aksi user ke Interactor.
        // Task mewarisi @MainActor isolation, lalu berpindah ke Interactor (actor)
        // ketika await dipanggil.
        Task {
            await interactor?.selectUser(
                request: UserList.SelectUser.Request(index: indexPath.row)
            )
        }
    }
}

// MARK: - UserCell

final class UserCell: UITableViewCell {

    static let reuseID = "UserCell"

    private let nameLabel = UILabel()
    private let emailLabel = UILabel()
    private let avatarView = UIImageView()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupCell()
    }

    required init?(coder: NSCoder) { fatalError() }

    private func setupCell() {
        nameLabel.font = .systemFont(ofSize: 16, weight: .semibold)
        emailLabel.font = .systemFont(ofSize: 13)
        emailLabel.textColor = .secondaryLabel

        avatarView.layer.cornerRadius = 20
        avatarView.clipsToBounds = true
        avatarView.backgroundColor = .systemGray5
        avatarView.translatesAutoresizingMaskIntoConstraints = false

        let textStack = UIStackView(arrangedSubviews: [nameLabel, emailLabel])
        textStack.axis = .vertical
        textStack.spacing = 2
        textStack.translatesAutoresizingMaskIntoConstraints = false

        contentView.addSubview(avatarView)
        contentView.addSubview(textStack)

        NSLayoutConstraint.activate([
            avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            avatarView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            avatarView.widthAnchor.constraint(equalToConstant: 40),
            avatarView.heightAnchor.constraint(equalToConstant: 40),

            textStack.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 12),
            textStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
            textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
        ])
    }

    func configure(with user: UserList.FetchUsers.ViewModel.DisplayedUser) {
        nameLabel.text = user.fullName
        emailLabel.text = user.emailLabel
        // Load avatar dengan Task — aman karena cell sudah @MainActor context
        if let url = user.avatarURL {
            Task {
                await loadAvatar(from: url)
            }
        }
    }

    // Menggunakan nonisolated sendable func untuk load image di background
    private func loadAvatar(from url: URL) async {
        guard let (data, _) = try? await URLSession.shared.data(from: url),
              let image = UIImage(data: data) else { return }
        // Kembali ke @MainActor (context dari caller) untuk update UI
        avatarView.image = image
    }
}

Penjelasan poin penting:

  • Task { await interactor?.... } — pattern ini adalah cara standar memanggil async method dari synchronous UIKit callback (viewDidLoad, didSelectRowAt). Task mewarisi @MainActor isolation dari ViewController, tapi setelah await melewati ke Interactor.
  • displayedUsers diakses tanpa lock — karena ViewController adalah @MainActor, semua akses ke displayedUsers dijamin di satu thread. Tidak perlu DispatchQueue atau lock.
  • loadAvatar di dalam cell — menggunakan async dengan await URLSession sehingga image download tidak memblokir UI. Swift menangani threading secara otomatis.