cat swift/traccia-ios-macos-swiftui-state-lifecycle.md
Traccia iOS/macOS: SwiftUI, stato e lifecycle
Usare SwiftUI come destinazione applicativa del dominio: view, stato, Observation, MainActor, navigation e lifecycle.
SwiftUI è il modo moderno per costruire interfacce Apple con Swift.
La sua idea centrale è dichiarativa: descrivi la UI come funzione dello stato.
struct ProductView: View {
let product: Product
var body: some View {
VStack(alignment: .leading) {
Text(product.name)
Text(product.price.formatted())
}
}
}
La view non dovrebbe contenere networking, parsing JSON o regole profonde. Dovrebbe presentare stato e inviare intenzioni.
App entry point
Una app SwiftUI parte da un tipo che conforma ad App.
@main
struct TaskApp: App {
var body: some Scene {
WindowGroup {
TaskListScreen(
viewModel: TaskListViewModel.live()
)
}
}
}
Il punto critico è dove costruisci le dipendenze. Per app piccole può stare nell’entry point. Per app più grandi puoi usare factory dedicate.
Stato locale
@State è adatto a stato locale della view:
struct SearchField: View {
@State private var query = ""
var body: some View {
TextField("Cerca", text: $query)
}
}
Usalo per:
- testo temporaneo;
- toggle;
- selezioni locali;
- stato di presentazione;
- controlli UI.
Non usarlo per nascondere logica di dominio complessa.
Stato osservabile
Per stato di feature, usa un modello osservabile.
import Observation
@MainActor
@Observable
final class TaskListViewModel {
private let loadTasks: LoadTasksUseCase
var state: LoadingState<[TaskItem]> = .idle
init(loadTasks: LoadTasksUseCase) {
self.loadTasks = loadTasks
}
func load() async {
state = .loading
do {
state = .loaded(try await loadTasks.execute())
} catch {
state = .failed("Impossibile caricare le attività.")
}
}
}
@MainActor rende esplicito che lo stato UI viene aggiornato sul main actor.
View con view model
struct TaskListScreen: View {
@State private var viewModel: TaskListViewModel
init(viewModel: TaskListViewModel) {
self._viewModel = State(initialValue: viewModel)
}
var body: some View {
content
.task {
await viewModel.load()
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle, .loading:
ProgressView()
case .loaded(let tasks):
List(tasks) { task in
Text(task.title)
}
case .failed(let message):
ContentUnavailableView("Errore", systemImage: "exclamationmark.triangle", description: Text(message))
}
}
}
La view non sa come si caricano le attività. Sa solo quando chiedere il caricamento e come mostrare lo stato.
Lifecycle
SwiftUI offre modifier legati al ciclo di vita della view:
.task;.onAppear;.onDisappear;.refreshable;.onChange;- scene phase tramite environment.
Regola pratica:
- usa
.taskper lavoro async legato alla vita della view; - usa
.refreshableper refresh utente; - evita di creare task non osservati se il risultato modifica UI;
- propaga cancellazione quando la view sparisce.
.task(id: selectedCategory) {
await viewModel.load(category: selectedCategory)
}
Con id, SwiftUI può cancellare il task precedente e avviarne uno nuovo quando cambia la categoria.
Navigation
SwiftUI moderna preferisce navigazione basata su dati.
struct TaskListScreen: View {
@State private var path: [TaskItem.ID] = []
var body: some View {
NavigationStack(path: $path) {
List(tasks) { task in
NavigationLink(value: task.id) {
Text(task.title)
}
}
.navigationDestination(for: TaskItem.ID.self) { id in
TaskDetailScreen(id: id)
}
}
}
}
La navigazione diventa stato, quindi può essere testata e ragionata meglio.
MainActor
I view model UI dovrebbero essere isolati al main actor.
@MainActor
@Observable
final class ProductViewModel {
var state: LoadingState<Product> = .idle
}
Il repository, invece, non deve essere @MainActor:
struct HTTPProductRepository: ProductRepository {
func product(id: Product.ID) async throws -> Product {
...
}
}
Separare UI state è lavoro di infrastruttura evita di bloccare il main actor con responsabilità sbagliate.
Errori nella UI
Non mostrare direttamente errori tecnici.
catch ProductError.notFound {
state = .failed("Prodotto non trovato.")
} catch {
state = .failed("Impossibile caricare il prodotto.")
}
La UI deve tradurre errore in esperienza.
Il dominio deve mantenere errore preciso.
macOS
SwiftUI permette di condividere molte view tra iOS e macOS, ma non tutto è identico.
Su macOS emergono:
- menu;
- finestre multiple;
- comandi da tastiera;
- sidebar;
- tabelle;
- drag and drop;
- documenti;
- file system più visibile.
Non progettare una UI macOS come un iPhone grande.
Testabilità
Il punto testabile non è la view grafica, ma il view model e i casi d’uso.
@MainActor
@Test
func loadMostraStatoLoaded() async {
let viewModel = TaskListViewModel(
loadTasks: LoadTasksUseCase(repository: StubTaskRepository(tasks: [.sample]))
)
await viewModel.load()
#expect(viewModel.state == .loaded([.sample]))
}
La view resta sottile. Il comportamento vive in oggetti testabili.
Checklist
Una traccia SwiftUI sana:
- view piccole e composabili;
- stato locale in
@State; - stato di feature in modello osservabile;
- view model isolati al
MainActor; - casi d’uso iniettati;
- dominio fuori dalle view;
- errori tradotti per l’utente;
- task cancellabili e legati al lifecycle;
- navigazione rappresentata come stato.
Esercizio
Usa il dominio task del modulo:
- crea
TaskListViewModel; - inietta
LoadTasksUseCase; - rappresenta stato
idle/loading/loaded/failed; - crea una
TaskListScreen; - usa
.taskper caricare; - scrivi un test
@MainActordel view model.
Obiettivo: la view non deve importare tipi DTO, non deve creare URL e non deve conoscere URLSession.