Как стать автором
Обновить
74.18
Онлайн-кинотеатр Иви
Мы открываем для людей многообразие мира кино

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

Уровень сложности Средний
Время на прочтение 16 мин
Количество просмотров 471

И снова здравствуйте! В 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 обратила внимание на проблему 🙏

Присоединяйтесь к нам в команду!

Сейчас мы активно ищем iOS‑разработчика в команду Видео.
Поток крутых задач, связанных с плеером и видео, обеспечен!

Теги:
Хабы:
+1
Комментарии 0
Комментарии Комментировать

Публикации

Информация

Сайт
www.ivi.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек