SwiftUI w ekosystemie iOS – gdzie naprawdę jesteśmy
SwiftUI kontra UIKit – konkretne różnice zamiast haseł
SwiftUI pojawiło się jako deklaratywna alternatywa dla UIKit, który przez lata był standardem w tworzeniu aplikacji iOS. W teorii obydwa frameworki służą do tego samego, ale sposób pracy różni się diametralnie.
UIKit opiera się na podejściu imperatywnym: programista krok po kroku mówi systemowi, co zrobić i w jakiej kolejności. Tworzy przyciski, dodaje je jako subwidoki, ustawia autolayout, ręcznie reaguje na zdarzenia cyklu życia kontrolera widoku. W praktyce często oznacza to rozrastające się klasy kontrolerów, sporo kodu związanego z konfiguracją i zarządzaniem pamięcią.
SwiftUI podchodzi do problemu inaczej: opisuje stan interfejsu użytkownika w danym momencie. Deklarujesz, jak ma wyglądać ekran, gdy dane są w konkretnym stanie. Gdy stan się zmienia – SwiftUI sam przebudowuje drzewo widoków. Znika więc duża część ręcznego zarządzania hierarchią widoków, za to pojawia się odpowiedzialność za właściwe zdefiniowanie stanu i przepływu danych.
Na poziomie praktycznym różnice widać choćby przy tworzeniu prostego przycisku z akcją:
| Aspekt | UIKit | SwiftUI |
|---|---|---|
| Styl programowania | Imperatywny (krok po kroku) | Deklaratywny (opis stanu) |
| Definicja UI | Kod + storyboardy / xib | Wyłącznie kod (View body) |
| Aktualizacja widoku | Ręcznie (setNeedsLayout, reloadData) | Automatycznie przy zmianie stanu |
| Cykl życia | UIViewController, viewDidLoad, itp. | Nietradycyjny, oparty o odświeżanie body |
Deklaratywny vs imperatywny UI – ten sam przycisk, inna filozofia
Dla ilustracji porównanie prostego przycisku „Zwiększ licznik”. W UIKit kod bywa rozproszony:
// UIKit (uproszczone)
class CounterViewController: UIViewController {
private let button = UIButton(type: .system)
private var count = 0
override func viewDidLoad() {
super.viewDidLoad()
button.setTitle("Kliknij", for: .normal)
button.addTarget(self, action: #selector(increment), for: .touchUpInside)
view.addSubview(button)
// konfiguracja frame / Auto Layout...
}
@objc private func increment() {
count += 1
print("Licznik: (count)")
}
}
W SwiftUI całość logiki i wyglądu jest skondensowana w jednym, małym typie:
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Licznik: (count)")
Button("Zwiększ") {
count += 1
}
}
}
}
Nie zarządzasz tu hierarchią widoków wprost. Deklarujesz, że body składa się z tekstu i przycisku. Zmiana count (stanu) powoduje ponowne obliczenie body. Dla osób przychodzących z UIKit najbardziej „podejrzane” bywa to, że nie ma tu odpowiednika viewDidLoad czy viewDidAppear – cykl życia jest kontrolowany przez SwiftUI.
Oczekiwania początkujących a codzienna praktyka
Początkujący często zakładają, że SwiftUI „zajmie się wszystkim”: nawigacją, cyklem życia, zarządzaniem stanem. W prostych przykładach tak to wygląda. Problem zaczyna się, gdy aplikacja rośnie: pojawiają się ekrany logowania, asynchroniczne API, model nawigacji zależny od uprawnień użytkownika.
Framework wymusza myślenie o źródle prawdy dla danych (Single Source of Truth). Gdy tej koncepcji się nie zrozumie, pojawiają się klasyczne objawy:
- stan „znika” przy powrocie na ekran, bo był przechowywany w nieodpowiednim miejscu,
- lista nie odświeża się po modyfikacji danych, bo zmieniana jest kopia, a nie źródło,
- nawigacja zachowuje się losowo przy próbie ręcznego sterowania ścieżką bez spójnego modelu.
SwiftUI nie jest „magiczny”; upraszcza wiele rzeczy, ale w zamian oczekuje solidnego panowania nad modelem danych i zależnościami między widokami.
Kiedy SwiftUI jest dobrym wyborem, a kiedy bywa kłopotliwy
Do typowych aplikacji produktowych – listy, formularze, prostsze animacje, integracja z API – SwiftUI jest już w praktyce standardem. Apple rozwija go agresywnie, a nowe wersje iOS dostają przede wszystkim funkcje dla SwiftUI. Przy projekcie zielone pole (greenfield) w docelowych wersjach iOS 16+ trudno obecnie racjonalnie uzasadnić start wyłącznie w UIKit.
Pułapki zaczynają się przy:
- bardzo złożonych animacjach i customowych przejściach – część rzeczy nadal wygodniej zrobić w UIKit i „opakować” przez
UIViewRepresentable, - wsparciu dla bardzo starych systemów (np. iOS 13 i 14) – API SwiftUI było wtedy uboższe i bardziej kapryśne, sporo znanych błędów,
- skomplikowanej, globalnej nawigacji – szczególnie z głębokim linkowaniem i zaawansowanym stanem logowania; wymaga to przemyślanego modelu nawigacji, a nie tylko pojedynczych
NavigationLink.
W praktyce, dla pierwszej aplikacji z listą i ekranem szczegółów SwiftUI jest jednak najprostszą drogą. Warunkiem jest pewne minimum sprzętowo-systemowe po stronie programisty.

Przygotowanie środowiska – Xcode, projekt i pierwsze uruchomienie
Minimalne, sensowne wersje macOS, Xcode i iOS
SwiftUI jest mocno związany z wersjami Xcode i iOS. Teoretycznie da się pisać w starszych wersjach, ale oznacza to walkę z brakami w API i znanymi błędami. Dla nauki i pierwszych projektów bezpieczniejszo jest trzymać się nowszych kombinacji.
Racjonalne minimum na dziś:
- macOS: aktualna lub poprzednia wersja systemu (np. macOS Sonoma / Ventura – zależnie od czasu czytania),
- Xcode: aktualna stabilna wersja z Mac App Store (nie bety, jeśli nie ma konkretnego powodu),
- Docelowy iOS: przynajmniej iOS 16 jako minimalne wsparcie, jeśli aplikacja nie musi działać na bardzo starych urządzeniach.
Próby pracy na Xcode 11–12 z iOS 13 kończą się najczęściej natychmiastowym zderzeniem z brakami: brak NavigationStack, mniej stabilny List, znikające stany. Takie środowiska lepiej traktować jako kuriozum historyczne niż punkt startu.
Nowy projekt „App” z szablonem SwiftUI – co w co kliknąć
Podstawowy proces zakładania projektu w Xcode wygląda następująco:
- Uruchom Xcode i wybierz Create a new Xcode project (lub z menu File → New → Project).
- Wybierz szablon App w sekcji iOS.
- W formularzu konfiguracji ustaw:
- Product Name – nazwa aplikacji, np. „TaskList”,
- Team – konto deweloperskie (może być bezpłatne Apple ID),
- Organization Identifier – np.
com.twojafirma, - Interface – koniecznie SwiftUI,
- Language – Swift,
- Use Core Data – na start odznaczone, aby nie wprowadzać dodatkowej złożoności,
- Include Tests – opcjonalnie, nie przeszkadza.
- Wybierz folder docelowy projektu i potwierdź tworzenie.
Wygenerowany projekt ma już prostą strukturę SwiftUI: plik z adnotacją @main (np. TaskListApp.swift) oraz główny widok ContentView.swift. To ten duet uruchomi się po pierwszym buildzie.
Struktura startowa: @main, scena i główny widok
Domyślny plik aplikacji (nazwa zależy od projektu) wygląda mniej więcej tak:
@main
struct TaskListApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Adnotacja @main określa punkt wejścia aplikacji. Protokół App wymaga właściwości body, która zwraca scenę (Scene) – w typowych aplikacjach jest to WindowGroup zawierający główny widok.
ContentView to z kolei zwykła struktura spełniająca protokół View. Po uruchomieniu aplikacji SwiftUI tworzy instancję TaskListApp, a następnie wyświetla to, co znajduje się w WindowGroup. Zmiana głównego widoku polega po prostu na podmianie zawartości tego bloku.
Uruchamianie: symulator vs fizyczne urządzenie
Na pasku narzędzi Xcode znajduje się wybór targetu uruchomieniowego: symulator lub fizyczne urządzenie. Symulator jest wygodny, ale ma swoje ograniczenia i pułapki.
- Symulator:
- szybki do iteracji UI,
- nie obsługuje wszystkich funkcji sprzętowych (np. niektóre czujniki, część integracji bluetooth),
- wydajność grafiki i animacji nie zawsze odpowiada realnym urządzeniom (szczególnie starszym).
- Prawdziwe urządzenie:
- wymaga podpięcia do Maca i zaufania urządzenia,
- pozwala zobaczyć zachowanie aplikacji w realnych warunkach (dotyk, gesty, wydajność, bateria),
- przy bezpłatnym koncie deweloperskim aplikacje mają ograniczony czas ważności certyfikatu.
Przy pierwszej aplikacji SwiftUI rozsądnie jest projektować głównie na symulatorze, ale każdą istotniejszą funkcję sprawdzać przynajmniej raz na fizycznym iPhonie. Różnice w responsywności i zachowaniu klawiatury czy gestów potrafią zaskoczyć.
Pierwszy widok w SwiftUI – anatomia prostego ekranu
Struktura typu View i rola body
Każdy ekran SwiftUI jest typem (najczęściej strukturą) spełniającym protokół View. Podstawowy szablon wygląda tak:
struct ContentView: View {
var body: some View {
Text("Hello, SwiftUI")
}
}
Protokół View wymaga jednej rzeczy: właściwości body, która opisuje wygląd widoku. Zwracany typ określa się słowami kluczowymi some View; w praktyce jest to bardziej złożony, generowany przez kompilator typ złożony z wielu podwidoków. Programista nie musi znać szczegółów – ważne, że body jest czystą funkcją stanu → UI.
Cechą, która często zaskakuje, jest „niemutowalność” struktury View. SwiftUI wielokrotnie tworzy nowe instancje widoku przy każdej aktualizacji stanu. Dlatego właściwości przechowujące stan powinny być oznaczone odpowiednimi adnotacjami (@State, @ObservedObject itd.) – zwykłe var zostaną po prostu zastąpione przy kolejnym przebudowaniu body.
Tekst i przycisk – prosty, ale kompletny przykład ekranu
Najprostszy, ale już użyteczny ekran w SwiftUI może wyglądać tak:
struct GreetingView: View {
@State private var name: String = ""
@State private var greeted: Bool = false
var body: some View {
VStack(spacing: 16) {
TextField("Twoje imię", text: $name)
.textFieldStyle(.roundedBorder)
Button("Przywitaj") {
greeted = true
}
.buttonStyle(.borderedProminent)
if greeted {
Text("Cześć, (name.isEmpty ? "nieznajomy" : name)!")
.font(.headline)
}
Spacer()
}
.padding()
}
}
W tym fragmencie pojawia się kilka typowych elementów:
TextFieldz bindingiem$name– znak$udostępnia powiązanie do właściwości oznaczonej jako@State,Buttonz akcją jako trailing closure,- warunkowe wyświetlanie tekstu przy użyciu zwykłej instrukcji
ifwewnątrzbody, VStackjako kontener pionowy iSpacer()wypychający zawartość do góry.
To niewielki przykład, ale oddaje filozofię SwiftUI: deklarujesz zależność między stanem (name, greeted) a widocznym interfejsem. Nie musisz ręcznie „chować” lub „pokazywać” etykiet – wystarczy logiczny warunek.
Modyfikatory – łańcuch zmian i wpływ kolejności
Modyfikatory w SwiftUI są nakładane od góry do dołu, ale ich efekt bywa mniej oczywisty, niż sugeruje prosty „łańcuch wywołań”. Najczęstsze nieporozumienia wynikają z mieszania modyfikatorów wpływających na układ i tych, które tylko „malują” widok.
Dobry przykład to różnica między padding() a background():
Text("Z kolejnością")
.padding()
.background(Color.yellow)
Text("Bez padding przed tłem")
.background(Color.yellow)
.padding()
W pierwszym przypadku żółte tło obejmuje również marginesy. W drugim – sam tekst, a odstęp pojawia się dopiero wokół całości. Z perspektywy kodu oba fragmenty są podobne, ale geometrycznie powstają inne drzewka widoków. Zmiana jednego wiersza potrafi całkowicie zmienić wrażenie wizualne.
Podobnie widać to przy modyfikatorach ramki:
Text("Przykład")
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.yellow)
Text("Przykład")
.background(Color.yellow)
.frame(maxWidth: .infinity, alignment: .leading)
W pierwszym wariancie background rozciąga się na całą szerokość. W drugim – tylko na naturalny rozmiar tekstu. Struktura drzewa widoków jest inna, więc i efekt końcowy się różni. Przy bardziej złożonych layoutach (np. listy, karty) uporządkowanie modyfikatorów ma realny wpływ na to, co da się później łatwo zmienić.
Uproszczona reguła: najpierw definiuj rozmiar i pozycję (frame, padding, layoutPriority), później „maluj” (background, foregroundColor, shadow, overlay). To nie dogmat, ale dobry punkt startu, zanim dojdzie do debugowania dziwnych zachowań.
Podział na mniejsze widoki – czy zawsze rozbijać na komponenty?
Przy pierwszych próbach łatwo skończyć z jednym plikiem ContentView.swift długim na kilkaset linii. Technicznie SwiftUI to zniesie, ale utrzymanie takiego widoku szybko zamienia się w gimnastykę. Z drugiej strony zbyt agresywne dzielenie wszystkiego na mikrokomponenty też nie pomaga – szczególnie, gdy każdy przycisk ląduje w osobnym pliku „ButtonWithIconAndPaddingView”.
Sensownym kompromisem jest wyciąganie „podwidoków” tam, gdzie pojawiają się naturalne fragmenty UI z własną logiką lub powtarzające się sekcje. Dla prostej listy z ekranem szczegółów można na przykład wyróżnić:
- widok pojedynczego wiersza listy (np.
TaskRowView), - widok ekranu szczegółów zadania (np.
TaskDetailView), - opcjonalnie małe, ale sensowne komponenty typu
PriorityBadgeczySectionHeader.
Przykład prostego, wydzielonego podwidoku:
struct TaskRowView: View {
let title: String
let isDone: Bool
var body: some View {
HStack {
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
.foregroundColor(isDone ? .green : .gray)
Text(title)
.strikethrough(isDone, color: .gray)
.foregroundColor(isDone ? .gray : .primary)
Spacer()
}
.padding(.vertical, 8)
}
}
Taki widok da się przetestować „w oderwaniu” (w podglądzie Xcode) i używać go zarówno na liście, jak i w innych miejscach, bez kopiowania tego samego kodu. Jeśli kiedyś ikona lub styl się zmienią, zmiana następuje w jednym miejscu.

