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@MainActorisolation dari ViewController, tapi setelahawaitmelewati ke Interactor.displayedUsersdiakses tanpa lock — karena ViewController adalah@MainActor, semua akses kedisplayedUsersdijamin di satu thread. Tidak perluDispatchQueueatau lock.loadAvatardi dalam cell — menggunakanasyncdenganawait URLSessionsehingga image download tidak memblokir UI. Swift menangani threading secara otomatis.