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

от автора

image

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

Поэтому сегодня мы поговорим, как с помощью Widget Kit iOS 17 и AppIntent сделать виджет не только интерактивным, но и рабочим и отзывчивым в моменте, и обойти подводные камушки, оставленные разработчиками API. Рассматривать будем на примере самописного приложения для заметок TODO.

Для тех, кому не терпится, или кто хочет читать и смотреть код одновременно, сам код

Помимо обработки событий из самого виджета в таких приложениях также важно синхронизировать состояние между таргетами без потерь и задержек. Данные (наши тудушки и их состояние) мы сохраняем локально. Для этого используем инструмент для хранения данных 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")             }))         }     } 

Хранилище на SwiftData
Данные для отображения берем напрямую из хранилища с помощью макроса 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

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     } } 

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

Аналогично включим поддержку всех таргетов для TodoDataManager. Теперь мы сможем запросить логику получения данных прямо из виджета

Состояние виджета и шаринг SwiftData
Сам провайдер состояний хранит в себе набор снепшотов нашего виджета в момент времени для отображения их по таймлайну через заданные промежутки. В 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)                 }                  } 

SwiftData и foreground/background
Однако, задачу шаринга состояния мы решили не до конца. На этом этапе мы можем заметить, что приложению при возврате из виджета может потребоваться перезапуск для обновления состояния. Дело в следующем:

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!

Что можно сказать в итоге:
Хотя у нас есть механизм и для создания интерактивных виджетов, и для шаринга состояний, остается довольно много не совсем задокументированных или неочевидных нюансов. Все-таки это лучше, чем ничего, может, к версии iOS 18 эти проблемы исправят. Либо добавят нам новый удобный функционал.

В следующий раз попробуем разобраться с плеером и особыми 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

Сам код


ссылка на оригинал статьи https://habr.com/ru/articles/798925/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *