Observation Framework в SwiftUI: Пълен гайд за @Observable, @Bindable и управление на състоянието

Как макрото @Observable променя управлението на състоянието в SwiftUI. Гайд с миграция от ObservableObject, @Bindable, @Environment, производителност и практически примери.

Въведение: Защо Observation Framework променя всичко

С пускането на iOS 17 и Swift 5.9, Apple представи Observation Framework — един фундаментално нов подход към управлението на състоянието в SwiftUI. И не, това не е поредното малко подобрение. Това е цялостно преосмисляне на начина, по който данните протичат между моделите и потребителския интерфейс.

Ако сте работили със SwiftUI от 2019 г. насам, вероятно знаете болката.

Старият подход с протокола ObservableObject и Combine имаше доста сериозни ограничения. Всеки път, когато дори едно @Published свойство се промени, всички изгледи, наблюдаващи обекта, получаваха известие да се обновят — без значение дали реално използваха промененото свойство. Резултатът? Излишни преизчертавания, лоша производителност и разочаровани програмисти.

Observation Framework решава точно този проблем с гранулирано проследяване (fine-grained tracking). SwiftUI вече знае точно кои свойства даден изглед чете и обновява само него, когато конкретно тези свойства се променят. Производителността скача значително, а API-то е далеч по-просто.

В това ръководство ще минем през всички аспекти на Observation Framework — от макрото @Observable и вътрешния му механизъм, през @State, @Bindable и @Environment, до миграция от старата система и практически примери. Хайде да започнем.

Какво е Observation Framework

Observation Framework беше представен на WWDC23 и е базиран на Swift Evolution предложението SE-0395 (Observation). Това е самостоятелен Swift фреймуърк, който не е обвързан единствено със SwiftUI — може да се използва във всякакъв Swift контекст — но честно казано, именно в SwiftUI той наистина блести.

Ядрото на фреймуърка е макрото @Observable, което замества протокола ObservableObject и property wrapper-а @Published. Вместо да маркирате всяко свойство поотделно с @Published, просто анотирате целия клас с @Observable и фреймуъркът автоматично се грижи за проследяването.

Основните компоненти на новата система са:

  • @Observable — макро, което прави клас наблюдаем с автоматично проследяване на всички свойства
  • @State — вече се използва за притежаване и управление на жизнения цикъл на Observable обекти (замества @StateObject)
  • @Bindable — създава обвързвания (bindings) към свойствата на Observable обект (замества част от ролята на @ObservedObject)
  • @Environment — инжектира Observable обекти чрез средата (замества @EnvironmentObject)
  • @ObservationIgnored — изключва конкретни свойства от проследяване

Това опростяване означава, че трябва да помните по-малко property wrapper-и и е по-трудно да объркате нещо. Важно е да отбележим, че @Observable работи само с класове, не със структури. Причината е, че проследяването на промени изисква референтна семантика — Swift трябва да знае кога обектът се мутира на място, а не когато се създава ново копие.

Какво генерира макрото под повърхността

Когато Swift компилаторът обработи макрото @Observable, той генерира доста код зад кулисите. За да разберем защо Observation Framework е толкова ефективен, нека надникнем какво всъщност се случва:

// Това, което пишете:
@Observable
class UserProfile {
    var name: String = ""
    var age: Int = 0
}

// Какво компилаторът генерира (опростено):
class UserProfile: Observable {
    private var _name: String = ""
    var name: String {
        get {
            access(keyPath: \.name)
            return _name
        }
        set {
            withMutation(keyPath: \.name) {
                _name = newValue
            }
        }
    }

    private var _age: Int = 0
    var age: Int {
        get {
            access(keyPath: \.age)
            return _age
        }
        set {
            withMutation(keyPath: \.age) {
                _age = newValue
            }
        }
    }

    internal let _$observationRegistrar = ObservationRegistrar()
}

Ключовият момент тук е, че access(keyPath:) се извиква при четене, а withMutation(keyPath:) — при запис. Така SwiftUI знае точно кое свойство е прочетено от кой изглед и изпраща известие за преизчертаване само когато конкретно това свойство се промени. Елегантно, нали?

@Observable срещу ObservableObject

За да разберем напълно предимствата на новия подход, нека сравним двата модела едно до друго. Нищо не говори по-ясно от конкретен код.

Старият подход с ObservableObject

import SwiftUI
import Combine

// Модел — стар подход
class ShoppingCart: ObservableObject {
    @Published var items: [CartItem] = []
    @Published var couponCode: String = ""
    @Published var isLoading: Bool = false

    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price * Double($1.quantity) }
    }

    func addItem(_ item: CartItem) {
        items.append(item)
    }

    func removeLast() {
        items.removeLast()
    }
}

struct CartItem: Identifiable {
    let id = UUID()
    var name: String
    var price: Double
    var quantity: Int
}

// Изглед — стар подход
struct CartView: View {
    @StateObject private var cart = ShoppingCart()

    var body: some View {
        VStack {
            Text("Общо: \(cart.totalPrice, format: .currency(code: "BGN"))")
            // Промяна на couponCode ще преизчертае ЦЕЛИЯ изглед,
            // въпреки че Text-ът не използва couponCode
        }
    }
}

// Дъщерен изглед — стар подход
struct CartItemsList: View {
    @ObservedObject var cart: ShoppingCart

    var body: some View {
        List(cart.items) { item in
            Text("\(item.name) — \(item.price, format: .currency(code: "BGN"))")
        }
    }
}

// Среда — стар подход
struct CartEnvironmentView: View {
    @EnvironmentObject var cart: ShoppingCart

    var body: some View {
        Text("Продукти: \(cart.items.count)")
    }
}

Новият подход с @Observable

import SwiftUI
import Observation

// Модел — нов подход
@Observable
class ShoppingCart {
    var items: [CartItem] = []
    var couponCode: String = ""
    var isLoading: Bool = false

    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price * Double($1.quantity) }
    }

    func addItem(_ item: CartItem) {
        items.append(item)
    }

    func removeLast() {
        items.removeLast()
    }
}

struct CartItem: Identifiable {
    let id = UUID()
    var name: String
    var price: Double
    var quantity: Int
}

// Изглед — нов подход
struct CartView: View {
    @State private var cart = ShoppingCart()

    var body: some View {
        VStack {
            Text("Общо: \(cart.totalPrice, format: .currency(code: "BGN"))")
            // Промяна на couponCode НЯМА да преизчертае този изглед,
            // защото Text-ът не чете couponCode
        }
    }
}

// Дъщерен изглед — нов подход
struct CartItemsList: View {
    var cart: ShoppingCart  // Просто обикновено свойство!

    var body: some View {
        List(cart.items) { item in
            Text("\(item.name) — \(item.price, format: .currency(code: "BGN"))")
        }
    }
}

// Среда — нов подход
struct CartEnvironmentView: View {
    @Environment(ShoppingCart.self) var cart

    var body: some View {
        Text("Продукти: \(cart.items.count)")
    }
}

Забележете няколко ключови разлики. Първо, няма @Published — всички съхранявани свойства се проследяват автоматично. Второ, @StateObject е заменен с обикновен @State. Трето (и това е любимата ми част), @ObservedObject изобщо не е необходим — можете просто да подадете обекта като обикновено свойство. И четвърто, @EnvironmentObject е заменен с @Environment(ShoppingCart.self).

Нови property wrapper-и в SwiftUI

С Observation Framework ролята на property wrapper-ите в SwiftUI се промени значително. Нека разгледаме всеки от тях по-подробно.

@State с @Observable обекти

В стария модел, @State беше запазен за прости стойностни типове (Int, String, Bool), а @StateObject се използваше за референтни типове. Сега @State поема и двете роли, което честно казано доста опростява нещата.

@Observable
class FormData {
    var username: String = ""
    var email: String = ""
    var acceptedTerms: Bool = false

    var isValid: Bool {
        !username.isEmpty && email.contains("@") && acceptedTerms
    }
}

struct RegistrationForm: View {
    @State private var formData = FormData()

    var body: some View {
        Form {
            TextField("Потребителско име", text: $formData.username)
            TextField("Имейл", text: $formData.email)
            Toggle("Приемам условията", isOn: $formData.acceptedTerms)

            Button("Регистрация") {
                submitForm()
            }
            .disabled(!formData.isValid)
        }
    }

    private func submitForm() {
        // Изпращане на данните
    }
}

Когато използвате @State с @Observable обект, SwiftUI притежава и управлява жизнения цикъл на обекта. Обектът оцелява между преизчертаванията на изгледа — точно както правеше @StateObject преди.

@Bindable — за обвързвания без притежание

@Bindable е нов property wrapper, представен специално за Observation Framework. Използва се, когато не притежавате обекта (тоест не го създавате с @State), но имате нужда от $ синтаксиса за създаване на обвързвания.

@Observable
class UserSettings {
    var darkMode: Bool = false
    var fontSize: Double = 14.0
    var notificationsEnabled: Bool = true
}

// Родителски изглед притежава обекта
struct SettingsScreen: View {
    @State private var settings = UserSettings()

    var body: some View {
        NavigationStack {
            SettingsDetailView(settings: settings)
                .navigationTitle("Настройки")
        }
    }
}

// Дъщерен изглед — получава обекта, но не го притежава
struct SettingsDetailView: View {
    @Bindable var settings: UserSettings

    var body: some View {
        Form {
            Toggle("Тъмен режим", isOn: $settings.darkMode)
            Slider(value: $settings.fontSize, in: 10...24, step: 1) {
                Text("Размер на шрифта: \(Int(settings.fontSize))")
            }
            Toggle("Известия", isOn: $settings.notificationsEnabled)
        }
    }
}

Без @Bindable, бихте получили грешка при компилация при опит да използвате $settings.darkMode. Правилото е просто: ако трябва само да четете — обикновено свойство е достатъчно; ако трябва да пишете с $ — слагате @Bindable.

@Environment с @Observable обекти

Синтаксисът за инжектиране на Observable обекти чрез средата също се промени. Вместо @EnvironmentObject, сега се използва @Environment с типа на обекта.

@Observable
class AuthManager {
    var isLoggedIn: Bool = false
    var currentUser: String? = nil

    func login(username: String) {
        currentUser = username
        isLoggedIn = true
    }

    func logout() {
        currentUser = nil
        isLoggedIn = false
    }
}

// Коренен изглед — инжектиране в средата
struct MyApp: App {
    @State private var authManager = AuthManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(authManager)  // Не .environmentObject()!
        }
    }
}

// Дъщерен изглед — получаване от средата
struct ProfileView: View {
    @Environment(AuthManager.self) var auth

    var body: some View {
        if auth.isLoggedIn {
            Text("Здравей, \(auth.currentUser ?? "Потребител")!")
            Button("Изход") { auth.logout() }
        } else {
            Text("Моля, влезте в профила си.")
        }
    }
}

Обърнете внимание — използваме .environment(authManager) вместо .environmentObject(authManager). По-чисто е и типът на обекта служи като ключ. Малък детайл, но прави кода по-четим.

Миграция от ObservableObject към @Observable

Добрата новина е, че миграцията от стария модел към новия е относително директна. Лошата — трябва внимание към детайлите. Ето стъпка по стъпка как става.

Стъпка 1: Промяна на модела

// ПРЕДИ:
class TaskStore: ObservableObject {
    @Published var tasks: [Task] = []
    @Published var selectedFilter: Filter = .all
    @Published var searchText: String = ""
}

// СЛЕД:
import Observation

@Observable
class TaskStore {
    var tasks: [Task] = []
    var selectedFilter: Filter = .all
    var searchText: String = ""
}

Премахвате съответствието с ObservableObject, добавяте макрото @Observable и махате всички @Published анотации. Не забравяйте import Observation.

Стъпка 2: Обновяване на изгледите

// ПРЕДИ:
struct TaskListView: View {
    @StateObject private var store = TaskStore()

    var body: some View {
        List(store.tasks) { task in
            TaskRow(task: task)
        }
    }
}

// СЛЕД:
struct TaskListView: View {
    @State private var store = TaskStore()

    var body: some View {
        List(store.tasks) { task in
            TaskRow(task: task)
        }
    }
}

Стъпка 3: Обновяване на дъщерни изгледи

// ПРЕДИ:
struct TaskFilterView: View {
    @ObservedObject var store: TaskStore

    var body: some View {
        Picker("Филтър", selection: $store.selectedFilter) {
            ForEach(Filter.allCases, id: \.self) { filter in
                Text(filter.rawValue).tag(filter)
            }
        }
    }
}

// СЛЕД:
struct TaskFilterView: View {
    @Bindable var store: TaskStore  // @Bindable, защото имаме нужда от $

    var body: some View {
        Picker("Филтър", selection: $store.selectedFilter) {
            ForEach(Filter.allCases, id: \.self) { filter in
                Text(filter.rawValue).tag(filter)
            }
        }
    }
}

Стъпка 4: Обновяване на средата

// ПРЕДИ:
ContentView()
    .environmentObject(store)

// СЛЕД:
ContentView()
    .environment(store)

// ПРЕДИ:
@EnvironmentObject var store: TaskStore

// СЛЕД:
@Environment(TaskStore.self) var store

Обобщение на замените

  • ObservableObject@Observable
  • @Published → премахва се (автоматично)
  • @StateObject@State
  • @ObservedObject → обикновено свойство или @Bindable (ако трябват bindings)
  • @EnvironmentObject@Environment(ТипаНаОбекта.self)
  • .environmentObject().environment()

Минималната версия за деплойване е iOS 17+, така че ако поддържате по-стари версии, ще трябва да използвате #available проверки или условна компилация. На практика много екипи вече са минали изцяло на iOS 17+, но ако не сте сред тях — бъдете готови за малко допълнителна работа.

Производителност: Гранулирано проследяване

Тук идва най-готината част. Гранулираното проследяване е може би най-значимото предимство на Observation Framework. Нека видим конкретен пример.

@Observable
class AppState {
    var userName: String = "Иван"
    var notificationCount: Int = 0
    var theme: String = "light"
    var isLoading: Bool = false
}

struct HeaderView: View {
    var state: AppState

    var body: some View {
        // Този изглед чете САМО userName
        Text("Здравей, \(state.userName)!")
            .font(.title)
    }
}

struct BadgeView: View {
    var state: AppState

    var body: some View {
        // Този изглед чете САМО notificationCount
        if state.notificationCount > 0 {
            Text("\(state.notificationCount)")
                .badge(state.notificationCount)
        }
    }
}

struct ThemeView: View {
    var state: AppState

    var body: some View {
        // Този изглед чете САМО theme
        Text("Тема: \(state.theme)")
    }
}

struct DashboardView: View {
    @State private var state = AppState()

    var body: some View {
        VStack {
            HeaderView(state: state)
            BadgeView(state: state)
            ThemeView(state: state)

            Button("Увеличи известията") {
                state.notificationCount += 1
                // Със стария ObservableObject: HeaderView, BadgeView И ThemeView
                // всички щяха да се преизчертаят.
                // С @Observable: САМО BadgeView се преизчертава,
                // защото само той чете notificationCount.
            }
        }
    }
}

