Всем привет! На связи Анна Жаркова, руководитель группы мобильной разработки в компании Usetech. В 2023 году на WWDC Apple представили много нового и интересного API, среди которого были долгожданные интерактивные виджеты, реагирующие на нажатия в моменте. Однако, как показывает практика, не все так просто и красиво, как Apple показывают на демонстрационных сессиях, а от беты до релиза что-то в API обязательно ломается или внезапно меняется.
Поэтому сегодня мы поговорим, как с помощью Widget Kit iOS 17 сделать виджет интерактивным, рабочим и отзывчивым в моменте, и обойти подводные камушки, оставленные разработчиками API. Рассматривать будем на примере самописного приложения для заметок TODO.
В таких приложениях также важно синхронизировать состояние между таргетами без потерь и задержек. Данные (наши тудушки и их состояние) мы сохраняем локально. Для этого используем инструмент для хранения данных SwiftData. Данный фреймворк также был представлен на WWDC 2023, и при его использовании в разных таргетах можно встретить тоже много подводных камней.
Итак, давайте посмотрим, что у нас есть в начале. Наше основное приложение у нас реализовано на SwiftUI:
Данные для отображения берем напрямую из хранилища с помощью макроса 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) } }
Пока ничего необычного, самое стандартное решение.
Теперь переходим к виджету:
Добавляем к нашему приложению таргет New Target — Widget Extensions. У нас создастся заготовка нашего виджета:
Это структура типа Widget устанавливает конфигурацию виджета, задание его UI и механизма обновления состояний (Provider).
За отображение нашего View отвечает TodoAppWidgetView.
Заменим UI View виджета:
Виджет не может иметь состояние и не может зависеть от переменных состояния @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 } }
Нам потребуется массив из нескольких тудушек, число всех записей и число завершенных. Чтобы мы могли поддерживать ту же структуру данных, которую используем для основного приложения, добавим ей поддержку всех таргетов приложения:
Аналогично включим поддержку всех таргетов для 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<SimpleEntry> { 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<TodoItem>())) ?? [] }
На этом этапе возникает вопрос: а почему мы не используем `@Query`в провайдере? Ответ: виджет не зависит от состояния и не может иметь подписку на состояние.
Запустим наше приложение и добавим пару записей:
Однако, это никак не повлияет на наш виджет. На данном этапе у него нет доступа к хранилищу основного приложения. Для того, чтобы расшарить доступ, нам нужно добавить AppGroups и таргету приложения, и таргету расширения:
Укажем одну и ту же группу:
Группа задает внутри url для нашего локального хранилища. Данные, которые мы сохранили до этого, теперь нам недоступны. Удаляем предыдущие виджеты с экраны и добавляем новый:
Теперь у нас есть доступ к хранилищу.
Однако, если мы изменим состояние записи, добавим новую или удалим, наш виджет не отреагирует на это корректно и не считает актуальные данные.
В текущей реализации мы считываем данные один раз при установке виджета. Также мы запрашиваем актуальное состояние в провайдере таймлайна:
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. В виджете может также наблюдаться рассинхрон при обновлении значения.
Попробуем решить эту проблему через фоновый контекст. Не путайте фоновый поток и фоновую таску. Речь именно о последней.
Для работы с background-контекстом делаем обертку-актор:
@ModelActor actor SwiftDataModelActor { func loadData() -> [TodoItem] { let data = (try? modelExecutor.modelContext.fetch(FetchDescriptor<TodoItem>())) ?? [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) } }
Получаем работающее приложение с виджетом:
Резюмируем, что мы сделали:
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