По граблям, по граблям. Пишем отзывчивый интерактивный виджет IOS 17

Всем привет! На связи Анна Жаркова, руководитель группы мобильной разработки в компании Usetech. В 2023 году на WWDC Apple представили много нового и интересного API, среди которого были долгожданные интерактивные виджеты, реагирующие на нажатия в моменте. Однако, как показывает практика, не все так просто и красиво, как Apple показывают на демонстрационных сессиях, а от беты до релиза что-то в API обязательно ломается или внезапно меняется.
Поэтому сегодня мы поговорим, как с помощью Widget Kit iOS 17 сделать виджет интерактивным, рабочим и отзывчивым в моменте, и обойти подводные камушки, оставленные разработчиками API. Рассматривать будем на примере самописного приложения для заметок TODO.

y1rpehyfrhbmg9ps6qq4fvyuqns.png

В таких приложениях также важно синхронизировать состояние между таргетами без потерь и задержек. Данные (наши тудушки и их состояние) мы сохраняем локально. Для этого используем инструмент для хранения данных SwiftData. Данный фреймворк также был представлен на WWDC 2023, и при его использовании в разных таргетах можно встретить тоже много подводных камней.
Итак, давайте посмотрим, что у нас есть в начале. Наше основное приложение у нас реализовано на SwiftUI:

Список записей в приложении View
struct ListContentView: View {
   @Query var items: [TodoItem]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(tems) { item in
                    Label(item.taskName , systemImage: "circle\(item.isCompleted ? ".fill" : "")")
                        .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle())
                        .onTapGesture {
                            withAnimation {
                                item.isCompleted = !item.isCompleted
                     //Обновление
                            }
                        }
                }.onDelete {index in
                  /// Вызываем удаление
                    deleteItems(offsets: index)
                }
            }
            .navigationTitle("TODO")
            .navigationBarItems(trailing: Button(action: addItem, label: {
              /// По нажатию на эту кнопку добавляем
                Image(systemName: "plus")
            }))
        }
    }


Данные для отображения берем напрямую из хранилища с помощью макроса Query. В качестве данного инструментария мы используем SwiftData. Для удобства помещаем логику в отдельный класс TodoDataManager:

class TodoDataManager {
    static var sharedModelContainer: ModelContainer = {do {
            return try ModelContainer(for: TodoItem.self)
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

// Тут методы
}


Контейнер для подключения берем из нашего TodoDataManager:

@main
struct TodoAppApp: App {
    var sharedModelContainer: ModelContainer = TodoDataManager.sharedModelContainer

    var body: some Scene {
        WindowGroup {
            ListContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}


Сама же модель, которую мы используем для хранения и отображения данных, будет иметь буквально несколько полей: имя задачи, флаг выполнения, дата.

@Model
class TodoItem: Identifiable {
    var id: UUID
    var taskName: String
    var startDate: Date
    var isCompleted: Bool = false
    
    init(task: String, startDate: Date) {
        id = UUID()
        taskName = task
        self.startDate = startDate
    }
}


Удаление и добавление записи делаем через контекст нашего хранилища:

    @MainActor
    func addItem(name: String) {
       withAnimation {
        let newItem = TodoItem(task: name, startDate: Date())
        TodoDataManager.sharedModelContext.insert(newItem)
    }
    }


Пока ничего необычного, самое стандартное решение.
Теперь переходим к виджету:
nv5kvrkjl32b00r2idseanwoy6k.png
Добавляем к нашему приложению таргет New Target — Widget Extensions. У нас создастся заготовка нашего виджета:

Код Widget
struct TodoAppWidget: Widget {
    let kind: String = "TodoAppWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            TodoAppWidgetView(entry: entry)
                .containerBackground(for: .widget) {
                    BackgroundView()
                }
        }.supportedFamilies([.systemSmall, .systemLarge])
    }
}


Это структура типа Widget устанавливает конфигурацию виджета, задание его UI и механизма обновления состояний (Provider).
За отображение нашего View отвечает TodoAppWidgetView.

Заменим UI View виджета:

TodoAppWidgetView
import WidgetKit

struct TodoAppWidgetView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .leading) {
            HStack(alignment: .center, content: {
                Text("Notes").foregroundStyle(.white)
                Spacer()
                Text("\(entry.uncompleted)/\(entry.total)").foregroundStyle(.white)
            }).frame(height: 40)
            ForEach(entry.data.indices) { index in
                    Label(entry.data[index].taskName , systemImage: "circle\(entry.data[index].isCompleted ? ".fill" : "")")
                        .frame(maxWidth: .infinity, alignment: .leading)             
            }
            Spacer()
            HStack(alignment: .bottom, content: {
                Text("Add task +").foregroundColor(.gray)
            }).frame(height: 40)
        }
    }
}

Виджет не может иметь состояние и не может зависеть от переменных состояния @PropertyWrapper. Для отрисовки данных во View мы передаем модель Entry через механизм нашего провайдера состояний Provider. Модель данных должна поддерживать протокол TimelineEntry:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let data: [TodoItem]
    var completed: Int {
        return data.filter{
            $0.isCompleted
        }.count
    }
    var total: Int {
        return data.count
    }
}


Нам потребуется массив из нескольких тудушек, число всех записей и число завершенных. Чтобы мы могли поддерживать ту же структуру данных, которую используем для основного приложения, добавим ей поддержку всех таргетов приложения:
krz1np4jwg2cquic1siasgbil94.png
Аналогично включим поддержку всех таргетов для TodoDataManager.

Сам провайдер состояний хранит в себе набор снепшотов нашего виджета в момент времени для отображения их по таймлайну через заданные промежутки. В iOS 17 провайдер реализует протокол AppIntentTimelineProvider с поддержкой async/await:

struct Provider: AppIntentTimelineProvider {

//...

    func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
        let items = await loadData()
        return SimpleEntry(date: Date(), data: items)
    }
    
    func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline {
        var entries: [SimpleEntry] = []
        let entryDate = Date()
        let items = await loadData() //<-- вот тут данные запрашиваем из TodoDataManager
        let entry = SimpleEntry(date: entryDate, data: items)
        //20 потом заменим на 60
        return Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(20)))
    }
}


Метод loadData вызывает запрос данных из TodoDataManager через fetch, используя sharedModelContainer и его контекст:

//Widget
@MainActor
    func loadData()->[TodoItem] {
        return TodoDataManager.shared.loadItems()
    }

//TodoDataManager
@MainActor
    func loadItems(_ count: Int? = nil)->[TodoItem] {
       return (try? TodoDataManager.sharedModelContainer.mainContext
                          .fetch(FetchDescriptor())) ?? []
    }


На этом этапе возникает вопрос:, а почему мы не используем `@Query`в провайдере? Ответ: виджет не зависит от состояния и не может иметь подписку на состояние.

Запустим наше приложение и добавим пару записей:
1hxkcfrmcgcdfcldckmus0w075g.png
Однако, это никак не повлияет на наш виджет. На данном этапе у него нет доступа к хранилищу основного приложения. Для того, чтобы расшарить доступ, нам нужно добавить AppGroups и таргету приложения, и таргету расширения:
8eqxcmr6xion-7o1cgiq8r9bonm.png
Укажем одну и ту же группу:
abfsmjw_23w7yo1pblbnu0zrwjy.png
Группа задает внутри url для нашего локального хранилища. Данные, которые мы сохранили до этого, теперь нам недоступны. Удаляем предыдущие виджеты с экраны и добавляем новый:
re174hk7grp6jmdwpkocfqn9je8.png
Теперь у нас есть доступ к хранилищу.
Однако, если мы изменим состояние записи, добавим новую или удалим, наш виджет не отреагирует на это корректно и не считает актуальные данные.
В текущей реализации мы считываем данные один раз при установке виджета. Также мы запрашиваем актуальное состояние в провайдере таймлайна:

Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(60)))


Интервал между обновлениями не должен быть меньше минуты, иначе оно будет игнорироваться. Для таймеров, плееров и прочее будут другие решения, но об этом не сегодня.

Давайте добавим обновление виджета при изменении данных в приложении. Для этого в нашем TodoDataManager добавим вызов WidgetCenter.shared.reloadAllTimelines () для перезагрузки всех виджетов, либо reloadTimelines (of: Kind) для перезагрузки виджетов с заданным ключевым параметром Kind:

 @MainActor
    func addItem(name: String) {
        // код
        WidgetCenter.shared.reloadAllTimelines()
    }
    
    @MainActor
    func deleteItem(offsets: IndexSet) {
        //код
        WidgetCenter.shared.reloadAllTimelines()
    }

    @MainActor
    func updateItem(index: Int) {
        let items = loadItems()
        let checked = items[index].isCompleted
        items[index].isCompleted = !checked
        WidgetCenter.shared.reloadAllTimelines()
    }


Также давайте создадим специальный контекст модели, который будем использовать для операций:

 static var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            TodoItem.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

//Тот самый контекст
    static var sharedModelContext = ModelContext(sharedModelContainer)


Наш виджет теперь может реагировать на добавление и удаление записей моментально.
Обратите внимание, что все методы для записи мы пометили @MainActor для вызова работы с хранилищем в главном потоке.

Добавим реакцию со стороны виджета. В ios 17 в WidgetKit появилась возможность использования AppIntent для передачи событий от кнопок и тогглов и вызова логики. Есть целый ряд специальных AppIntent, которые не только поддерживают интерактивность, но и включают в себя различные полезные разрешения и поддержку функционала.
Создадим такой интент:

struct CheckTodoIntent: AppIntent {
    @Parameter(title: "Index")
    var index: Int
    
    init(index: Int) {
        self.index = index
    }
    
    func perform() async throws -> some IntentResult {
      //Вызов обновления по индексу
        await TodoDataManager.shared.updateItem(index: index)
        return .result()
    }
}


Мы планируем по индексу вызывать событие изменения записи. Нужное нам свойство мы помечаем Parameter с указанием ключа. В нашем случае мы будем использовать индекс (порядковый номер) элемента из массива записей в виджете.
В основном методе perform асинхронно вызываем метод TodoDataManager. Также нам нужно обернуть в кнопки наши строки:

 ForEach(entry.data.indices) { index in
              //Вот сюда мы индекс и передаем
                Button(intent: CheckTodoIntent(index: index)) {
                    Label(entry.data[index].taskName , systemImage: "circle\(entry.data[index].isCompleted ? ".fill" : "")")
                        .frame(maxWidth: .infinity, alignment: .leading)
                }     
            }


На этом этапе мы можем заметить, что приложению при возврате из виджета может потребоваться перезапуск для обновления состояния. Дело в следующем:
1. `@Query` у нас вызывается при старте нашего приложения и может отслеживать изменения в Foreground. И вообще он багованный.
2. SwiftData mainContext может работать корректно только в foreground. Виджет запрашивает данные не из foreground, приложение при возврате стартует из background. Нужен контекст для фоновой задачи.
3 В виджете может также наблюдаться рассинхрон при обновлении значения.
rblc-pq1bffetng_to-0g6meyno.png
Попробуем решить эту проблему через фоновый контекст. Не путайте фоновый поток и фоновую таску. Речь именно о последней.
Для работы с background-контекстом делаем обертку-актор:

@ModelActor
actor SwiftDataModelActor {
    
    func loadData() -> [TodoItem] {
        let data = (try? modelExecutor.modelContext.fetch(FetchDescriptor())) ?? 
                 [TodoItem]()
        return data
    }
}


Макрос ModelActor создает специальный modelExecutor, который и даст нам тот самый фоновый контекст модели. Через него делаем запрос fetch для получения данных.
На стороне виджета заменяем код метода для загрузки:

 @MainActor
    func reloadItems() async -> [TodoItem] {
            let actor = SwiftDataModelActor(modelContainer: TodoDataManager.sharedModelContainer)
            return await actor.loadData()
    }


Для нашего основного приложения сделаем следующее. Убираем `@Query`, создаем ObservableObject и крепим к нашему View как ObservedObject. В нем сделаем 2 метода для запроса данных в фоне и в main контекстах:

@MainActor
    func loadItems(){
        Task.detached {
            let actor = SwiftDataModelActor(modelContainer: TodoDataManager.sharedModelContainer)
            await self.save(items: await actor.loadData())
        }
    }
    
    @MainActor
    func save(items: [TodoItem]) {
        self.items = items
    }
   
    @MainActor
    func reloadItems() {
        self.items = TodoDataManager.shared.loadItems()
    }


Запрос данных из фона будем вызывать при возврате в приложение. Например, в методе onChange:

.onChange(of: phase) { oldValue, newValue in
            if oldValue == .background {
                model.loadItems()
            }


А вот reloadItems с mainContext нам потребуется в форграунде нашего приложения для запроса данных, например, после создания записи.
Мы убрали `@Query`, и теперь у нас нет автоматической подписки на изменения данных. Чтобы исправить это создаем протокол UpdateListener, и по принципу делегата, связываем TodoDataManager с нашей ViewModel:

protocol UpdateListener {
    func loadItems()
    
    func reloadItems()
}

//TodoDataManager
@MainActor
    func addItem(name: String) {
        let newItem = TodoItem(task: name, startDate: Date())
        TodoDataManager.sharedModelContext.insert(newItem)
        listeners.forEach { listener in
            listener.reload()
        }
        WidgetCenter.shared.reloadAllTimelines()
    }


Надо заменить и обновление состояния из списка:

.onTapGesture {
       withAnimation {
           item.isCompleted = !item.isCompleted
          TodoDataManager.shared.updateItem(index: model.items.firstIndex(of: item) ?? 0)
              }
      }


Получаем работающее приложение с виджетом:
4yd09pheaufajhfa-y58lsa9-hw.gif

Резюмируем, что мы сделали:
1. Добавили AppGroups приложению и виджету
2. Создали единый контекст для доступа к операциям
3. Добавили AppIntent в кнопку для вызова событий.
4. Из операций вызвали перезагрузку виджета.
5. Решили проблему с запросом в фоне для SwiftData
Profit!

В следующий раз попробуем разобраться с плеером и особыми AppIntent.
Полезные ссылки:
developer.apple.com/videos/play/wwdc2023/10028
developer.apple.com/documentation/widgetkit/adding-interactivity-to-widgets-and-live-activities
developer.apple.com/documentation/swiftdata/modelactor

© Habrahabr.ru