cat swift/traccia-ios-macos-swiftui-state-lifecycle.md

Lezione 6565 min

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 .task per lavoro async legato alla vita della view;
  • usa .refreshable per 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.

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:

  1. crea TaskListViewModel;
  2. inietta LoadTasksUseCase;
  3. rappresenta stato idle/loading/loaded/failed;
  4. crea una TaskListScreen;
  5. usa .task per caricare;
  6. scrivi un test @MainActor del view model.

Obiettivo: la view non deve importare tipi DTO, non deve creare URL e non deve conoscere URLSession.