
И снова здравствуйте! В 2022 году у нас появились первые HomeScreen-виджеты, это был первый опыт работы с библиотекой WidgetKit. Затем Apple представила LockScreen-виджеты, и мы их тоже добавили. А выход iOS 17 и поддержка библиотеки AppIntents ознаменовали новый этап в эволюции виджетов.
В этой статье расскажем о том, как мы зарелизили интерактивные виджеты, и из чего они состоят (разделение слоёв на SPM-пакеты, обеспечение качества (unit, snapshot-тесты), accessibility), а также о нюансах, которыми Apple не делилась на WWDC23, но с которыми столкнулись мы.
Введение
В предыдущей статье разобрали в пет‑проекте, как работают виджеты + интерактивность. Данная статья больше про кейс разработки интерактивной виджет‑подборки и решения архитектурных задач.
Приложение Иви на iOS насчитывает 15+ виджетов:
Виджет‑подборка «Продолжить просмотр». Старый виджет + новый интерактивный
Виджет‑подборка «Рекомендуем вам посмотреть». Старый виджет + новый интерактивный
Шорткат‑виджеты — быстрый доступ к нужной функции приложения: «Мой Иви», «Поток»
Быстрый доступ — динамически настраиваемая подборка с шорткат‑виджетами
Далее для удобства: ПП — «Продолжить просмотр», РВП — «Рекомендуем вам посмотреть».
Самые первые версии виджетов не обладали отличительными особенностями: весь код лежал в таргете с виджетами, отсутствовало покрытие тестами, не было поддержки VoiceOver. Это было трудно поддерживать, а тем более добавлять новые семейства виджетов.
Проект развивался, с каждым новым выпущенным виджетом архитектура дорабатывалась: код разносился по отдельным SPM-пакетам, бизнес‑логика покрывалась unit‑тестами, View Layer покрывался snapshot‑тестами.
Точкой апогея стали интерактивные виджет‑подборки, где архитектура и качество виджетов были подняты на новый уровень. Поговорим об этом далее.

Многомодульность — наше всё
Вместе с растущей кодовой базой и бизнес‑логикой хочется иметь возможность контролировать этот рост и быть уверенным, что всё работает согласно ТЗ. На помощь приходит разделение на логические слои и последующее 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 данные пользователя, к примеру, сессии.

Код процессора
Процессор соединяет в себе все сущности (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)) } }
После добавления транзишна получаем анимацию.

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 обратила внимание на проблему ?
