Делаем свой Widget в iOS 14

В iOS 14 и macOS 11 Apple представили Widgets. Еще один способ взаимодействия пользователя с приложением. В данной статье рассмотрим основные принципы работы WidgetKit и интегрируем свой Widget в готовый проект.

Widget показывает главную информацию вашего приложения, без открытия самого приложения. Так же виджеты помогают сделать ваш домашний экран более персонализированным.

Пользователи могут найти виджеты в галерее виджетов, там же они могут выбрать размер для виджета. Всего есть 3 размера для виджетов: small, medium и large.

Для того чтобы добавить виджет на экран нужно войти в режим редактирования домашнего экрана и нажать на плюсик в верхнем левом углу, откроется галерея, где будут представлены виджеты приложений, но только от тех приложений, которые их поддерживают. При нажатии на конкретный виджет можно будет выбрать его тип и его размер, например приложение Stocks имеет 2 типа виджетов Watchlist и Symbol, причем для Watchlist представлены все 3 размера, а для Symbol только один.

Основные принципы

Главное предназначение виджета это показать небольшой объем информации пользователю, актуальной в данный момент времени. Определение этого одного предназначение является первым шагом для построения хорошего пользовательского опыта. Поэтому основные принципы включают в себя

  • Придерживайтесь одной идеи. Главная идея вашего приложения должна быть отображена в вашем виджете. Например приложение Weather может показывать погоду для одной локации, приложение Stocks последние котировки акций.
  • Для каждого размера показывайте только ту информацию, которая прямо относится к вашей идеи. Для Stocks это может быть отображение только 3-х акций для маленького размера с указанием только названия и цены, а для большого размера это будет отображение более 5 акций с более детальной информации по ним.
  • Не создавайте виджет, который ничего не делает, а только открывает приложение. Пользователь ожидает возможность просмотра важной информации без открытия приложения.
  • Сделайте поддержку нескольких размеров, это улучшит пользовательский опыт. Реализация одного маленького размера приведет к тому, что на бОльших размерах много свободного пространства будет не задействовано, что не придает привлекательности приложению. ?????
  • Информация показанная на виджете должна меняться в течении дня. Если информация будет статичной или будет меняться очень редко, то будет ли польза от такого виджета? Поэтому важно чтобы информация менялась в течении дня, для приложения Calendar это будут предстоящие митинги, для Weather текущая погода.

Конфигурация виджета

  • Позвольте пользователю конфигурировать виджет, когда это имеет смысл. Например для Weather можно выбрать город, откуда будет показываться погода, для Stocks доступен выбор акции, котировки который вы хотите мониторить.
  • Когда нажимаете на виджет, убедитесь что открывается нужный экран приложения. Если нажали на пару USD/RUB в Stocks от должен открыться экран с детальной информацией по этой паре, а не какая-то другая.
  • Избегайте большое количество нажимаемых элементов на виджете. Можно иметь несколько элементов на которые можно нажать, но тут надо быть аккуратным, чтобы у пользователя не возникло проблем с попаданием пальцем на нужный элемент виджета.

  • Дайте пользователю понять, что будучи залогиненным он получит больше информации. Для приложения по бронированию билетов или отелей, аутентификация пользователя в системе предоставляет ему больше информации, например номер брони, QR-код и многое другое.

Обновление виджета

  • Поддерживайте обновленное состояние вашего виджета. Для того чтобы информация на виджете оставалась актуальной, его нужно периодически обновлять. Виджеты не поддерживают обновление в режиме реального времени, так же система может ограничить вас в частоте обновления виджета. Поэтому нужно подойти к этому вопросы с осторожностью, например приложение Почта России может обновлять статус посылки раз в час или в два, этого будет достаточно, а вот приложению для трейдинга обновление раз в час может быть недостаточно и нужно будет обновляться чаще.
  • Позвольте системе обновлять даты и время в вашем виджете. Частота обновления виджета ограничена и поэтому переложите обязанность обновления даты и времени системе.
  • Показывайте контент быстро. Когда вы определяете частоту обновления контента, помните, что вам не обязательно прятать устаревшие данные за каким-то плейсхолдером, лучшее показать что-то старое, чем какой-то пустой скелетон.

Дизайн виджета

Пройдемся кратко по дизайну.

  • Если необходимо, то отобразите название или логотип вашего приложения через цвет, текст или картинку . Сделайте так, что контент виджета и брэндинг не мешались друг другу, зачастую это и не нужно, потому что название вашего приложения отображается под виджетом.
  • Поддерживайте баланс плотности информации. Если на виджете будет мало информации, то тогда возникнет вопрос в необходимости иметь этот виджет, если будет слишком много информации, то пользователю будет сложно в ней ориентироваться и ее воспринимать.
  • Используйте цвета разумно. Насыщенные, яркие цвета привлекают внимание пользователей, но тут нужно не перестараться, а просто придерживаться цветовой палитры приложения.
  • Поддержка темной темы. Виджет должен выглядеть соответствующе на темной и светлой темах.

  • Рассмотрите использование шрифта SF Pro. Согласитесь, что приятно, когда все виджеты на домашнем экране имеют одинаковый шрифт, а не когда у каждого свой.
  • Используйте текстовые элементы в виджете. Убедитесь, что текст масштабируется нормально, не растрируйте его, иначе это приведет к проблем с VoiceOver.
  • Проектируйте реалистичную превьюшку для отображения в галерее виджетов. Покажите какими возможностями обладает этот виджет, вы так же можете отобразить реальные данные здесь, но если загрузка этих данных занимает много времени, то может просто заменить моками.
  • Используйте скелетоны, чтобы дать понять пользователю структуру виджета. Виджет показывает скелетон, когда данные все еще загружаются.

  • Напишите короткое, но емкое описание для виджета. В Галереи виджетов есть описание для каждого виджета, что именно он делает. Рекомендуется начинать это писание с глагола, например “Следить за предстоящими митингами”, “Посмотреть погоду в указанной местности” и т.д.

Поддержка различных размеров экранов

Виджеты масштабируются к размеру экрана на различных устройствах, начиная с iPhone 6s заканчивая iPhone 11 Pro Max.

  • Убедитесь что картинки, соответствуют размерам виджета. Ниже представлена таблица с размерами виджетов.

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

  • Рекомендуется использовать стандартные отступы между элементами. Стандартный отступ это 16 поинтов, использование стандартного отступа поможет разместить элементы в читабельном виде и сделает контент более разборчивым для пользователя.

Smart Stack

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

Когда у пользователя есть стек, то он может включить Smart Rotate (включено по умолчанию), который будет перемешивать и показывать только тот виджет, который он посчитает релевантным в данный момент времени.

Определение

WidgetKit позволяет пользователю получить доступ к возможностям вашего приложения с помощью вынесения виджета на iOS Home screen или в macOS Notification Center.
С тремя различными размерами для виджета (small, medium, large) у пользователя есть выбор, сколько именно информации он хочет видеть.

Чтобы создать свой виджет, вам нужно добавить Widget Extension к своему приложению. Сам контент виджета создается с помощью SwiftUI, чтобы настроить виджет используется Timeline Provider, Timeline Provider используется для того, чтобы сказать виджету когда ему обновлять свой контент.

Чтобы позволит пользователю конфигурировать виджет вы добавляете свой SiriKit intent definition, остальное сделает WidgetKit за вас и предоставит пользователю кастомизированный интерфейс.

Итак представим у нас есть iOS приложение и теперь мы хотим добить поддержку виджетов, рассмотрим шаг за шагом, как это можно сделать.

Определение главное идеи для виджета

Перво-наперво нужно определится с главной идеи для виджета, какую проблему он будет решать, будет ли он полезен пользователю, также желательно соотнести вашу идею с эпловскими гайдлайнами.

Существующий проект

Представим, что у нас уже есть готовое приложение и теперь мы хотим к нему прикрутить виджеты.
В данной статье будет рассматривается простое приложение Рейтинг Игроков, которое состоит из 2х экранов — таблицы с рейтингом профилей и детальной информации по каждому профилю. Исходный код можно найти в конце статьи.

Добавление виджета в проект

Для того чтобы добавить виджет в приложение нужно выбрать File -> New -> Target, на вкладке iOS выбираем Widget Extension, даем имя нашему виджету, например LadderWidget. В проект добавится новый таргет, также файл LadderWidget.swift, где будет сразу добавлены все необходимые классы. Уже начиная с этого моменты, можно запустить виджет, выбрав соответствующий таргет, и увидеть результат на экране симулятора.

Основные классы

Рассмотрим какие классы создал для нас Xcode.

  • LadderWidget
  • LadderWidgetEntryView
  • Provider
  • SimpleEntry

Как мы увидим, на самом деле это не классы, а структуры, кратко пройдемся по каждой из этих структур.

  • SimpleEntry — слепок, который содержит данные для отображения их на виджете;
  • Provider — предоставляет таймлайн из слепков для виджета;
  • LadderWidgetEntryView — View для виджета, тут определяется как будет выглядеть виджет;
  • LadderWidget — наследник Widget, это непосредственно и есть наш виджет.

Как данные обновляются на виджете? iOS использует механизм слепков, то есть виджету предоставляются таймлайн слепков из провайдера и он их показывает на определенном интервале времени в будущем.

Проведем небольшой рефакторинг и переименуем наши структуры.

LadderEntry

Данные для виджета определяются именно в этой структуре путем добавления соответствующих свойств.

struct LadderEntry: TimelineEntry {
    let date: Date
    // your properties -> let name: String
}

То есть если ваш виджет будет показывать, например, погоду, то у вас скорее всего будут свойства let cityName: String, let cityTemperature: Double, ну и let date: Date, последнее свойство определяет дату когда WidgetKit обновит виджет и является обязательным, чтобы следовать протоколу TimelineEntry.

LadderProvider

Кто-то должен предоставлять LadderEntry для виджета и за это ответственен LadderProvider. Эта структура следует протоколу TimelineProvider, взлянем на определение.

protocol TimelineProvider {

    associatedtype Entry : TimelineEntry
    typealias Context = TimelineProviderContext

    func placeholder(in context: Context) -> Entry
    func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void)
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void)
}

  • placeholder(in context: ) — возвращает слепок, который будет использоваться как отображения шаблонной версии виджета.
  • getSnapshot(in context: , completion:) — возвращает слепок, который представляет текущее время и состояние виджета.
  • getTimeline(in context: , completion:) — возвращает массив слепков в виде таймлайна для текущего времени и будущих случаев обновления виджета.

LadderWidgetEntryView

Это обычное View, которое определяет, как будет выглядеть наш виджет.

struct LadderWidgetEntryView : View {
    var entry: LadderProvider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

LadderWidget

LadderWidget — наследник Widget, если посмотреть на определение протокола Widget, то можно заметить, что оно сильно напоминает обычный View, так же через body определяется контент виджета, но тут также указывается, как виджет будет конфигурироваться статически или динамически, а также здесь указывается провайдер для виджета.

@main
struct LadderWidget: Widget {
    let kind: String = "LadderWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: LadderProvider()) { entry in
            LadderWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
        .supportedFamilies([.systemMedium, .systemSmall])
    }
}

  • .configurationDisplayName(_) указывает название виджета в галерее виджетов;
  • .description(_) указывает описание в галерее виджетов;
  • .supportedFamilies(_) указывает какие размеры будет поддерживать виджет, если не указать, то будут поддерживаться все размеры.

@main перед определением виджета обозначает “точку входа” для данного таргета, а именно то, что при запуске будет показан именно Ladder Widget.

Размеры виджета — .widgetFamily

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

Начнем с LadderEntry, для этого добавим соответствующую поле.

struct LadderEntry: TimelineEntry {
    let date: Date
    let profile: Profile
}

Так же надо не забыть включать файлы, классы которых используется в таргете виджета, в таргет виджета. Для Profile.swift это будет выглядеть так.

Теперь обновим как будет отображается виджет.

struct LadderWidgetEntryView : View {
    var entry: LadderProvider.Entry

    @Environment(.widgetFamily) var family

    @ViewBuilder
    var body: some View {
        switch family {
        case .systemSmall:
            ProfileInfoView(profile: entry.profile)
        default:
            ProfileView(profile: entry.profile)
        }
    }
}

С помощью @Environment(.widgetFamily) можно получить доступ к размеру виджета и в зависимости от значения показывать просто текст для маленького размера или картинку и текст для среднего размера.

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

struct LadderWidgetEntryView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            LadderWidgetEntryView(entry: LadderEntry(date: Date(), profile: sampleProfile))
                .previewContext(WidgetPreviewContext(family: .systemSmall))
            LadderWidgetEntryView(entry: LadderEntry(date: Date(), profile: sampleProfile))
                .previewContext(WidgetPreviewContext(family: .systemMedium))
        }
    }
}

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

Конфигурация виджета — IntentConfiguration

Чтобы добавить динамическую конфигурацию, нужно в LadderWidget заменить StaticConfiguration на IntentConfiguration, для этого нажимаем File -> New -> File -> SiriKit Intent Definition File. Называем файл LadderIntents.intentdefinition.

Конфигурация определяется Intent, Intent определяет некоторые свойства для виджета, их много, рассмотрим только некоторые. Но перед этим создадим модель для Intent, это будет списком профилей из которых будет выбирать пользователь.
Нажимаем на плюсик и добавляем Enum, заполняем поля этого перечисления.

Гуд, теперь создадим сам Intent, нажимаем на плюсик и кликаем New Intent, называем его ProfileSelection и конфигурируем, как показано на картинке.

Xcode под капотом сгенерирует для нас класс ProfileSelectionIntent. Теперь заменим конфигурацию в нашем LadderWidget.

        IntentConfiguration(kind: kind, intent: ProfileSelectionIntent.self, provider: LadderProvider()) { entry in
            LadderWidgetEntryView(entry: entry)
        }

Компилятор будет ругаться, что LadderProvider не следует протоколу IntentTimelineProvider, исправляем это, для этого просто следуем протоколу IntentTimelineProvider вместо TimelineProvider и обновляем сигнатуры функций соответственно.
Раз мы добавили ProfileSelectionIntent, значит пора его где-то использовать, то есть в зависимости от выбранного профиля наш провайдер должен отдавать соответствующий LadderEntry. Обновим наш getTimeline(for configuration:, in context:, completion:)

    func getTimeline(for configuration: ProfileSelectionIntent, in context: Context, completion: @escaping (Timeline<LadderEntry>) -> ()) {
        let entries: [LadderEntry] = [LadderEntry(date: Date(), profile: profile(for: configuration))]

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

    func profile(for configuration: ProfileSelectionIntent) -> Profile {
        switch configuration.profile {
        case .lina:
        return Profile(id: 3, name: "Lina", ...)
        case .morphling:
        return Profile(id: 2, name: "Morphling", ...)
        // ...
        }
    }

Теперь у виджета появится кнопка Edit Widget и выбор отображаемого профиля для виджета.

Показ Placeholder’a

Когда WidgetKit отображает виджет первый раз, он показывает вью виджета как placeholder. Вью Placeholder view показывает общую структуру вашего виджета, как бы давая пользователю понять, что будет отображено на виджете. WidgetKit вызывает placeholder(in:) у провайдера чтобы получить слепок для placeholder’a.

    func placeholder(in context: Context) -> LadderEntry {
        LadderEntry(date: Date(), profile: sampleProfile)
    }

WidgetKit использует redacted(reason:) модификатор, чтобы показать вью как placeholder.
Для того, чтобы понять, как будет выглядеть наш виджет в данном случае давайте создадим LadderWidgetPlaceholderView.

struct LadderWidgetPlaceholderView: View {
    var entry: LadderProvider.Entry

    var body: some View {
        LadderWidgetEntryView(entry: entry)
            .redacted(reason: .placeholder)
    }
}

То есть мы просто показывает вью нашего виджета и применяем к нему модификатор .redacted(reason: .placeholder). Если есть необходимость не показывать какие-то отдельные элементы вью как placeholder, то можно использовать оператор .unredacted() для них.

Более подробно про работу placeholder’ов можно прочитать у Majid Jabrayilov здесь.

Данные для виджета предоставляет LadderProvider, пройдемся более подробно по его функциям.

Плейсхолдер

func placeholder(in context: Self.Context) -> Self.Entry

Тут все просто, просто возвращаем слепок для плейсхолдера.

Таймлайн

    func getTimeline(for configuration: Self.Intent, in context: Self.Context, completion: @escaping (Timeline<Self.Entry>) -> Void)

Как можно заметить этот метод ничего не возвращает, вместо этого у него есть комплишен блок, который принимает объект Timeline.

По сути таймлайн это просто структура, которая содержит массив слепков и объект полиси.

struct Timeline<EntryType> where EntryType : TimelineEntry {
    public let entries: [EntryType]
    public let policy: TimelineReloadPolicy
}

Как мы помним, слепок здесь это наша структура LadderEntry, у которой есть date и собственно данные для виджета, так вот эта date и определяет когда виджет будет обновлен в будущем.

Можно например сгенерировать таймлайн, где виджет будет обновляться каждые 3 секунды на протяжении 1 минуты.

    func getTimeline(for configuration: ProfileSelectionIntent, in context: Context, completion: @escaping (Timeline<LadderEntry>) -> ()) {
        let profile = self.profile(for: configuration)
        var entries: [LadderEntry] = [LadderEntry(date: Date(), profile: profile)]

        let threeSeconds: TimeInterval = 3
        var currentDate = Date()
        let endDate = Calendar.current.date(byAdding: .minute, value: 1, to: currentDate)!
        while currentDate < endDate {
            let newScore = Double.random(in: 150...200)
            let newProfile = Profile(id: profile.id, name: profile.name, rating: profile.rating, score: newScore, imageName: profile.imageName)
            let entry = LadderEntry(date: currentDate, profile: newProfile)
            entries.append(entry)
            currentDate += threeSeconds
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

Что будет когда закончится эта 1 минута? Это зависит от того, что указано в policy объекта структуры TimelineReloadPolicy.

  • atEnd — WidgetKit запросит новый таймлайн;
  • never — само приложение скажет WidgetKit’у когда обновить виджет;
  • after(_ date: ) — определяет точную дату, когда WidgetKit запросит новый таймлайн.

Текущий снапшот

Осталась одна функция в провайдере, которую осталась описать.

    func getSnapshot(for configuration: Self.Intent, in context: Self.Context, completion: @escaping (Self.Entry) -> Void)

В ней мы возвращает слепок, который представляет текущее время и состояние виджета. По сути реализация мало чем отличается от getTimeline(), но есть один момент который стоит упомянуть.

WidgetKit вызывает этот метод, когда виджет появляется в разных переходах и так же в галерее виджетов, тогда context.isPreview == true. В этом случае нужно вызвать комплишен как можно быстрее, допускается возвращать заранее приготовленные данные, если загрузка реальных данных может занять больше пары секунд.

    func getSnapshot(for configuration: ProfileSelectionIntent, in context: Context, completion: @escaping (LadderEntry) -> ()) {
        if context.isPreview {
            let entry = LadderEntry(date: Date(), profile: sampleProfile)
            completion(entry)
        } else {
            // it takes a few seconds to fetch the data
            let profile = self.profile(for: configuration)
            let entry = LadderEntry(date: Date(), profile: profile)
            completion(entry)
        }
    }

На симуляторе, это будет выглядеть вот так

WidgetBundle

WidgetKit также предоставляет возможность иметь больше чем один виджета на приложение.
Для того, чтобы добавить еще один виджет в приложение нужно соответственно реализовать этот виджет и создать бандл виджетов, который будет входной точкой для Widget таргета.

Важный момент здесь, что точка входа может быть только одна, то есть @main должен быть только один и стоять перед WidgetsBundle.

@main
struct WidgetsBundle: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        LadderWidget()
        NewsWidget()
    }
}

Здесь Xcode может бросить ошибку:

Please specify the widget kind in the scheme’s Environment Variables using the key ‘_XCWidgetKind’ to be one of: ‘LadderWidget’, ‘NewsWidget’}.

Чтобы это поправить нужно пойти Edit Scheme виджет таргета и поправить значение свойства _XCWidgetKind.

Запускаем симулятор снова и видим, что теперь мы можем выбрать еще один виджет для приложения.

IntentHandler

Что если, когда мы редактируем виджет мы хотели бы иметь не статические параметры, а динамические? Которые мы могли бы, скажем, загружать с сервера?

Виджеты позволяют это сделать и для этого нужно будет создать Intents Extension таргет, создать файл .intentdefinition и в нем определить опции выбора и далее в классе IntentHandler` обработать колбек выбора опции.

Итак начнем, создаем новый таргет New -> Target -> Intents Extension и даем ему имя например LadderWidgetIntent. Далее руками создаем DynamicLadderIntents.intentdefinition. Его конфигурация будет похожа на LadderIntents.intentdefinition, но с небольшими отличиями.

Вместо Enum у нас будет конкретный тип ProfileType, сам intent будет называться DynamicProfileSelection и так же нужно проставить галочку динамически загружать опции.

Теперь везде в проекте нужно заменить ProfileSelectionIntent на DynamicProfileSelectionIntent.

Xcode автоматически для нас генерирует класс ProfileType, а также протоколы выбора конкретного профиля. Тем самым мы получаем тот самый колбек для загрузки конфигурации.

extension IntentHandler: DynamicProfileSelectionIntentHandling {
    func provideProfileTypeOptionsCollection(for intent: DynamicProfileSelectionIntent, with completion: @escaping (INObjectCollection<ProfileType>?, Error?) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            let items: [ProfileType] = [
                ProfileType(identifier: "1", display: "Bloodseeker"),
                ProfileType(identifier: "2", display: "Morphling"),
                ProfileType(identifier: "3", display: "Lina")]
            let collection = INObjectCollection(items: items)
            completion(collection, nil)
        }
    }
}

Поправляем функцию profile(for _:) в провайдере и теперь наша конфигурация подтягивается динамически.

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

iOS предоставляет несколько способов передачи данных между виджетом и приложением.

  1. Keychain access group — хорошо подойдет если нужно передать какой-нибудь сервер-сайд токен, ключ или любую другую сенситив информацию.
  2. App group — предоставляет больше возможностей, но меньше секьюрити. Можно шарить UserDefaults, а также место на диске.

    UserDefaults(suiteName: appGroup)
    let url = sharedFileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
  3. Свои костыли, например с использованием NSFileCoordinator или Darwin Notifications

Принудительное обновление виджета

Приложение может обновить свой виджет не дожидаясь, когда это сделает система, с помощью WidgetCenter.

WidgetCenter.shared.reloadAllTimelines()

Еще пару слов

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

Вопрос: Можно ли использовать готовые UIKit вьюшки в виджетах?
Ответ: Нет, виджеты работают только на чистом SwiftUI, даже если вы обернете UIKit вьюшку в SwiftUI вью, то да код скомпилируется, но на устройстве вы увидите просто желтый экран заглушку.

Вопрос: Почему при запуске на симуляторе, я не вижу своих последних изменений?
Ответ: Отладка виджетов достаточно болезненный процесс, то виджет не появляется на экране или появляется только черный квадрат, то вообще виджета нету в галереи виджетов или виджет не обновляется, и такого много, как правило удаление виджета с симулятора и перезапуск симулятора помогает решить такие проблемы.

Заключение

В статье были разобраны основные моменты работы с виджетами, надеюсь кому-то пригодится.

Let’s block ads! (Why?)

Read More

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

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