![](https://webcf.waybackmachine.org/web/20240322004020/https://habrastorage.org/getpro/habr/upload_files/f4f/747/ef5/f4f747ef5601ff26e623e12d95d2cd83.jpg)
И снова здравствуйте! В 2022 году у нас появились первые HomeScreen-виджеты, это был первый опыт работы с библиотекой WidgetKit. Затем Apple представила LockScreen-виджеты, и мы их тоже добавили. А выход iOS 17 и поддержка библиотеки AppIntents ознаменовали новый этап в эволюции виджетов.
В этой статье расскажем о том, как мы зарелизили интерактивные виджеты, и из чего они состоят (разделение слоёв на SPM-пакеты, обеспечение качества (unit, snapshot-тесты), accessibility), а также о нюансах, которыми Apple не делилась на WWDC23, но с которыми столкнулись мы.
Введение
В предыдущей статье разобрали в пет‑проекте, как работают виджеты + интерактивность. Данная статья больше про кейс разработки интерактивной виджет‑подборки и решения архитектурных задач.
Приложение Иви на iOS насчитывает 15+ виджетов:
Виджет‑подборка «Продолжить просмотр». Старый виджет + новый интерактивный
Виджет‑подборка «Рекомендуем вам посмотреть». Старый виджет + новый интерактивный
Шорткат‑виджеты — быстрый доступ к нужной функции приложения: «Мой Иви», «Поток»
Быстрый доступ — динамически настраиваемая подборка с шорткат‑виджетами
Далее для удобства: ПП — «Продолжить просмотр», РВП — «Рекомендуем вам посмотреть».
Самые первые версии виджетов не обладали отличительными особенностями: весь код лежал в таргете с виджетами, отсутствовало покрытие тестами, не было поддержки VoiceOver. Это было трудно поддерживать, а тем более добавлять новые семейства виджетов.
Проект развивался, с каждым новым выпущенным виджетом архитектура дорабатывалась: код разносился по отдельным SPM-пакетам, бизнес‑логика покрывалась unit‑тестами, View Layer покрывался snapshot‑тестами.
Точкой апогея стали интерактивные виджет‑подборки, где архитектура и качество виджетов были подняты на новый уровень. Поговорим об этом далее.
![Medium виджет «Продолжить просмотр» Medium виджет «Продолжить просмотр»](https://webcf.waybackmachine.org/web/20240322004020/https://habrastorage.org/getpro/habr/upload_files/bdc/bbc/9de/bdcbbc9de3229861098652d34a341d25.gif)
Многомодульность — наше всё
Вместе с растущей кодовой базой и бизнес‑логикой хочется иметь возможность контролировать этот рост и быть уверенным, что всё работает согласно ТЗ. На помощь приходит разделение на логические слои и последующее unit и snapshot-тестирование.
Глобально виджет разделён на 3 слоя:
Слой с бизнес‑логикой (В SPM-пакете Widget Core)
View Layer (В SPM-пакете IVIUIKit)
Widget Layer (ivi‑widget target)
Слой с бизнес‑логикой и view разбиты по SPM-пакетам и ничего не знают друг о друге, Widget Layer находится в таргете виджета и является сборочным (assembly) слоем, объединяющим в себе view и бизнес-слои, а также реализующим методы жизненного цикла виджета.
![Связь слоёв Связь слоёв](https://webcf.waybackmachine.org/web/20240322004020/https://habrastorage.org/getpro/habr/upload_files/616/f02/2a8/616f022a863681a54a8c5ff96d0c5a1e.png)
Из плюсов многомодульной организации кода в проекте можно выделить:
Меньшая связанность кода;
Быстрое компилирование за счёт параллелизации билда.
С меньшей связанностью кода и разделением зон ответственности в сущностях появляется возможность покрывать код тестами, что в свою очередь, позволяет синхронизировать бизнес‑логику из ТЗ и логику, определённую в коде.
Минус подхода: возрастает время на написание фичи
Но минус нивелируется будущими трудозатратами на добавление новых семейств / фичей в виджеты.
Слой с бизнес-логикой
За бизнес‑логику в SPM-пакете Widget Core отвечает Processor конкретного виджета, в нём происходит получение/подготовка сырых данных из сети, из БД, а также последующая их передача в Widget Layer.
Этот слой можно считать входным, поскольку после триггера виджета на перезагрузку в первую очередь процессор получает событие о необходимости подготовки новых данных.
![Что триггерит виджет на перезагрузку Что триггерит виджет на перезагрузку](https://webcf.waybackmachine.org/web/20240322004020/https://habrastorage.org/getpro/habr/upload_files/f9a/570/d3d/f9a570d3df7fbb574afc813dbebc7f06.png)
Зачем появился слой БД
В старом процессоре виджетов данные брались только из 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](https://webcf.waybackmachine.org/web/20240322004020/https://habrastorage.org/getpro/habr/upload_files/78b/5ef/c8b/78b5efc8b27155eee7865b966c79c6cb.png)
Код процессора
Процессор соединяет в себе все сущности (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 виджет ПП](https://webcf.waybackmachine.org/web/20240322004020/https://habrastorage.org/getpro/habr/upload_files/2d1/87d/c5d/2d187dc5d5ebe9756b4a1427f5255164.gif)
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 обратила внимание на проблему 🙏
Присоединяйтесь к нам в команду!
Сейчас мы активно ищем iOS‑разработчика в команду Видео.
Поток крутых задач, связанных с плеером и видео, обеспечен!