Тази разлика е огромна при по-сложни приложения. При ObservableObject, промяна на което и да е @Published свойство изпращаше известие чрез objectWillChange publisher-а на Combine, което караше всички наблюдаващи изгледи да се преизчертаят. С @Observable, SwiftUI записва кои свойства са прочетени по време на изпълнението на body и реагира само на промени в тези конкретни свойства.

На практика това означава:

  • По-малко CPU цикли за ненужни преизчертавания
  • По-плавни анимации и по-отзивчив интерфейс
  • По-дълъг живот на батерията (особено при приложения с чести промени на данните)
  • Не ви се налага ръчно да разделяте модели на по-малки части заради производителността

Последното е важно. Преди бяхме принудени да разбиваме view model-ите на по-малки парчета, само за да избегнем излишни преизчертавания. Сега това просто не е нужно.

@ObservationIgnored и @ObservationTracked

Въпреки че @Observable автоматично проследява всички съхранявани свойства, понякога имате нужда от по-фин контрол.

@ObservationIgnored

@ObservationIgnored маркира свойства, които не трябва да предизвикват обновяване на изгледи при промяна. Кога е полезно?

  • За кешове и временни данни, които не влияят на UI-я
  • За тежки обекти като мрежови клиенти или менажери на файлове
  • За вътрешно състояние, което не е визуално значимо
import Observation

@Observable
class MediaPlayer {
    var currentTrack: String = "Няма избрана песен"
    var isPlaying: Bool = false
    var volume: Double = 0.5

    // Тези свойства НЕ предизвикват преизчертаване на изгледи
    @ObservationIgnored
    var audioBuffer: [Float] = []

    @ObservationIgnored
    var analyticsData: [String: Any] = [:]

    @ObservationIgnored
    private var internalTimer: Timer?

    @ObservationIgnored
    let networkClient = NetworkClient()

    func play() {
        isPlaying = true  // Ще предизвика обновяване
        analyticsData["lastPlayed"] = Date()  // Няма да предизвика обновяване
    }

    func updateBuffer(with samples: [Float]) {
        audioBuffer = samples  // Няма да предизвика обновяване
    }
}

@ObservationTracked

@ObservationTracked е обратното на @ObservationIgnored и честно казано рядко ще го ползвате директно. Макрото @Observable автоматично анотира всяко свойство с @ObservationTracked зад кулисите, така че няма нужда да го правите ръчно.

@Observable
class ExampleModel {
    // Тези две декларации са еквивалентни:
    var name: String = ""                    // Автоматично @ObservationTracked
    @ObservationTracked var age: Int = 0     // Изрично @ObservationTracked (излишно)

    @ObservationIgnored var cache: [String: Data] = [:]  // Не се проследява
}

withObservationTracking извън SwiftUI

Observation Framework не е ограничен до SwiftUI — можете да го използвате навсякъде в Swift кода си чрез функцията withObservationTracking. Това е особено полезно, ако работите с UIKit или просто искате да реагирате на промени от произволен контекст.

import Observation

@Observable
class DataStore {
    var items: [String] = []
    var lastUpdated: Date = Date()
}

let store = DataStore()

// Наблюдаване на промени извън SwiftUI
withObservationTracking {
    // Този блок записва кои свойства се четат
    print("Брой елементи: \(store.items.count)")
} onChange: {
    // Извиква се, когато НЯКОЕ от прочетените свойства се промени
    print("Данните се промениха!")
}

Критично важно: Callback-ът onChange се извиква само веднъж. След първата промяна наблюдението спира. Ако искате да продължите да наблюдавате, трябва да извикате withObservationTracking отново — рекурсивно.

import Observation

@Observable
class SensorData {
    var temperature: Double = 20.0
    var humidity: Double = 50.0
}

func observeContinuously(data: SensorData) {
    withObservationTracking {
        print("Температура: \(data.temperature)°C, Влажност: \(data.humidity)%")
    } onChange: {
        // Планираме ново наблюдение на главната опашка
        DispatchQueue.main.async {
            observeContinuously(data: data)
        }
    }
}

let sensorData = SensorData()
observeContinuously(data: sensorData)

// По-късно, всяка промяна ще бъде засечена:
sensorData.temperature = 22.5  // Ще отпечата новите стойности и ще се регистрира отново

Това е полезно за сценарии като логване, синхронизация с бекенд или обновяване на UIKit изгледи. В SwiftUI не е нужно да правите това ръчно — фреймуъркът го прави автоматично зад кулисите.

Често срещани грешки и капани

При работа с Observation Framework има няколко капана, в които е лесно да попаднете. Ето най-важните (от личен опит включително).

1. Използване на @StateObject вместо @State

Ако вече ползвате @Observable, не бъркайте стария @StateObject с новия подход. Те просто не работят заедно.

@Observable
class ViewModel {
    var data: [String] = []
}

// ГРЕШНО: @StateObject не работи с @Observable
struct MyView: View {
    // @StateObject private var vm = ViewModel() // Грешка при компилация!

    // ПРАВИЛНО:
    @State private var vm = ViewModel()

    var body: some View {
        List(vm.data, id: \.self) { item in
            Text(item)
        }
    }
}

2. Забравяне на @Bindable, когато трябват обвързвания

Класическа грешка. Подавате @Observable обект на дъщерен изглед без @Bindable и после се чудите защо $ синтаксисът не работи.

@Observable
class Settings {
    var volume: Double = 0.5
}

struct VolumeControl: View {
    // ГРЕШНО: Няма да компилира с $settings.volume
    // var settings: Settings

    // ПРАВИЛНО: @Bindable позволява $ синтаксис
    @Bindable var settings: Settings

    var body: some View {
        Slider(value: $settings.volume, in: 0...1)
    }
}

3. Опит за използване на @Observable със структури

@Observable работи само с класове. Структурите просто няма как да го поддържат — имат стойностна семантика.

// ГРЕШНО: Структурите не поддържат @Observable
// @Observable
// struct UserData {
//     var name: String = ""
// }

// ПРАВИЛНО: Използвайте клас
@Observable
class UserData {
    var name: String = ""
}

4. @Environment с грешен синтаксис

Новият синтаксис за @Environment използва типа, не key path. Лесно е да се объркате, особено ако дълго сте работили със стария API.

@Observable
class ThemeManager {
    var isDark: Bool = false
}

// ГРЕШНО (стар синтаксис):
// @EnvironmentObject var theme: ThemeManager

// ПРАВИЛНО (нов синтаксис):
// @Environment(ThemeManager.self) var theme

5. Четене на свойства извън body

Тук е важно да знаете: SwiftUI проследява достъпа до свойства само по време на изпълнението на body. Ако прочетете свойство в onAppear или друг callback, то няма да бъде проследено за автоматично обновяване.

@Observable
class Counter {
    var count: Int = 0
}

struct CounterView: View {
    var counter: Counter

    var body: some View {
        VStack {
            // ДОБРЕ: Четене в body — ще се проследи
            Text("Брояч: \(counter.count)")

            Button("Увеличи") {
                counter.count += 1
            }
        }
        .onAppear {
            // Четенето тук НЕ регистрира проследяване
            // Но това обикновено не е проблем, защото onAppear
            // се изпълнява за странични ефекти, а не за визуализация
            print("Текущ брояч: \(counter.count)")
        }
    }
}

6. Съвместимост със Swift 6.2 и Approachable Concurrency

В Swift 6.2 с функцията Approachable Concurrency, @Observable класовете работят безпроблемно с @MainActor по подразбиране. Тъй като SwiftUI изгледите вече са изолирани към главния актьор, вашите Observable модели също се изпълняват на главния актьор — което елиминира повечето главоболия с конкурентността.

// В Swift 6.2 с Approachable Concurrency,
// @Observable класовете по подразбиране са @MainActor
@Observable
class ProfileViewModel {
    var name: String = ""
    var isLoading: Bool = false

    func loadProfile() async {
        isLoading = true
        // Мрежовото извикване се изпълнява на фонова нишка,
        // но обновяването на свойствата се връща на MainActor
        let profile = await fetchProfile()
        name = profile.name
        isLoading = false
    }

    private func fetchProfile() async -> (name: String, id: Int) {
        try? await Task.sleep(for: .seconds(1))
        return (name: "Иван Петров", id: 1)
    }
}

Практически пример: Приложение за задачи

Време е да обединим всичко в цялостен практически пример — приложение за управление на задачи, което използва @Observable, @State, @Bindable и @Environment. Това е нещо, което реално бихте могли да използвате като отправна точка за собствен проект.

Модел на данните

import Foundation
import Observation

struct TodoItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool = false
    var priority: Priority = .medium
    var createdAt: Date = Date()

    enum Priority: String, CaseIterable {
        case low = "Нисък"
        case medium = "Среден"
        case high = "Висок"
    }
}

@Observable
class TodoStore {
    var items: [TodoItem] = []
    var searchText: String = ""
    var selectedPriority: TodoItem.Priority? = nil

    // Изчислено свойство — автоматично се обновява
    var filteredItems: [TodoItem] {
        items.filter { item in
            let matchesSearch = searchText.isEmpty ||
                item.title.localizedCaseInsensitiveContains(searchText)
            let matchesPriority = selectedPriority == nil ||
                item.priority == selectedPriority
            return matchesSearch && matchesPriority
        }
    }

    var completedCount: Int {
        items.filter(\.isCompleted).count
    }

    var pendingCount: Int {
        items.count - completedCount
    }

    func addItem(title: String, priority: TodoItem.Priority = .medium) {
        let newItem = TodoItem(title: title, priority: priority)
        items.append(newItem)
    }

    func toggleItem(_ item: TodoItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].isCompleted.toggle()
        }
    }

    func deleteItems(at offsets: IndexSet) {
        let sortedItems = filteredItems
        for offset in offsets {
            if let index = items.firstIndex(where: { $0.id == sortedItems[offset].id }) {
                items.remove(at: index)
            }
        }
    }
}

Главен изглед на приложението

import SwiftUI

@main
struct TodoApp: App {
    @State private var store = TodoStore()

    var body: some Scene {
        WindowGroup {
            TodoListView()
                .environment(store)
        }
    }
}

Списък със задачи

struct TodoListView: View {
    @Environment(TodoStore.self) var store
    @State private var showingAddSheet = false

    var body: some View {
        @Bindable var store = store

        NavigationStack {
            VStack(spacing: 0) {
                // Статистика
                HStack {
                    Label("\(store.pendingCount) чакащи", systemImage: "circle")
                    Spacer()
                    Label("\(store.completedCount) завършени", systemImage: "checkmark.circle.fill")
                }
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .padding()

                // Списък
                List {
                    ForEach(store.filteredItems) { item in
                        TodoRowView(item: item) {
                            store.toggleItem(item)
                        }
                    }
                    .onDelete { offsets in
                        store.deleteItems(at: offsets)
                    }
                }
            }
            .navigationTitle("Моите задачи")
            .searchable(text: $store.searchText, prompt: "Търсене...")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        showingAddSheet = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }

                ToolbarItem(placement: .topBarLeading) {
                    Menu("Филтър") {
                        Button("Всички") { store.selectedPriority = nil }
                        ForEach(TodoItem.Priority.allCases, id: \.self) { priority in
                            Button(priority.rawValue) {
                                store.selectedPriority = priority
                            }
                        }
                    }
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                AddTodoView()
                    .environment(store)
            }
        }
    }
}

Ред за задача

struct TodoRowView: View {
    let item: TodoItem
    let onToggle: () -> Void

    var body: some View {
        HStack {
            Button(action: onToggle) {
                Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
                    .foregroundStyle(item.isCompleted ? .green : .gray)
                    .font(.title3)
            }
            .buttonStyle(.plain)

            VStack(alignment: .leading, spacing: 4) {
                Text(item.title)
                    .strikethrough(item.isCompleted)
                    .foregroundStyle(item.isCompleted ? .secondary : .primary)

                HStack {
                    Text(item.priority.rawValue)
                        .font(.caption)
                        .padding(.horizontal, 8)
                        .padding(.vertical, 2)
                        .background(priorityColor.opacity(0.2))
                        .foregroundStyle(priorityColor)
                        .clipShape(Capsule())

                    Text(item.createdAt, style: .date)
                        .font(.caption2)
                        .foregroundStyle(.tertiary)
                }
            }
        }
        .padding(.vertical, 4)
    }

    private var priorityColor: Color {
        switch item.priority {
        case .low: return .blue
        case .medium: return .orange
        case .high: return .red
        }
    }
}

Добавяне на нова задача

struct AddTodoView: View {
    @Environment(TodoStore.self) var store
    @Environment(\.dismiss) var dismiss
    @State private var title = ""
    @State private var priority: TodoItem.Priority = .medium

    var body: some View {
        NavigationStack {
            Form {
                Section("Заглавие") {
                    TextField("Какво трябва да направите?", text: $title)
                }

                Section("Приоритет") {
                    Picker("Приоритет", selection: $priority) {
                        ForEach(TodoItem.Priority.allCases, id: \.self) { p in
                            Text(p.rawValue).tag(p)
                        }
                    }
                    .pickerStyle(.segmented)
                }
            }
            .navigationTitle("Нова задача")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Отказ") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Добави") {
                        store.addItem(title: title, priority: priority)
                        dismiss()
                    }
                    .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
        }
    }
}

Този пример демонстрира пълния набор от инструменти: @Observable за модела, @State за притежаване на обекта, @Environment за инжектиране в дъщерни изгледи, @Bindable за създаване на обвързвания, и обикновени свойства за четене. Ако тръгвате от нулата с Observation Framework, това е солидна основа.

Често задавани въпроси (FAQ)

Мога ли да използвам @Observable с iOS 16 или по-стари версии?

За съжаление, не. Observation Framework изисква минимум iOS 17, macOS 14, watchOS 10 или tvOS 17. Ако поддържате по-стари версии, трябва да продължите с ObservableObject или да приложите условна компилация с if #available(iOS 17, *). На практика доста екипи поддържат и двата подхода паралелно по време на прехода.

@Observable работи ли със структури?

Не. Макрото @Observable работи само с класове. Структурите имат стойностна семантика — при всяка промяна се създава ново копие — а Observation Framework изисква референтна семантика за проследяване на мутациите. Ако имате нужда от наблюдаем стойностен тип, просто използвайте @State с обикновена структура — SwiftUI си има собствен механизъм за това.

Какво се случва, ако забравя @Bindable?

Получавате грешка при компилация: "Cannot find '$variableName' in scope." Решението е просто — добавете @Bindable пред свойството. Ако изгледът само чете данни, @Bindable не е нужен — той е необходим само когато трябва да записвате обратно чрез bindings.

Мога ли да смесвам ObservableObject и @Observable в едно приложение?

Да, абсолютно. Двата подхода могат да съществуват едновременно, което е полезно при постепенна миграция. Можете да конвертирате модели един по един. Само внимавайте — @StateObject работи единствено с ObservableObject, а @State за обекти — с @Observable. Не ги смесвайте за един и същ тип.

withObservationTracking извиква ли onChange повече от веднъж?

Не. Callback-ът onChange се извиква само веднъж — при първата промяна на което и да е проследено свойство. След това наблюдението спира. За непрекъснато наблюдение, трябва да извикате withObservationTracking отново в onChange блока. SwiftUI прави това автоматично при всяко преизчертаване, така че в контекста на SwiftUI не е нужно да се притеснявате за това.

За Автора Editorial Team

Our team of expert writers and editors.