Интерактивные виджет-подборки в Иви (iOS)

от автора

И снова здравствуйте! В 2022 году у нас появились первые HomeScreen-виджеты, это был первый опыт работы с библиотекой WidgetKit. Затем Apple представила LockScreen-виджеты, и мы их тоже добавили. А выход iOS 17 и поддержка библиотеки AppIntents ознаменовали новый этап в эволюции виджетов.

В этой статье расскажем о том, как мы зарелизили интерактивные виджеты, и из чего они состоят (разделение слоёв на SPM-пакеты, обеспечение качества (unit, snapshot-тесты), accessibility), а также о нюансах, которыми Apple не делилась на WWDC23, но с которыми столкнулись мы.

Введение

В предыдущей статье разобрали в пет‑проекте, как работают виджеты + интерактивность. Данная статья больше про кейс разработки интерактивной виджет‑подборки и решения архитектурных задач.

Приложение Иви на iOS насчитывает 15+ виджетов:

  • Виджет‑подборка «Продолжить просмотр». Старый виджет + новый интерактивный

  • Виджет‑подборка «Рекомендуем вам посмотреть». Старый виджет + новый интерактивный

  • Шорткат‑виджеты — быстрый доступ к нужной функции приложения: «Мой Иви», «Поток»

  • Быстрый доступ — динамически настраиваемая подборка с шорткат‑виджетами

Далее для удобства: ПП — «Продолжить просмотр», РВП — «Рекомендуем вам посмотреть».

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

Проект развивался, с каждым новым выпущенным виджетом архитектура дорабатывалась: код разносился по отдельным SPM-пакетам, бизнес‑логика покрывалась unit‑тестами, View Layer покрывался snapshot‑тестами.

Точкой апогея стали интерактивные виджет‑подборки, где архитектура и качество виджетов были подняты на новый уровень. Поговорим об этом далее.

Medium виджет «Продолжить просмотр»

Medium виджет «Продолжить просмотр»

Многомодульность — наше всё

Вместе с растущей кодовой базой и бизнес‑логикой хочется иметь возможность контролировать этот рост и быть уверенным, что всё работает согласно ТЗ. На помощь приходит разделение на логические слои и последующее unit и snapshot-тестирование.

Глобально виджет разделён на 3 слоя:

  • Слой с бизнес‑логикой (В SPM-пакете Widget Core)

  • View Layer (В SPM-пакете IVIUIKit)

  • Widget Layer (ivi‑widget target)

Слой с бизнес‑логикой и view разбиты по SPM-пакетам и ничего не знают друг о друге, Widget Layer находится в таргете виджета и является сборочным (assembly) слоем, объединяющим в себе view и бизнес-слои, а также реализующим методы жизненного цикла виджета.

Связь слоёв

Связь слоёв

Из плюсов многомодульной организации кода в проекте можно выделить:

  • Меньшая связанность кода;

  • Быстрое компилирование за счёт параллелизации билда.

С меньшей связанностью кода и разделением зон ответственности в сущностях появляется возможность покрывать код тестами, что в свою очередь, позволяет синхронизировать бизнес‑логику из ТЗ и логику, определённую в коде.

Минус подхода: возрастает время на написание фичи

Но минус нивелируется будущими трудозатратами на добавление новых семейств / фичей в виджеты.

Слой с бизнес-логикой

За бизнес‑логику в SPM-пакете Widget Core отвечает Processor конкретного виджета, в нём происходит получение/подготовка сырых данных из сети, из БД, а также последующая их передача в Widget Layer.

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

Что триггерит виджет на перезагрузку

Что триггерит виджет на перезагрузку

Зачем появился слой БД

В старом процессоре виджетов данные брались только из API, в новом процессоре добавляется слой с БД.

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

Кэширование данных из API в UserDefaults storage позволяет решить эту проблему и отлично ложится на принцип работы виджета. Когда будет происходить перезагрузка виджета по расписанию/после смены профиля/перезагрузки приложения, данные будут подтягиваться из сети и кэш будет обновляться новыми данными. После нажатия на интерактивную кнопку, данные будут браться из кэша.

Управление таким механизмом осуществляется через Date. После загрузки данных через API, сохраняется дата последней загрузки, и в следующий раз, если дата существует и не истекло время Timeline, данные будут браться из кэша. В случае отсутствия даты будет осуществлён поход в сеть.

У ПП дополнительным триггером для перезагрузки является добавление/удаление из блока «Продолжить просмотр»

Бесконечная карусель на итераторах

