Полный список
— создаем виджет со списком
В третьей версии Андроид у виджетов появилась возможность работать с наборами данных типа списка или грида. Рассмотрим эту технологию на примере списка. В качестве view-компонента используется обычный ListView. Для межпроцессной работы с ним используется, как обычно в виджетах, RemoteViews. Но для заполнения нам придется создать два класса в дополнение к стандартному классу провайдера.
Первый – этот класс будет наполнять наш список значениями. Класс является реализацией интерфейса RemoteViewsService.RemoteViewsFactory, и его методы очень схожи с методами стандартного адаптера. Его обычно везде называют factory. Я же в этом уроке буду называть его просто адаптером.
Второй – класс сервиса, наследующий RemoteViewsService. В нем мы реализуем только один метод, который будет создавать и возвращать экземпляр (первого) класса, который будет заполнять список.
При создании и работе со списком в виджете необходимо понимать, как реализованы два момента: заполнение данными и реакция на нажатия.
Опишу вкратце схему заполнения данными. При подготовке виджета в классе провайдера мы для списка присваиваем Intent, который содержит данные для вызова нашего второго класса-сервиса. Когда система хочет обновить данные в списке (в виджете) она достает этот интент, биндится к указанному сервису и берет у него адаптер. И этот адаптер уже используется для наполнения и формирования пунктов списка.
Теперь о реализации нажатий на пункты списка. В обычном виджете использовались PendingIntent. Здесь чуть по-другому. Для каждого пункта в списке НЕ создается свой отдельный PendingIntent. Вместо этого списку дается общий, шаблонный PendingIntent. А для каждого пункта списка мы указываем отдельный Intent с extra-данными. Далее, при создании, каждому пункту списка система присваивает обработчик нажатия, который при срабатывании берет этот общий PendingIntent, добавляет к нему данные из персонального Intent, и отправляет по назначению сформированный таким образом PendingIntent. Т.е. в итоге по клику все равно срабатывает PendingIntent.
Сделаем пример и рассмотрим на практике все эти теоретические выкладки.
Создадим проект без Activity:
Project name: P1211_ListWidget
Build Target: Android 4.1
Application name: ListWidget
Package name: ru.startandroid.develop.p1211listwidget
Создаем layout-виджета — widget.xml:
Текст будет использован для отображения время обновления. Он же собственно и будет кнопкой обновления. В списке будем показывать данные.
Теперь layout строки списка – item.xml:
В каждом пункте списка у нас будет только текст.
Создаем класс-адаптер — MyFactory.java:
Методы очень похожи на методы обычного адаптера. Обсудим некоторые.
MyFactory – конструктор. Здесь никаких требований. Я, например, использую конструктор с двумя параметрами – Context и Intent. Этот Intent будет передавать нам сервис при создании адаптера. В нем я передаю адаптеру ID виджета.
getLoadingView – здесь вам предлагается возвращать View, которое система будет показывать вместо пунктов списка, пока они создаются. Если ничего здесь не создавать, то система использует некое дефолтное View.
getViewAt – создание пунктов списка. Здесь идет стандартное использование RemoteViews
onDataSetChanged – вызывается, когда поступил запрос на обновление данных в списке. Т.е. в этом методе мы подготавливаем данные для списка. Метод заточен под выполнение тяжелого долгого кода. В трех первых пунктах списка мы выводим текущее время, хэш-код адаптера и ID-виджета. Позже станет понятно, зачем.
onDestroy – вызывается при удалении последнего списка, который использовал адаптер (один адаптер может использоваться несколькими списками).
Создаем сервис – MyService.java:
В нем мы просто реализуем метод onGetViewFactory, который создает адаптер, передает ему Context и Intent, и возвращает этот созданный адаптер системе.
Класс провайдер – MyProvider.java:
onUpdate вызывается, когда поступает запрос на обновление виджетов. В нем мы перебираем ID, и для каждого вызываем метод updateWidget.
updateWidget – здесь вызываем три метода для формирования виджета и затем метод updateAppWidget, чтобы применить все изменения к виджету.
setUpdateTV – в этом методе работаем с TextView (который над списком). Ставим ему время в качестве текста и вешаем обновление виджета по нажатию.
setList – с помощью метода setRemoteAdapter указываем списку, что для получения адаптера ему надо будет обратиться к нашему сервису MyService.
Также обратите внимание, что в Intent мы помещаем ID виджета. Зачем? Этот Intent будет передан в метод сервиса onGetViewFactory. Этот метод мы реализовывали, в нем мы создаем адаптер и передаем ему тот же Intent. А уже в адаптере достаем этот ID и используем (третья строка в списке). Т.е. этот Intent пройдет через сервис и попадет в адаптер, поэтому если хотите что-то передать адаптеру, используйте этот Intent.
Но, повторюсь, это вовсе необязательно. Вы можете создать конструктор адаптера и без Intent-а на вход. Просто мне надо было как-то передать адаптеру ID виджета, поэтому я использую Intent.
setListClick – пока пустой. Чуть позже будем кодить в нем обработку нажатий на пункты списка.
Файл метаданных виджета — res/xml/widget_metadata.xml:
Фрагмент манифеста, описывающий сервис и бродкаст:
Для сервиса, необходимо установить разрешение BIND_REMOTEVIEWS. Это мы не наделяем сервис полномочиями, а наоборот, указываем, что этими полномочиями должен быть наделен тот, кто будет этот сервис вызывать. Система имеет такие полномочия, поэтому сможет использовать сервис для заполнения списка в адаптере.
Непростая это штука – виджеты, правда? Столько телодвижений из-за простого списка 🙂
Все сохраняем, инсталлим виджет. Добавим на экран. В списке он будет называться ListWidget.
Видим время обновления виджета, время формирования данных в списке, хэш-код адаптера, ID виджета.
Жмем зеленую зону для обновления.
Время обновления виджета поменялось, а вот список не обновился, время формирования данных осталось прежним.
Чтобы обновить данные в списке виджета, необходимо явно вызвать метод notifyAppWidgetViewDataChanged и передать ему ID виджета и ID списка.
Давайте сделаем это. Перепишем updateWidget в MyProvider.java:
Добавили вызов обновления данных списка.
Сохраняем, инсталлим. Теперь нажатие на зеленую зону будет обновлять и список.
Теперь давайте добавим второй виджет. У них внезапно совпадают ID виджета и хэш-коды адаптеров.
Вывод: они используют один адаптер. Почему так?
Когда система создает список в виджете, она использует Intent, который мы передавали в метод setRemoteAdapter. В этом Intent мы указали, что надо использовать сервис MyService. Система биндится к MyService и передает ему этот Intent. Сервис проверяет, не был ли уже создан адаптер для такого Intent. Если был – то он и возвращается системе. Если по такому Intent еще не создавался адаптер, то он создается (используется метод onGetViewFactory, который мы реализовали) и возвращается системе. Т.е. некая система кэширования адаптеров по Intent.
Теперь наложим эту логику на нашу ситуацию. Мы создали первый виджет. В метод setRemoteAdapter передавали Intent с указанием класса нашего сервиса и с ID виджета в extra-данных. Сервис создал адаптер, отдал его списку первого виджета и связал эту пару – Intent и адаптер. Далее мы создаем второй виджет. Для его списка, мы использовали такой же Intent. Отличие только в extra-данных – ID виджета. Но сервис сверяет Intent-ы только по основным данным, без extra. Поэтому для него два этих Intent от разных виджетов получились одинаковы. И когда список второго виджета дал Intent и попросил выделить ему адаптер, сервис взял Intent, увидел, что по подобному Intent уже был выдан адаптер и его и использовал вместо того, чтобы новый городить. Т.е. список второго виджета получил тот же адаптер, что и список первого виджета.
Поэтому список второго виджета и показывает те же данные адаптера (ID виджета и хэш-код), что и список первого. Как это пофиксить? Сделать Intent-ы разными. Для этого будем добавлять к ним data, в который поместим все данные Intent. В этом случае у нас в data попадут extra-данные и Intent-ы будут разными.
Перепишем метод setList в MyProvider.java:
Теперь все ок. Для разных виджетов получатся разные Intent и списки получат разные адаптеры.
Для чистоты эксперимента удалите пару ранее созданных виджетов с экрана.
Все сохраняем, инсталлим. Размещаем пару виджетов
Все ок, видим, что теперь списки используют разные адаптеры.
Осталось разобраться с реагированием на нажатия пунктов списка.
Добавим пару констант в класс MyProvider.java:
Заполним метод setListClick в MyProvider.java:
Здесь используется обычный алгоритм послания бродкаста. Мы с помощью метода setPendingIntentTemplate устанавливаем шаблонный PendingIntent, который затем будет использоваться всеми пунктами списка. В нем мы указываем, что необходимо будет вызвать наш класс провайдера (он же BroadcastReceiver) с action = ACTION_ON_CLICK.
Теперь нам надо сделать обработку этого action. Добавим метод onReceive в MyProvider.java:
Вызываем метод родителя, чтобы не нарушать работу провайдера. Далее проверяем, что action тот, что нам нужен — ACTION_ON_CLICK, вытаскиваем позицию нажатого пункта в списке и выводим сообщение на экран.
Осталось допилить адаптер. Перепишем getViewAt в MyFactory.java:
Для каждого пункта списка мы создаем Intent, помещаем в него позицию пункта и вызываем setOnClickFillInIntent. Этот метод получает на вход ID View и Intent. Что он с ними делает?
Для View с полученным на вход ID он создает обработчик нажатия, который будет дергать PendingIntent, который получается следующим образом. Берется шаблонный PendingIntent, который был привязан к списку методом setPendingIntentTemplate (в классе провайдера) и к нему добавляются данные полученного на вход Intent-а. Т.е. получится PendingIntent, Intent которого будет содержать action = ACTION_ON_CLICK (это мы сделали еще в провайдере) и данные по позиции пункта списка. При нажатии на пункт списка, этот Intent попадет в onReceive нашего MyProvider и будет обработан, как я уже чуть ранее описывал.
Все сохраняем, инсталлим. Проверяем – нажимаем на какой либо пункт:
Подытожим про LifeCycle-методы. Метод onCreate для адаптера вызывается, когда он создается для первого своего списка. А метод onDestory вызывается, когда удаляется последний список, использующий этот адаптер.
Мы использовали метод setRemoteAdapter. который на вход берет ID View и Intent, этот метод появился только в API 14. А изначально в API 11 была такая реализация — setRemoteAdapter (int appWidgetId, int viewId, Intent intent). Он на вход требовал еще ID виджета. Используйте этот вариант метода, если ваш виджет должен будет работать в Android 3.
У обычного ListView есть возможность установить View, которое будет отображаться если данных в списке нет — метод setEmptyView. RemoteViews также предоставляет вам такую возможность — setEmptyView. На вход передаете ID списка и ID пустого View.
На следующем уроке:
— рассмотрим прочие возможности виджета: превью, изменение размера, экран блокировки, ручное обновление
Присоединяйтесь к нам в Telegram:
— в канале StartAndroid публикуются ссылки на новые статьи с сайта startandroid.ru и интересные материалы с хабра, medium.com и т.п.
— в чатах решаем возникающие вопросы и проблемы по различным темам: Android, Kotlin, RxJava, Dagger, Тестирование
— ну и если просто хочется поговорить с коллегами по разработке, то есть чат Флудильня
— новый чат Performance для обсуждения проблем производительности и для ваших пожеланий по содержанию курса по этой теме
Источник
Виджеты на Android. Редкая фича, в которой придется разобраться
Привет, Хабр! Меня зовут Александр Хакимов, я android-разработчик в компании FINCH.
У вас бывало такое, что ваш дизайн был под iOS, а вам приходится адаптировать его под android? Если да, то часто ли ваши дизайнеры используют виджеты? К сожалению, виджет — редкий кейс для многих разработчиков, потому что с ним редко кто работает,
В статье я подробно расскажу как создать виджет, на что стоит обратить внимание и поделюсь своим кейсом.
Создание виджета
Для создания виджета нужно знать:
- Особенности компонентов виджета.
- Особенности отображения виджета в сетке экрана.
- Особенности обновления виджета.
Разберем каждый пункт отдельно.
Особенности компонентов виджета
С этим пунктом знаком любой разработчик, который хоть раз работал с RemoteViews. Если вы из таких, смело переходите к следующему пункту.
RemoteViews предназначен для описания и управления иерархиями Views, которые принадлежат процессу другого приложения. С помощью управления иерархиями можно изменять свойства или вызывать методы, принадлежащие View, которое выступает частью другого приложения. В RemoteViews входит ограниченный набор компонентов стандартной библиотеки компонентов android.widget.
View внутри виджетов работают в отдельном процессе (как правило, это домашний экран), поэтому для изменения UI виджета используется расширение BroadcastReceiver — AppWidgetProvider, работающий в нашем приложении.
Особенности отображения виджета в «сетке» экрана
Each widget must define a minWidth and minHeight, indicating the minimum amount of space it should consume by default. When users add a widget to their Home screen, it will generally occupy more than the minimum width and height you specify. Android Home screens offer users a grid of available spaces into which they can place widgets and icons. This grid can vary by a device; for example, many handsets offer a 4×4 grid, and tablets can offer a larger, 8×7 grid.
Переводя на русский: каждый виджет должен задать свои минимальную ширину и высоту, чтобы обозначить минимальное пространство, которое будет им занято по умолчанию.
Пример настроек виджета при создании в Android Studio
Виджет, который добавили на на Home screen, обычно займет больше места чем минимальные ширина и высота экрана, которые вы задали. Android Home screens предоставляет пользователям сетку доступного пространств, в которых могут быть расположены виджеты и иконки. Эта сетка может отличаться в зависимости от устройства; например, многие телефоны предлагают сетку 4х4, а планшеты могут предложить большие сетки 8х7.
Из этого становится понятно, что сетка устройства может быть какой угодно, а размер ячеек может отличаться, в зависимости от размера сетки. Соответственно, контент виджета должен быть спроектирован с учетом этих особенностей.
Минимальные ширину и высоту виджета для заданного количества столбцов и строк можно вычислить по формуле:
minSideSizeDp = 70 × n − 30, где n —количество строк или столбцов
На текущий момент максимальный минимум сетки, которую вы можете задать это 4х4. Это гарантирует, что ваш виджет будет отображаться на всех девайсах.
Особенности обновления виджета
Так как AppWidgetProvider по своей сути является расширением BroadcastReceiver-а, с ним можно делать все то же самое, что и с обычным BroadcastReceiver. AppWidgetProvider просто парсит соответствующие поля из Intent, полученного в onReceive и вызывает методы перехвата с полученными extras.
Сложность возникла с частотой обновления контента — все дело в разнице внутренней работы виджетов на iOS и Android. Дело в том, что данные на iOS-виджетах обновляются тогда, когда виджет становится виден пользователю. В Android, такого события не существует. Мы не можем узнать, когда пользователь видит виджет.
Для виджетов на Android рекомендуемым способом обновления является обновление по таймеру. Настройки таймера задаются параметром виджета updatePeriodMillis. К сожалению, эта настройка не позволяет обновлять виджет чаще чем раз в 30 минут. Ниже я расскажу об этом подробнее.
Кейс создания виджета
Дальше речь пойдет о кейсе который был у нас в FINCH в крупном лотерейном приложении с приложением «Столото» для участия в государственных лотереях.
Задача приложения – упростить и сделать прозрачным для пользователя выбор лотереи и покупку билета. Поэтому требуемая функциональность виджета довольно проста: показывать пользователю рекомендуемые игры для покупки и по тапу переходить к соответствующей. Список игр определяется на сервере и регулярно обновляется.
В нашем кейсе дизайн виджета предусматривал два состояния:
- Для авторизованного пользователя
- Для неавторизованного пользователя
Авторизованному пользователю нужно показывать данные его профиля: состояние внутреннего кошелька, количество билетов ожидающих розыгрыша и сумму неполученных выигрышей. Для каждого из этих элементов предусмотрен, отличный от других, переход на экран внутри приложения.
Как вы могли заметить, еще одной особенностью для авторизованного пользователя является кнопка «обновить», но об этом позже.
Для реализации отображения двух состояний, с учетом дизайна, я использовал RemoteAdapter в виде реализации RemoteViewsService, чтобы генерировать карточки контента.
А теперь немного кода и того, как все работает внутри. Если у вас уже был опыт работы с виджетом, то вы знаете, что любое обновление данных виджета начинается с метода onUpdate:
Пишем апдейт для каждого инстанса нашего виджета.
Пишем реализацию нашего сервиса. В нем нам важно указать, какую реализацию интерфейса RemoteViewsService.RemoteViewsFactory использовать, чтобы генерировать контент.
Фактически это тонкий wrapper над Adapter. Благодаря ему, мы можем связывать наши данные с remote collection view. RemoteViewsFactory предоставляет методы генерации RemoteViews для каждого элемента в наборе данных. У конструктора нет никаких требований — все что я делаю, это передаю в нем контекст.
Далее будет пару слов об основных методах:
- onCreate – создание адаптера.
- getLoadingView – метод предлагает возвращать View, которое система будет показывать вместо пунктов списка, пока они создаются. Если ничего здесь не создавать, то система использует некое дефолтное View.
- getViewAt – метод предлагает создать пункты списка. Здесь идет стандартное использование RemoteViews.
- onDataSetChanged вызывается, когда поступил запрос на обновление данных в списке. Т.е. в этом методе мы подготавливаем данные для списка. Метод заточен под выполнение тяжелого, долгого кода.
- onDestroy вызывается при удалении последнего списка, который использовал адаптер (один адаптер может использоваться несколькими списками).
- RemoteViewsFactory живет пока все инстансы списка живы, поэтому мы можем хранить в нем текущие данные, например, список текущих айтемов.
Определяем список данных, который будем показывать:
При создании адаптера начинаем загружать данные. Здесь спокойно можно выполнять любые тяжелые задачи, в том числе спокойно ходить в сеть блокируя поток.
При вызове команды на обновление данных, так же вызываем updateDataSync()
Внутри updateDataSync тоже все просто. Очищаем текущий список item-ов. Загружаем данные профиля и игры.
Здесь уже поинтереснее
Так как нам важно показывать профиль только авторизованному пользователю, то и информацию профиля нам нужно загружать только в этом случае:
Модель WidgetProfile собирается из разных источников, поэтому логика их получения и её дефолтные значения устроены таким образом, что отрицательное значение кошелька говорит о некорректных данных или проблемах с их получением.
Для бизнес логики отсутствие данных кошелька является критичным, поэтому, в случае некорректного кошелька, модель профиля не будет создана и добавлена в список item-ов.
Метод updateGamesSync() использует getWidgetGamesInteractor и добавляет в список widgetItems набор актуальных для виджета игр.
Прежде чем перейти к генерации карточек, рассмотрим подробнее модель WidgetItem. Она реализована через kotlin sealed class, что делает модель более гибкой, а работу с ней более удобной.
Создаем RemoteViews и определяем их отклик через FillInIntent
Метод setOnClickFillInIntent назначает указанной viewId intent, который будет объединен с родительским PendingIntent для определения поведения при клике на view с этим viewId. Таким образом, мы сможем реагировать на клики пользователей в нашем WidgetProvider.
Ручное обновление виджета
Для нашего виджета было установлено время обновления в полчаса. Можно обновлять его чаще, например, через танцы с WorkManager, но зачем грузить вашу сеть и аккумулятор? Такое поведение на первых этапах разработки казалось адекватным.
Все изменилось когда «бизнес» обратил внимание, что когда пользователь смотрит на виджет, на нем отображаются неактуальные данные: «Вот на моем iPhone, я открываю виджет и там САМЫЕ свежие данные моего профиля».
Ситуация банальна: iOS генерирует новые карточки при КАЖДОМ показе виджетов, ведь для этого у них отведен специальный экран, а Android не имеет подобных событий для виджета в принципе. Пришлось учесть, что некоторые лотереи проводятся раз в 15 минут, поэтому виджет должен давать актуальную информацию – ты хочешь поучаствовать в каком-то тираже, а он уже прошел.
Чтобы выйти из этой неприятной ситуации и как то решить проблему с обновлением данных, мной было предложено и реализовано проверенное временем решение — кнопка «обновить».
Добавляем эту кнопку в макет layout-a со списком и инициализируем её поведение при вызове updateWidget
Первые наработки показали грустную картину: от нажатия на кнопку «обновить» до фактического обновления, могло пройти несколько секунд. Хотя виджет и генерируется нашим приложением, он фактически находится во власти системы и общается с нашим приложением через broadcast-ы.
Т.е. при нажатии на кнопку «обновить» нашего виджета запускается цепочка:
- Получить Intent в onReceive провайдера action’ .
- AppWidgetManager.ACTION_APPWIDGET_UPDATE.
- Вызов onUpdate для всех указанных в intent-e widgetIds.
- Зайти в сеть за новыми данными.
- Обновить локальные данные и отобразить новые карточки списка.
В результате, обновление виджета выглядело не очень красиво, так как нажав на кнопку, мы пару секунд смотрели на тот же виджет. Было непонятно обновились ли данные. Как решить проблему визуального отклика?
Во-первых, я добавил флаг isWidgetLoading с глобальным доступом через интерактор. Роль этого параметра довольно проста — не показывать кнопку «обновить», пока идет загрузка данных виджета.
Во вторых, процесс загрузки данных в фабрике я разделил на три этапа:
START — начало загрузки. На этом этапе состояние всех вьюшек адаптера и глобального флага загрузки меняется на «загружается».
MIDDLE — этап основной загрузки данных. После их загрузки глобальный флаг загрузки переводится в состояние «загружено», а в адаптере отображаются загруженные данные.
END — конец загрузки. Адаптеру на этом шаге не требуется изменять данные адаптера. Этот шаг нужен чтобы корректно обработать этап обновления вьюшек в WidgetProvider.
Давайте посмотрим подробнее как теперь выглядит обновление кнопки в провайдере:
А теперь посмотрим на то, что происходит в адаптере:
- В конце этапов START и MIDDLE я вызываю метод updateWidgets для того, чтобы обновить состояние view управляемых провайдером.
- После выполнения шага START для пользователя визуально отображается «загрузка» в ячейках виджета, и начнется этап MIDDLE.
- Перед тем как вызвать обновление данных адаптера на шаге MIDDLE, провайдер скроет кнопку «обновить».
- После выполнения шага MIDDLE, для пользователя будет отображаются новые данные и начнется этап END.
- Перед тем как вызвать обновление данных адаптера, на шаге END, провайдер скроет кнопку «обновить». С точки зрения фабрики все данные будут актуальными, поэтому на шаге END меняем значение loadingStep на START.
С помощью подобной реализации я достиг компромисс между требованием «бизнеса» видеть на виджете актуальные данные и необходимостью «дергать» обновление слишком часто.
Надеюсь, что статья была для вас полезной. Если у вас был опыт создания виджетов для Android, то расскажите об этом в комментариях.
Источник