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

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

@Observable SwiftUI 2026: Complete Guide

Въведение: Защо 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 не е нужно да се притеснявате за това.

За Автора Priya Raghavan

Priya spent six years at Instacart building the iOS shopper app, where she led the migration from UIKit to SwiftUI across 80+ screens and cut crash-free sessions from 99.2% to 99.87%. Before that, she was a contractor at a Bay Area design studio shipping App Store apps for two Fortune 500 retail clients. She focuses on practical SwiftUI architecture - what holds up when you have 12 engineers committing to the same codebase, not just toy MVVM examples. Her recent work involves The Composable Architecture, Swift concurrency migration audits, and reducing main-thread hangs on older devices like the iPhone XR that enterprise fleets still ship. Priya runs a small consultancy in Oakland and occasionally speaks at try! Swift NYC. She has been writing Swift since the Objective-C bridging days of 2015.