Для итерации по виджету хранится текущая позиция, сохранённая в UserDefaults, благодаря ней можно сдвигать на следующую/предыдущую позицию подборки. Более того, итератор является цикличным, что делает подборку бесконечной.

let iterator: WidgetIteratorProtocol = WidgetIterator(     keyIdx: "ivi.Widget.keyIdx",     keyIsPlused: "ivi.Widget.isPlusedKey" )  // Инкрементирование счётчика итератора iterator.incrementCount()

Также сохраняется последнее действие, выполненное с итератором — isPlused: сделали инкремент / декремент. Зачем нужен этот параметр, подробнее в UI‑главе.

Передача данных между приложением и виджетом

Важной составляющей виджета является общение и обмен данными с основным приложением. Существует два способа для обмена данными:

  • UserDefaults (через App Group)

  • Keychain (через Keychain Sharing)

Мы используем оба способа, но с разными целями. Через UserDefaults обмениваемся такими данными, как метаинформация для стран, жанров, последняя дата загрузки виджета. А в Keychain передаём sensitive данные пользователя, к примеру, сессии.

UserDefaults, Keychain передача данных через App Group

UserDefaults, Keychain передача данных через App Group

Код процессора

Процессор соединяет в себе все сущности (interactor, бд storage, iterator и т. д.) и в результате своей работы отдаёт подготовленные данные в Timeline виджета. Каждая сущность закрыта протоколом и передаётся на этапе инициализации. Такой подход позволяет:

  • Разделить зоны ответственности;

  • Обеспечить более гибкое тестирование бизнес‑логики виджета.