Pierwszy model danych – od „magicznego stringa” do spójnej struktury
Dlaczego nie trzymać wszystkiego jako String?
Na samym początku kusi, żeby dane przechowywać w najprostszej możliwej formie, np. jako tablicę tekstów:
@State private var tasks: [String] = [
"Kupić mleko",
"Napisać raport",
"Zadzwonić do klienta"
]
Dla szybkiego prototypu to wystarczy, ale przy pierwszym rozszerzeniu wymagań zaczynają się schody. Wystarczy dodać status wykonania zadania lub priorytet i natychmiast pojawia się potrzeba dodatkowej struktury danych. Łatanie tego słownikami [String: Any] albo równoległymi tablicami kończy się kodem, który trudno zrozumieć i trudno testować.
Prosty typ modelu – struct Task
Bezpieczniejszą bazą jest spójny model domenowy. Dla listy zadań wystarczy prosta struktura:
struct Task: Identifiable {
let id: UUID
var title: String
var isDone: Bool
var dueDate: Date?
init(
id: UUID = UUID(),
title: String,
isDone: Bool = false,
dueDate: Date? = nil
) {
self.id = id
self.title = title
self.isDone = isDone
self.dueDate = dueDate
}
}
Kilka istotnych decyzji jest już na tym poziomie:
Identifiable– wymusza posiadanie unikalnegoid, co ułatwia integrację zListi innymi kontenerami. IdentyfikatorUUIDjest rozsądnym wyborem na start, choć w aplikacjach z backendem często przechodzi się później na identyfikatory z serwera.- Opcjonalne
dueDate– nie każde zadanie musi mieć termin, więcDate?odzwierciedla rzeczywistość zamiast wciskać „puste” daty. - Własny inicjalizator – pozwala podać domyślne wartości i utrudnia nieuświadomione tworzenie zadań bez tytułu.
Modele a widoki – gdzie kończy się UI, a zaczynają dane
Rozdzielenie modelu (np. Task) od widoków (TaskRowView, TaskListView) jest kluczowe, jeśli kod ma przetrwać dłużej niż jeden weekend. Widok powinien wiedzieć tyle o zadaniu, ile potrzebuje do wyświetlenia i ewentualnej prostej interakcji, ale nie więcej.
Minimalny scenariusz: widok otrzymuje obiekt Task lub jego kopię i prezentuje dane. Stan przechowywania (tablica zadań) żyje wyżej – w widoku rodzicu lub w dedykowanym obiekcie modelu (np. ObservableObject). Taki podział utrudnia mieszanie logiki biznesowej (np. „nie pozwól oznaczyć jako gotowe, jeśli termin minął 3 dni temu”) z logiką czysto wizualną.
Lista danych w SwiftUI – budowa ekranu głównego
List i ForEach – podobne, ale nie to samo
Do wyświetlania kolekcji danych SwiftUI oferuje dwa podstawowe narzędzia: List i ForEach. List jest gotowym, platformowym widokiem listy (z przewijaniem, separatorami, wsparciem dla swipe actions itd.). ForEach to z kolei czysta konstrukcja „powtórz X razy w hierarchii widoków”, którą można umieścić w List, VStack, Group i gdziekolwiek indziej.
Prosty ekran główny z listą zadań może wyglądać tak:
struct TaskListView: View {
@State private var tasks: [Task] = [
Task(title: "Kupić mleko"),
Task(title: "Napisać raport"),
Task(title: "Zadzwonić do klienta", isDone: true)
]
var body: some View {
NavigationStack {
List {
ForEach(tasks) { task in
TaskRowView(title: task.title, isDone: task.isDone)
}
}
.navigationTitle("Zadania")
}
}
}
Tutaj List zajmuje się przewijaniem i odzyskiwaniem komórek, natomiast ForEach przekształca kolekcję tasks w konkretne wiersze. Zastosowanie protokołu Identifiable w modelu Task pozwala uniknąć ręcznego podawania id:.
Identyfikacja elementów – jak uniknąć „ForEach is not uniquely identified”
Jedna z pierwszych frustracji przy listach to komunikat o niejednoznacznej identyfikacji. Powód jest prozaiczny: SwiftUI musi rozpoznawać, który wiersz jest którym, aby skutecznie stosować animacje, przeładowania i recykling widoków. Gdy dwa elementy mają ten sam identyfikator, sytuacja staje się nieokreślona.
Przy prostych typach (np. tablica String) najprościej podać sposób identyfikacji jawnie:
List {
ForEach(tasks, id: .self) { title in
Text(title)
}
}
To jednak działa rozsądnie tylko wtedy, gdy każdy element jest naprawdę unikalny. Jeśli dwóch użytkowników ma na imię „Jan”, .self przestaje być bezpiecznym identyfikatorem. Dlatego w przypadku rozbudowanych modeli lepiej polegać na dedykowanym id w typie Identifiable, a nie na przypadkowych polach.
Usuwanie i przenoszenie elementów – integracja z systemową listą
List oferuje systemowe gesty usuwania (przeciągnięcie w lewo) i przenoszenia (w trybie edycji). Ich integracja jest prosta, ale łatwo pominąć szczegół: mutacja źródłowej tablicy musi być spójna z animowanymi operacjami listy.
Przykład podstawowej obsługi usuwania:
List {
ForEach(tasks) { task in
TaskRowView(title: task.title, isDone: task.isDone)
}
.onDelete(perform: deleteTasks)
}
private func deleteTasks(at offsets: IndexSet) {
tasks.remove(atOffsets: offsets)
}
Analogicznie z przenoszeniem:
List {
ForEach(tasks) { task in
TaskRowView(title: task.title, isDone: task.isDone)
}
.onMove(perform: moveTasks)
}
.toolbar {
EditButton()
}
private func moveTasks(from source: IndexSet, to destination: Int) {
tasks.move(fromOffsets: source, toOffset: destination)
}
EditButton() automatycznie przełącza listę w tryb edycji, jeśli tylko List posiada odpowiednie modyfikatory. Przy większych projektach sensowne staje się wydzielenie logiki mutacji do warstwy modelu (np. TaskStore), ale na tym etapie pojedynczy View z @State jeszcze się broni.
Edytowalność wiersza – Binding zamiast kopiowania
Dotąd każdy wiersz listy dostawał kopię danych Task. To wystarczy, jeśli wiersz ma tylko wyświetlać informacje. Gdy jednak z wiersza trzeba zmieniać stan (np. zaznaczanie zadania jako wykonane), proste przekazanie modelu przestaje działać – modyfikacje dotyczą kopii, a nie źródłowej tablicy.
Rozwiązaniem jest przekazanie Binding do elementu tablicy:
struct TaskListView: View {
@State private var tasks: [Task] = [
Task(title: "Kupić mleko"),
Task(title: "Napisać raport")
]
var body: some View {
NavigationStack {
List {
ForEach($tasks) { $task in
TaskRowEditableView(task: $task)
}
}
.navigationTitle("Zadania")
}
}
}
struct TaskRowEditableView: View {
@Binding var task: Task
var body: some View {
HStack {
Button {
task.isDone.toggle()
} label: {
Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
}
Text(task.title)
Spacer()
}
}
}
ForEach($tasks) zwraca kolekcję bindingów do poszczególnych elementów. Destrukturyzacja $task inside closure oznacza, że wewnątrz TaskRowEditableView operuje się na żywym połączeniu z tablicą tasks, a nie na kopii. Dzięki temu zmiany w wierszu automatycznie aktualizują stan rodzica.

Nawigacja w SwiftUI – od pojedynczego ekranu do stosu widoków
NavigationStack – nowe podejście do nawigacji
Dla docelowego iOS 16+ sensownie jest od razu używać NavigationStack, a nie starszego NavigationView. Nowszy typ rozwiązuje część starych błędów (m.in. losowe zachowania tytułów, problemy z „Back” przy programowej nawigacji) i lepiej współgra z programowo kontrolowanym stosem.
Najprostsza forma z pojedynczym przejściem wygląda tak:
struct TaskListView: View {
@State private var tasks: [Task] = [...]
var body: some View {
NavigationStack {
List {
ForEach(tasks) { task in
NavigationLink(task.title) {
TaskDetailView(task: task)
}
}
}
.navigationTitle("Zadania")
}
}
}
NavigationLink pełni tutaj rolę „przycisku” na liście, który dokłada nowy ekran na stosie nawigacji. W prostych aplikacjach taka deklaratywna forma wystarcza – użytkownik klika w wiersz, pojawia się ekran szczegółów, w lewym górnym rogu automatycznie ląduje przycisk „Wstecz”.
NavigationPath – gdy sam NavigationLink już nie wystarcza
NavigationPath – gdy jeden NavigationLink przestaje wystarczać
Przy prostym scenariuszu użytkownik klika w wiersz, przechodzi do szczegółów i wraca strzałką „Wstecz”. Problem zaczyna się, gdy stos widoków ma być kontrolowany programowo: otwieranie głębokich ekranów po powiadomieniu push, resetowanie nawigacji po wylogowaniu, przechodzenie na konkretny ekran z zakładki „Ulubione”.
NavigationPath pozwala kontrolować cały stos jako dane:
struct TaskListView: View {
@State private var tasks: [Task] = [...]
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
ForEach(tasks) { task in
NavigationLink(task.title, value: task)
}
}
.navigationDestination(for: Task.self) { task in
TaskDetailView(task: task)
}
.navigationTitle("Zadania")
.toolbar {
Button("Pokaż losowe") {
if let random = tasks.randomElement() {
path.append(random)
}
}
}
}
}
}
Kilka istotnych konsekwencji:
NavigationLinkużywa teraz inicjalizatora zvalue:, a nie z ręcznie podanym docelowym widokiem.navigationDestination(for:)mapuje typ danych (Task) na widok docelowy. Przy kilku typach można zdefiniować wiele takich maperów.- Programowa nawigacja to zwykła operacja na tablicopodobnym
path–append,removeLast, przypisanie pustej ścieżki w celu „powrotu do korzenia”.
Najczęstsza pułapka: Task w takim scenariuszu musi być stabilnie identyfikowalny i możliwy do zakodowania, jeśli planowane jest zachowanie ścieżki np. po restarcie aplikacji. Samo pole title jako identyfikator raczej nie wystarczy. W praktyce do Task często trafia własne stałe id (np. UUID przechowywane w bazie).
Nawigacja z edycją – gdy detal musi modyfikować listę
Prosta wersja: ekran szczegółów pokazuje dane tylko do odczytu. W realnej aplikacji szczegóły prędzej czy później dostaną formularz edycji, przełącznik priorytetu, przycisk usuwania. To oznacza konieczność spięcia stanu między listą a widokiem detalu.
Najprostszy, choć nie jedyny, wariant to przekazanie Binding zamiast kopii modelu. Niestety w połączeniu z NavigationPath robi się to nieco mniej wygodne, dlatego dobrze zacząć od prostszego przypadku – klasycznego NavigationLink w obrębie tej samej hierarchii stanu:
struct TaskListView: View {
@State private var tasks: [Task] = [...]
var body: some View {
NavigationStack {
List {
ForEach($tasks) { $task in
NavigationLink {
TaskDetailEditableView(task: $task)
} label: {
TaskRowEditableView(task: $task)
}
}
}
.navigationTitle("Zadania")
}
}
}
struct TaskDetailEditableView: View {
@Binding var task: Task
var body: some View {
Form {
TextField("Tytuł", text: $task.title)
Toggle("Zakończone", isOn: $task.isDone)
DatePicker("Termin",
selection: Binding($task.dueDate, replacingNilWith: .now),
displayedComponents: .date)
}
.navigationTitle("Szczegóły")
}
}
Helper do bezpiecznego „rozpakowania” Date? można zdefiniować jako rozszerzenie:
extension Binding {
init(_ source: Binding, replacingNilWith defaultValue: Value) {
self.init(
get: { source.wrappedValue ?? defaultValue },
set: { newValue in
source.wrappedValue = newValue
}
)
}
}
Taki wzorzec ma sens dopóki lista jest „prawdziwym źródłem prawdy”, a widok detalu tylko pracuje na przekazanym bindingu. Gdy model zadań staje się współdzielony przez wiele ekranów, lepiej przenieść go do osobnego obiektu stanu.
Koordynacja nawigacji a stan globalny – prosty coordinator bez nadinżynierii
Większe aplikacje często korzystają z własnej warstwy koordynującej przepływ ekranów. W UIKit robiło się to zwykle przez klasy coordinatorów. W SwiftUI nie ma jednej „prawidłowej” odpowiedzi – można albo zostać przy lokalnym NavigationPath w każdym module, albo spiąć wszystko jednym obiektem.
Przykład minimalnego „store + navigation” dla sekcji z zadaniami:
final class TaskFlow: ObservableObject {
@Published var tasks: [Task] = [
Task(title: "Kupić mleko"),
Task(title: "Napisać raport")
]
@Published var path = NavigationPath()
func openTask(_ task: Task) {
path.append(task)
}
func reset() {
path = NavigationPath()
}
}
struct TaskFlowView: View {
@StateObject private var flow = TaskFlow()
var body: some View {
NavigationStack(path: $flow.path) {
TaskListContentView()
.environmentObject(flow)
.navigationDestination(for: Task.self) { task in
TaskDetailContentView(task: task)
.environmentObject(flow)
}
}
}
}
Wnętrze listy korzysta z @EnvironmentObject zamiast lokalnego @State:
struct TaskListContentView: View {
@EnvironmentObject var flow: TaskFlow
var body: some View {
List {
ForEach(flow.tasks) { task in
Button {
flow.openTask(task)
} label: {
TaskRowView(title: task.title, isDone: task.isDone)
}
}
}
.navigationTitle("Zadania")
}
}
Takie podejście bywa wygodne, gdy nawigację trzeba uruchomić spoza listy (np. z powiadomienia, z innej zakładki). Jednocześnie rośnie ryzyko „rozlania” logiki po całym drzewie przez nadużywanie @EnvironmentObject. Dobrym nawykiem jest trzymanie takich obiektów blisko granicy modułu, zamiast wrzucać jeden globalny store „na całą aplikację”.
Stan w SwiftUI – @State, @Binding i „Single Source of Truth”
@State – prywatne dane widoku, które żyją tak długo jak widok
@State to najprostsza forma stanu w SwiftUI. Dane oznaczone @State są przechowywane poza strukturą widoku, ale funkcjonalnie przynależą do konkretnego „instancjonowania” widoku w hierarchii. Zwykle służą do przechowywania:
- lokalnych przełączników (
isPresentingSheet,isExpanded), - prostych modeli formularza, które nie wychodzą poza pojedynczy ekran,
- tymczasowych danych pomocniczych (np. zaznaczony filtr na liście).
struct AddTaskView: View {
@State private var title: String = ""
@State private var hasDueDate: Bool = false
@State private var dueDate: Date = .now
var body: some View {
Form {
TextField("Tytuł", text: $title)
Toggle("Ma termin", isOn: $hasDueDate)
if hasDueDate {
DatePicker("Termin",
selection: $dueDate,
displayedComponents: .date)
}
}
}
}
Typowy błąd początkujących: próba oznaczania @State na danych, które faktycznie powinny należeć do wspólnego modelu (np. lista zadań współdzielona między kilkoma ekranami). Efekt to zdublowany stan – każdy ekran ma „swoją” tablicę, a spójność zależy od ręcznego synchronizowania. Przy mniejszej aplikacji to jeszcze działa, ale przy większej rozjeżdża się bardzo szybko.
@Binding – most między rodzicem a dzieckiem
@Binding nie przechowuje danych – wskazuje na istniejący stan w innym miejscu. Praktyczny widok: rodzic ma @State, dzieci dostają referencje do tego stanu. Dzięki temu nie tworzy się kopii, tylko kilka widoków operuje na tej samej wartości.
Prosty przykład: przełącznik filtra na ekranie listy, który jednocześnie kontroluje widoczne wiersze i stan paska narzędzi:
struct TaskFilterBar: View {
@Binding var showOnlyOpen: Bool
var body: some View {
Toggle("Tylko otwarte", isOn: $showOnlyOpen)
.toggleStyle(.switch)
}
}
struct TaskListWithFilterView: View {
@State private var showOnlyOpen = false
@State private var tasks: [Task] = [...]
var filteredTasks: [Task] {
tasks.filter { task in
!showOnlyOpen || !task.isDone
}
}
var body: some View {
NavigationStack {
VStack {
TaskFilterBar(showOnlyOpen: $showOnlyOpen)
List {
ForEach(filteredTasks) { task in
TaskRowView(title: task.title, isDone: task.isDone)
}
}
}
.navigationTitle("Zadania")
}
}
}
Jeśli filtr miałby być sterowany z kilku miejsc (np. pasek nad listą + przycisk w toolbarze), oba widoki mogą korzystać z tego samego bindingu. Kluczem jest trzymanie „prawdziwego” źródła danych w jednym miejscu (tu: @State private var showOnlyOpen).
Single Source of Truth – co naprawdę oznacza w małej aplikacji
Hasło „Single Source of Truth” bywa nadużywane. W praktyce chodzi o to, żeby jedna konkretna instancja pamięci była odpowiedzialna za daną porcję stanu. Nie oznacza to, że w projekcie może istnieć tylko jeden „globalny” obiekt; raczej, że dla każdej spójnej domeny danych wskazać można jedno miejsce odpowiedzialne za utrzymanie ich aktualności.
Dla prostej aplikacji z listą zadań rozsądny podział może wyglądać tak:
- Ekran listy: źródło prawdy dla „aktualnie wybranych filtrów”, sortowania, stanu wyszukiwarki.
- Moduł zadań (np.
TaskStore): źródło prawdy dla samych obiektówTask, bez świadomości o filtrach UI. - Ekran detalu: nie ma własnego źródła prawdy dla
Task, tylko binding lub obserwację doTaskStore.
Gdy w kilku miejscach zaczynają żyć „lokalne” tablice zadań, które nie są w żaden sposób ze sobą powiązane, pojawiają się typowe problemy: usunięcie zadania na jednym ekranie nie odświeża drugiego, zmiana tytułu w detalu nie aktualizuje widocznego wiersza na liście itd. Rozwiązaniem jest doprowadzenie do jednego miejsca, z którego oba ekrany czerpią dane.
@StateObject i @ObservedObject – kiedy @State to za mało
Gdy lista zadań ma być współdzielona między kilkoma ekranami i potencjalnie powiązana z warstwą sieci/bazy, sama tablica w @State w jednym widoku przestaje wystarczać. Wtedy przydaje się dedykowany typ modelu, np. TaskStore, implementujący ObservableObject:
final class TaskStore: ObservableObject {
@Published var tasks: [Task] = []
func add(_ task: Task) {
tasks.append(task)
}
func remove(_ task: Task) {
tasks.removeAll { $0.id == task.id }
}
}
Główny widok tworzy instancję i oznacza ją jako @StateObject. To sygnał, że ten widok jest „właścicielem” cyklu życia obiektu:
struct RootTaskView: View {
@StateObject private var store = TaskStore()
var body: some View {
NavigationStack {
TaskListFromStoreView()
.environmentObject(store)
}
}
}
Niżej w hierarchii ten sam obiekt można pozyskać przez @EnvironmentObject lub przekazać jawnie przez @ObservedObject:
struct TaskListFromStoreView: View {
@EnvironmentObject var store: TaskStore
var body: some View {
List {
ForEach(store.tasks) { task in
NavigationLink {
TaskDetailFromStoreView(task: task)
} label: {
TaskRowView(title: task.title, isDone: task.isDone)
}
}
}
.navigationTitle("Zadania")
.toolbar {
Button {
store.add(Task(title: "Nowe zadanie"))
} label: {
Image(systemName: "plus")
}
}
}
}
Kluczowa różnica:
@StateObject– używane tam, gdzie obiekt jest tworzony i ma żyć tak długo, jak dany widok (typowo bardzo wysoko w hierarchii).@ObservedObject– używane tam, gdzie obiekt jest wstrzykiwany z zewnątrz; widok nie zarządza jego cyklem życia.
Częsty błąd: tworzenie TaskStore w kilku widokach przez @StateObject, zamiast w jednym, a następnie przekazywanie referencji. Efekt: kilka niezależnych list, które „udają” wspólny stan.
Łączenie @State, @Binding i ObservableObject w jednym przepływie
Typowy przepływ dla małej aplikacji z zadaniami można spiąć w prostą strukturę:
TaskStorejakoObservableObjecttrzyma listę zadań i podstawowe operacje (dodaj, usuń, zaktualizuj).- Główny widok zakłada
@StateObject private var store = TaskStore()i wstrzykuje go do podwidoków. - Ekran listy używa
@EnvironmentObject var storeoraz lokalnego@Statena filtry / stan formularza dodawania. - Ekran dodawania zadania opiera się na
@State(tymczasowe pola formularza) i po zatwierdzeniu wołastore.add(...). - Ekran detalu dostaje model albo przez
Binding(gdy dane trzymane są lokalnie) albo przez referencję do store i wyszukanie zadania poid. Dla prostych scenariuszy binding jest wygodniejszy, dla złożonych – lookup w store bywa czytelniejszy.
Najczęściej zadawane pytania (FAQ)
SwiftUI czy UIKit – czego powinienem się uczyć na pierwszą aplikację iOS?
Dla nowych projektów celujących w iOS 16+ rozsądniej jest zacząć od SwiftUI. Apple rozwija ten framework agresywnie, a nowe funkcje systemu są projektowane przede wszystkim z myślą o nim. Do typowych aplikacji – listy, formularze, ekrany szczegółów, integracja z API – SwiftUI w praktyce wystarcza i zwykle przyspiesza start.
UIKit bywa lepszy, gdy:
- musisz wspierać bardzo stare systemy (iOS 13–14) lub specyficzne scenariusze enterprise,
- robisz nietypowe, złożone animacje i customowe przejścia, które w SwiftUI są nadal kłopotliwe,
- dołączasz do istniejącego projektu opartego na UIKit, gdzie przepisywanie wszystkiego nie ma sensu.
Najczęstszy model w firmach to hybryda: główny interfejs w SwiftUI, specyficzne fragmenty dopisywane w UIKit i opakowane przez UIViewRepresentable.
Czym konkretnie różni się deklaratywny SwiftUI od imperatywnego UIKit w codziennej pracy?
W UIKit opisujesz krok po kroku, co ma się stać: tworzysz widoki, dodajesz je jako subwidoki, ustawiasz constraints, ręcznie reagujesz na eventy cyklu życia (viewDidLoad, viewDidAppear itd.). Kod rozjeżdża się po wielu metodach i klasach, a zarządzanie stanem widoku miesza się z logiką biznesową.
W SwiftUI definiujesz, jak ekran ma wyglądać dla danego stanu danych. Stan zmienia się → framework sam przebudowuje drzewo widoków. Nie zarządzasz ręcznie hierarchią widoków ani layoutem w tradycyjnym sensie. Zyskujesz prostszy kod UI, ale płacisz koniecznością przemyślenia: skąd dane pochodzą, gdzie jest „źródło prawdy” i jak przepływa stan między widokami.
Jakie minimalne wymagania sprzętowe i systemowe, żeby sensownie zacząć z SwiftUI?
Do nauki i pierwszej poważniejszej aplikacji przydaje się:
- macOS: aktualna lub poprzednia stabilna wersja (np. Sonoma / Ventura w zależności od momentu),
- Xcode: aktualna stabilna wersja z Mac App Store (nie beta, jeśli nie testujesz konkretnej nowości),
- Docelowy iOS: co najmniej iOS 16 jako minimalna wersja, chyba że z powodów biznesowych musisz wspierać starsze urządzenia.
Na Xcode 11–12 z iOS 13–14 „da się”, ale napotkasz braki w API SwiftUI (brak NavigationStack, niestabilne List, znikający stan). Dla początkującego zwykle kończy się to debugowaniem problemów frameworka zamiast nauką samego języka i wzorców.
Jak założyć pierwszy projekt SwiftUI w Xcode i czego nie zaznaczać na start?
Przy tworzeniu nowego projektu wybierz szablon „App” w sekcji iOS, a w ustawieniach:
- Interface → SwiftUI,
- Language → Swift,
- Use Core Data → odznacz, dopóki nie wiesz, że naprawdę tego potrzebujesz,
- Include Tests → opcjonalnie, testy nie przeszkadzają, ale na pierwsze kroki nie są krytyczne.
Tak powstanie struktura z plikiem @main (np. TaskListApp.swift) i głównym widokiem ContentView.swift. Początkowe zadanie to zrozumieć, że WindowGroup w pliku App jest punktem wejścia UI – to tam podmieniasz główny widok, zamiast „ręcznych” AppDelegate/SceneDelegate jak w UIKit.
Dlaczego stan w SwiftUI „znika” po powrocie na ekran lub nie odświeża listy?
Najczęściej powodem jest złe ulokowanie źródła prawdy. Typowe błędy:
- trzymanie stanu w dziecku zamiast w rodzicu (np. @State w widoku, który jest tworzony na nowo przy każdej zmianie),
- modyfikacja kopii danych zamiast obiektu, który jest oznaczony jako @StateObject / @ObservedObject / @EnvironmentObject,
- inicjalizowanie modelu widoku „w locie” w body zamiast w stabilnym miejscu.
Jeżeli po nawigacji w przód i wstecz stan resetuje się, zwykle oznacza to, że SwiftUI traktuje widok jak nowy. Rozwiązaniem jest wyniesienie stanu wyżej (np. do rodzica lub globalnego modelu) i przekazywanie go jako binding lub obiekt obserwowany.
Czy SwiftUI „załatwi za mnie” całą nawigację w aplikacji?
Dla prostych scenariuszy – pojedynczy NavigationStack, kilka NavigationLink do ekranów szczegółów – wrażenie jest takie, że framework robi wszystko za ciebie. Problemy zaczynają się przy bardziej rozbudowanych przepływach: logowanie, on-boarding, różne ścieżki w zależności od uprawnień czy deep linki.
SwiftUI nie rozwiązuje automatycznie modelu nawigacji, tylko dostarcza narzędzia. Przy złożonych aplikacjach często i tak trzeba:
- zaprojektować centralny model nawigacji (np. enum z ekranami i ścieżką),
- zsynchronizować stan logowania z tym modelem,
- pilnować, by programowa zmiana ścieżki nie „szarpała” UI.
Bez tego podejścia nawigacja potrafi wyglądać na losową, zwłaszcza gdy ręcznie sterujesz wieloma NavigationLink bez spójnego źródła prawdy.
Czy muszę mieć fizyczne urządzenie, czy wystarczy symulator iOS w Xcode?
Do nauki SwiftUI i budowy pierwszej aplikacji z listą i ekranem szczegółów symulator w zupełności wystarcza. Jest szybszy do iteracji UI, łatwo uruchomić wiele konfiguracji urządzeń, a większość podstawowych funkcji (nawigacja, stan, prosta komunikacja z API) działa identycznie jak na telefonie.
Fizyczne urządzenie staje się potrzebne, gdy:
- testujesz wydajność, płynność animacji i realne zachowanie na słabszym sprzęcie,
- korzystasz z funkcji typowo sprzętowych (np. niektóre czujniki, aparat w niskopoziomowy sposób),
- planujesz dystrybucję poza Xcode (TestFlight, App Store) i chcesz zobaczyć faktyczną wersję produkcyjną.
Na sam start lepiej zainwestować w stabilne środowisko (aktualny macOS + Xcode) niż w dodatkowe urządzenia testowe.