Процессор виджета рекомендаций
class RecommendationWidgetProcessor {          // MARK: - Properties          struct Constants {                  // 6 часов         static let sixHours: TimeInterval = 6.0 * 60.0 * 60.0     }          let interactor: InteractorProtocol          let storage: StorageProtocol          let iterator: WidgetIteratorProtocol          let dateFetcher: DateFetcherProtocol          // MARK: - Init          init(interactor: Interactor,          storage: StorageProtocol,          iterator: WidgetIteratorProtocol,          dateFetcher: DateFetcherProtocol) {         self.interactor = interactor         self.storage = storage         self.iterator = iterator         self.dateFetcher = dateFetcher     }          // MARK: - Methods          func requestRecommendation(completion: (WidgetCore.Timeline) -> Void) {                  // Если прошло 6 часов с момента последнего получения данных         // Обнуляем дату         if let date = dateFetcher.date,            date.timeIntervalSinceNow > Constants.sixHours {             dateFetcher.reset()         }                  // Если дата существует и постеры в кеше есть, то идём в кеш         if dateFetcher.date != nil,            let posters = self.storage.posters {                          // Получаем индексы, относительно текущего             // Которые нужно отобразить             let indecies = WidgetIterator.fetchIndexes(                 currIdx: iterator.currIdx,                 totalItemsCount: posters.count             )                          // Формируем timeline и передаём в completion             let timeline = self.formContentTimeline(posters, iterator.isPlused)             completion(timeline)         } else {                          // Иначе получаем данные по API             interactor.requestRecommendation { [weak self] result in                 guard let self else { return }                 result                     .onSuccess { recommendations in                                                  // В случае успешной загрузки данных:                         // Обновляем дату, кеш и сбрасываем итератор                         self.dateFetcher.date = Date()                         self.iterator.reset()                         let posters = recommendations.toPosters                         self.storage.posters = recommendations.toPosters                                                  // Формируем и отдаём timeline                         let timeline = self.formContentTimeline(posters, true)                         completion(timeline)                     }                     .onFailure { error in                         // В случае ошибки                         // Отображаем ошибочное состояние в виджете                         completion(self.processFailure(error))                     }             }         }     }          func formContentTimeline(_ posters: WidgetPoster,                               _ isPlused: Bool) -> WidgetCore.Timeline {                  // Создаём новое состояние виджета         let widgetState = WidgetState.content(             posters: posters,             isPlused: iterator.isPlused         )         let entry = PosterEntry(             date: Date(),             widgetState: widgetState         )                  // Создаём таймлайн         let timeline = WidgetCore.Timeline(             entries: [entry],             policy: .after(Date().addingTimeInterval(Constants.sixHours))         )         return timeline     }          }

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

Логика обработки данных в сравнении с виджетом «Продолжить просмотр» может различаться, поскольку рекомендации не могут приходить пустые, а ПП может. Но основной принцип остаётся неизменным: ходим в сеть и наполняем кэш данными, а в следующие разы ходим в кэш.

Unit-тест на проверку RecommendationProcessor с пустым кешом
class RecommendationProcessorNewTests: XCTestCase {          // MARK: - Properties          var interactor: RecommendationInteractorType!     var processor: RecommendationProcessorProtocol!     var dateFetcher: WidgetDateFetcherProtocol!     var iterator: WidgetIteratorProtocol!     var storage: PostersStorageProtocol!          // MARK: - Tests          // Случай, когда нет кеша.     func test_CaseWithWithoutCache_SuccessHandleContentTimeline() {                  // Arrange         let mockRecommendations = [             RecommendationContent(id: 1,                                   title: "Abc",                                   kind: .single,                                   country: 1,                                   genres: [1]),             RecommendationContent(id: 2,                                   title: "Bcd",                                   compilation: Compilation(id: 20, title: "Сериал 1"),                                   kind: .compilation,                                   country: 2,                                   genres: [5, 6]),             RecommendationContent(id: 3,                                   title: "Ничего",                                   compilation: Compilation(id: 20, title: "Сериал 2"),                                   kind: .compilation,                                   country: 3,                                   genres: [20]),             RecommendationContent(id: 4, kind: .compilation),             RecommendationContent(id: 5, kind: .single),             RecommendationContent(id: 6, kind: .single),             RecommendationContent(id: 7, kind: .compilation)         ]         self.interactor = RecommencationInteractorMock(mockRecommendations: mockRecommendations)         self.dateFetcher = DateFetcherMock(date: nil)         self.iterator = WidgetIteratorMock(currIdx: 0, isPlused: false)         self.storage = PostersStorageMock(posters: nil)         self.processor = RecommendationProcessor(interactor: self.interactor,                                                  storage: self.storage,                                                  dateFetcher: self.dateFetcher,                                                  iterator: self.iterator)                  // Act         self.processor.requestRecommendation { timeline in             let posterEntries = PostersConverter.convertEntries(timeline.entries)             switch posterEntries.first?.widgetState {             case let .content(viewModel):                                  // Assert                 for (expectedValue, actualValue) in zip(mockRecommendations, viewModel.posters) {                     TimelineAssert.assertRecommendationContentState(                         recommendation: expectedValue,                         viewModel: actualValue                     )                 }                  // В виджете стоит ограничение на макс. 6 единиц контента                 // Это тоже проверяется                 XCTAssertEqual(self.storage.posters?.count, 6)                 XCTAssertEqual(viewModel.posters.count, 6)                 XCTAssertEqual(self.iterator.currIdx, 0)                 XCTAssertTrue((self.dateFetcher.date?.timeIntervalSinceNow ?? 0.0) < 100.0)             default:                 XCTAssert(false, "ViewModel should contain content state.")             }                          XCTAssertEqual(timeline.entries.count, 1)             TimelineAssert.assertDate(date: timeline.policy.date, expectedDate: Date().addingTimeInterval(6.0 * 60.0 * 60.0))         }              } }

Аналогично этому кейсу написаны тесты и на другие жизненные ситуации: когда пришли пустые рекомендации, когда Timeline перешёл в состояние expired, когда запрос упал с ошибкой и т.д.

UI-слой

Apple активно продвигает SwiftUI в своих новых библиотеках, и виджеты не стали исключением, но работают они в упрощённом режиме: не работает async загрузка изображений, использование property wrappers бесполезно, так как каждая новая вью виджета после перерисовки одного snapshot на другой теряет своё локальное состояние. По сути, работа в виджете осуществляется по принципу Unidirectional Data Flow (UDF).

Вью виджета устроена схоже с паттерном билдер, так как настройки отображения виджета передаются снаружи в виде структуры‑конфигурации. Конфиг содержит в себе тайтлы, изображение, AppIntents. Вью берёт данные из этого конфига. Такое устройство вью + конфиг особенно удобно при написании snapshot‑тестов.

В дополнение, в приложении мы активно поддерживаем Accessibility и VoiceOver для всех элементов, это полезно для людей со слабым зрением и нам для автотестов. В виджете тоже добавлена поддержка accessibility.

SwiftUI view с постерами на примере Small виджета
public struct SmallWidgetPostersView: View {          var configuration: Configuration          var plusIntent: any AppIntent          var minusIntent: any AppIntent          public init(configuration: Configuration,                 plusIntent: any AppIntent,                 minusIntent: any AppIntent) {         self.configuration = configuration         self.plusIntent = plusIntent         self.minusIntent = minusIntent     }          public var body: some View {         GeometryReader { geometry in             ZStack(alignment: .topLeading) {                 Color.background                  VStack(alignment: .leading) {                     contentPosters(posters: self.configuration.posters,                                    size: geometry.size)                      Spacer()                     self.buttons()                         .padding()                 }                 .padding()                  if let logoImage = self.configuration.logoImage {                     iviLogo(logoImage)                         .frame(width: 16.0, height: 16.0)                         .padding()                 }             }         }     }          @ViewBuilder     func iviLogo(_ image: Image) -> some View {         image             .resizable()             .unredacted()             .accessibilityHidden(true)     }          @ViewBuilder     private func contentPosters(posters: [WidgetPosters.PosterModel],                                 size: CGSize) -> some View {         VStack(alignment: .leading) {             HStack {                 Poster(id: posters.first?.hashValue ?? 0,                        image: posters.first?.image,                        progress: posters.first?.progress,                        size: size)                 .accessibilityLabel(posters.first?.title ?? "")                 .accessibilityValue(posters.first?.subtitle ?? "")                                  Poster(id: posters[safe: 1]?.hashValue ?? 0,                        image: posters[safe: 1]?.image,                        progress: posters[safe: 1]?.progress,                        size: size)                 .accessibilityLabel(posters[safe: 1]?.title ?? "")                 .accessibilityValue(posters[safe: 1]?.subtitle ?? "")             }             .padding()                          VStack(alignment: .center) {                 Text(posters.first?.title ?? "")                     .iviFont(size: 13.0, fontType: .medium)                     .foregroundColor(Color.white)                     .lineLimit(1)                     .accessibilityHidden(true)                 Text(posters.first?.subtitle ?? "")                     .iviFont(size: 10.0, fontType: .regular)                     .foregroundColor(Color.gray)                     .lineLimit(1)                     .accessibilityHidden(true)             }             .id(posters.first?.hashValue ?? 0)             .transition(.push(from: self.configuration.isPlused ? .trailing : .leading))             .accessibilityLabel(posters.first?.title ?? "")             .accessibilityValue(posters.first?.subtitle ?? "")         }     }          @ViewBuilder     private func buttons() -> some View {         HStack {             SwiftUI.Button(intent: self.minusIntent) {                 ButtonArrow(title: "Назад")             }             .buttonStyle(.plain)             .accessibilityLabel("Пролистнуть назад")             SwiftUI.Button(intent: self.plusIntent) {                 ButtonArrow(title: "Вперёд")             }             .buttonStyle(.plain)             .accessibilityLabel("Пролистнуть вперёд")         }         .unredacted()     } }

AppIntents

Button с AppIntents триггерит перезагрузку виджета. После нажатия на кнопку, осуществляет инкремент / декремент итератора, после чего перезагружается вью с проскролленной подборкой.

Ключи keyIdx, keyIsPlused одинаковы с ключами итератора в процессоре. Они являются точкой синхронизации состояний подборки.

AppIntent для пролистывания подборки вперёд
import AppIntents import SwiftUI  struct RecommendationPlusIteratorIntent: AppIntent {          static var title: LocalizedStringResource = "Пролистнуть вперёд"     static var description = IntentDescription("Пролистывает вперёд подборку рекомендаций")     static var isDiscoverable: Bool = false          func perform() async throws -> some IntentResult {         let iterator: WidgetIteratorProtocol = WidgetIterator(             keyIdx: "ivi.Widget.keyIdx",             keyIsPlused: "ivi.Widget.isPlusedKey"         )         iterator.incrementCount()         return .result()     } }

Аналогично написан MinusIteratorIntent с декрементом счётчика.

Анимации

Привычная View в SwiftUI анимируется при помощи модификатора withAnimation { … }, в момент изменения состояния / активации триггера View происходит вызов анимации на дифф вью во View Tree.

В виджете отсутствует какое‑либо состояние, поскольку в момент смены одного Entry на другое происходит полная перерисовка вью и соответсвенно локальное состояние затирается. Триггером для неявной (implicit) анимации как раз служит перерисовка View с Entry<N> на View с Entry<M>.

В качестве анимации в виджете мы используем push transition, направление которого зависит от направления нажатия кнопки.

Параметр isPlused передаётся от Iterator , который лежит в процессоре виджета. По этому параметру различается с какой стороны необходимо запушить постеры.

struct PostersView: View {      var configuration: Configuration        var isPlused: Bool {         configuration.isPlused      }      var body: some View {         VStack { ... }         .transition(.push(from: isPlused ? .trailing : .leading))     } }

После добавления транзишна получаем анимацию.

Large виджет ПП

Large виджет ПП

Snapshot-тесты

Размер виджета зависит от размера девайса: для примера, где‑то small виджет может быть 141×141 в логических пикселях, а где‑то 170×170, из‑за этого вёрстка может разниться. Контролировать эти вариации помогают snapshot-тесты.

Для snapshot-тестирования SwiftUI View используем библиотеку swift‑snapshot‑testing.

Типовой snapshot-тест вью виджета
class SmallWidgetPostersViewTests: XCTestCase {      var poster1: WidgetPosters.PosterModel = .init(         image: .burg,         title: "Македонская резня бензопилой",         subtitle: "Ещё 250 мин.",         progress: 0.6,         deeplink: "./"     )          var poster2: WidgetPosters.PosterModel = .init(         image: .abrakadabred,         title: "Романтическая комедия Шрек"     )      func test_Config() {                // Arrange         let config = SmallWidgetPostersView.Configuration(             sizeCriteria: .large,             logoImage: .generated(.logo_img),             posters: [self.poster1,                       self.poster2,                       self.poster1],             isPlused: true         )         let view = SmallWidgetPostersView(configuration: config,                                           plusIntent: StubIntent(),                                           minusIntent: StubIntent())         let viewCtrl = view.toViewController()          // Assert         assertView(of: viewCtrl.view,                     layouts: [.fixed(width: 157.0, height: 157.0),                              .fixed(width: 169.0, height: 169.0),                              .fixed(width: 188.0, height: 188.0)])     }  }

Аналогично тестам на изображения, пишем snapshot-тесты на accessibility UI-элементов.

Accessibility тест на Poster
class PosterTests: XCTestCase {      func test_Accessability() {                // Arrange         let config = Poster.ElementsConfiguration()             .with(auxTextBadgeConfig: .visible(text: "123"))         let poster = Poster(elementsConfiguration: config)         let width: CGFloat = 200.0         let height = Poster.height(width: width)          // Assert         assertViewAccessability(             of: poster,             layouts: [.fixed(width: width, height: height)]         )     }    }

И получаем на выходе txt файл:

ID: Poster   Value: "available"     ID: VoiceOverElement   Frame: {(0,0),(200x307)}     ID: image         ID: VoiceOverElement   Frame: {(0,0),(200x307)}     ID: TextBadge   Label: "auxTextBadge"         ID: title   Label: "123"         ID: VoiceOverElement   Label: "123"   Frame: {(0,0),(40x20)}   Traits: [button]

Библиотека прижилась у нас в проекте, и сейчас snapshot‑тестами покрываются не только вью UIKit и SwiftUI, но и UIViewController’ы, Lottie-анимации, accessibility.

Виджет слой

Этот слой можно считать финальным слоем, где собирается итоговый вариант виджета. Здесь задействованы 2 предыдущих слоя, а также добавляется библиотека WidgetKit (во view и business-слое интерфейсы и модели используются самописные).

Это сделано, чтобы не размазывать WidgetKit по другим слоям проекта: всё что относится к виджет библиотеке, используется в виджет-таргете. К тому же мы защищаем себя от будущих обновлений библиотеки. И при желании можем переиспользовать слой view и логический слой в приложении в случае необходимости.

Но с таким подходом появляется сущность‑адаптер под названием Receiver, которая переводит, к примеру, WidgetCore.Timeline к WidgetKit.Timeline, аналогично адаптируются и другие модели, интерфейсы.

Receiver и TimelineProvider рекомендаций
import WidgetKit import WidgetCore  class RecommendationReceiver: RecommendationReceiverProtocol {          let context: TimelineProviderContext     let processor: RecommendationProcessorProtocol          init(context: TimelineProviderContext) {         self.context = context         self.processor = RecommendationProcessorBuilder.build(context: context)     }          func receiveRecommendation(completion: (Timeline<PostersTimelineEntry>) -> Void) {         processor.requestRecommendation { widgetTimeline in             // Специально держим сильной ссылкой,             // Иначе формирование таймлайна завершится раньше времени             completion(self.timeline(widgetTimeline))         }     }        func timeline(_ timeline: WidgetCore.Timeline) -> Timeline<PostersTimelineEntry> {         let posterEntries = timeline.entries             .map { entry in                 PostersTimelineEntry(date: entry.date,                                      contentState: PostersTimelineEntry.asEntry)             }         return Timeline(entries: posterEntries, policy: timeline.policy.asPolicy)     }      }

Потом Receiver используем в TimelineProvider конкретного виджета.

import SwiftUI import WidgetKit  struct RecommendationTimelineProvider: TimelineProvider {          func placeholder(in context: Context) -> PostersTimelineEntry {        /* ... */     }      func getTimeline(in context: Context,                       completion: @escaping (Timeline<PostersTimelineEntry>) -> Void) {         let receiver = RecommendationReceiver(context: context)         receiver.receiveRecommendation { timeline in             completion(timeline)         }     }          func getSnapshot(in context: Context,                       completion: @escaping (PostersTimelineEntry) -> Void) {         let receiver = RecommendationReceiver(context: context)         receiver.receiveRecommendation { timeline in             guard let entry = timeline.entries.first else {                 completion(self.previewEntry(in: context))                 return             }             completion(entry)         }     } }

WidgetContext

Виджет имеет TimelineProviderContext c несколькими параметрами:

  • family  — к какому семейству относится виджет: small, medium и т. д.

  • isPreview — признак, обозначающий, показывается ли виджет в галерее или на рабочем столе

  • displaySize — размер виджета в логических поинтах

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

Параметр isPreview используем, чтобы облегчить запрос за контентом, когда виджет показывается первый раз в галерее. displaySize помогает нам облегчить запросы за картинками с нашего сервиса ресайза изображения.

Семейства виджетов в view можно различать по EnvironmentKey widgetFamily .

Использование widgetFamily
struct SomeEntryView: View {          @Environment(\.widgetFamily)     var widgetFamily      var entry: SomeTimelineEntry          var body: some View {         switch self.widgetFamily {         case .systemSmall:             VStack { ... }         case .systemMedium:             HStack { ... }         default:             ZStack { ... }         }     } }

В зависимости от разных семейств у нас варьируется показ вью.

Мы используем данный параметр только в EntryView виджета, поскольку все контентные вью лежат в другой библиотеке и никак не использую библиотеку WidgetKit. Такая независимость от реализации библиотеки развязывает нам руки на случай будущих обновлений языка.

WidgetCenter

WidgetCenter — синглтон библиотеки WidgetKit, позволяет:

  • Перезагружать таймлайны всех виджетов и конкретных виджетов: reloadAllTimelines(), reloadTimelines(ofKind kind: String)

  • Получить текущие конфигурации виджетов, добавленные пользователем: getCurrentConfigurations(_ completion: @escaping (Result<[WidgetInfo], Error>) -> Void)

  • Инвалидировать виджеты с динамической конфигурацией: invalidateConfigurationRecommendations()

Мы используем метод перезагрузки таймлайнов, когда пользователь меняет профиль приложения, когда добавляет/удаляет контент в ПП, когда происходит первый запуск приложения.

А метод getCurrentConfigurations помогает нам с аналитикой добавлений и установок виджета.

import WidgetKit  // Перезагрузка таймлайнов. WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadTimelines(ofKind: "RecommendationWidget")   WidgetCenter.shared.getCurrentConfigurations { widgetInfo in     // Получаем добавленные виджеты }

Боль виджетов

Перезагрузка таймлайна виджетов одного семейства

Нюанс, про который Apple не рассказывает: после нажатия на интерактивный Button / Toggle в виджете перезагружаются все таймланы семейства конкретного виджета. Например, после нажатия на кнопку в виджете «Продолжить просмотр», перезагружаются все таймлайны виджетов ПП.

Этот нюанс стал для нас неожиданностью и послужил аргументом в пользу добавления кеширования в UserDefaults.

Дебаг

В Xcode 14 виджеты перестали собираться на симуляторах, для сборки приходилось использовать реальный девайс. При этом отладка как перестала работать с 14 Xcode, так и в 15 Xcode до сих пор не работает. В тредах на форумах отсутствует информация по решению данной проблемы.

Возможно, это связно с переходом Xcode на Apple Silicon.

Голосуйте

Самое сложное в работе над виджетом оказалась не разработка, а информирование пользователя об этой фиче. Ещё сложнее это сделать, понимая что Apple не предоставила способ навигации к виджет-галерее, хотя бы через URL Scheme.

Хотим исправить это недоразумение и поэтому создали тредик с описанием проблемы — developer.apple.com/forums/thread/746410

Уважаемый хабр‑читатель, помоги своим хабр‑голосом, чтобы Apple обратила внимание на проблему ?


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


Комментарии

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

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