По давней традиции вместе с новой версией Android выходит обновление Support Library. Пока библиотека вышла в стадии альфа, но список изменений уже намного интереснее, чем такой же список у Android P. Google несправедливо мало рассказал и написал об основных нововведениях главной библиотеки для Android. Приходится читать исходники и разбираться, в чем особенности новых фич и зачем они нужны. Восстановлю справедливость и расскажу, чем нас порадовал Google:
- RecyclerView selection — выбор элементов теперь из коробки;
- Slices — новый способ отображать контент другого приложения;
- новые элементы дизайна: BottomAppBar, ChipGroup и другие;
- мелкие изменения одной строкой.
RecyclerView selection
В 2014 году, вместе с релизом Lollipop, Google добавила в support новый элемент — RecyclerView, как замену устаревшему ListView. Все было хорошо с ним, да не хватало одного метода из ListView — setSelectionMode(). Спустя 4 года этот метод косвенно был реализован в RecyclerView в виде целой библиотеки.
Что же волшебного в selection? Selection mode — режим, которой инициализируется долгим нажатием по элементу списка. Далее можем выбрать несколько других элементов и сделать общее действие на ними. Пример: в Google Photos selection mode значительно облегчает жизнь.
Давайте разберемся на практике, как обстоит дело в support.
Добавим в gradle зависимости. Интересно, что Google выделила selection в отдельный репозиторий.
dependencies {
implementation "com.android.support:recyclerview-selection:28.0.0-alpha1"
}
Напишем стандартный адаптер для RecyclerView.
class WordAdapter(private val items: List<Word>) : RecyclerView.Adapter<WordViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = WordViewHolder(
LayoutInflater
.from(parent.context)
.inflate(R.layout.item_word, parent, false)
)
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
val item = items[position]
holder.bind(item)
}
class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val text: TextView = itemView.findViewById(R.id.item_word_text)
fun changeText(word: Word) {
text.text = word.text
}
}
}
Модель Word используем в качестве данных.
@Parcelize
data class Word(val id: Int, val text: String) : Parcelable
Фундамент есть, приступим к реализации выбора. Сперва нужно определиться, что будет идентифицировать элемент списка. Google предлагает на выбор три варианта: Long, String, Parcelable. Для этой цели у нас уже сформирован Word, не хватает только реализации Parcelable. Реализацию добавим аннотацией @Parcelize, которая доступная в экспериментальной версии Kotlin. В Android Studio 3.2 пока есть проблемы со сборкой проекта с экспериментальным Kotlin, но никто не отменял студийные шаблоны.
SelectionTracker — главный класс библиотеки. Объект этого класса обладает информацией про выбранные пользователем элементы и позволяет из кода изменять этот список. Чтобы инициализировать данный класс, понадобятся реализации двух абстрактных классов: ItemKeyProvider и ItemDetailsLookup. Первый нужен для двусторонней связи позиции элемента в коллекции и ключа.
// В конструкторе ItemKeyProvider мы выбираем метод предоставления доступа к данным:
// SCOPE_MAPPED - ко всем данным. Позволяет реализовать функционал, требующий наличие всех элементов в памяти
// SCOPE_CACHED - к данным, которые были недавно или сейчас на экране. Экономит память
class WordKeyProvider(private val items: List<Word>) : ItemKeyProvider<Word>(ItemKeyProvider.SCOPE_CACHED) {
override fun getKey(position: Int) = items.getOrNull(position)
override fun getPosition(key: Word) = items.indexOf(key)
}
ItemDetailsLookup нужен для получения позиции элемента и его ключа по координатам x и y.
class WordLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<Word>() {
override fun getItemDetails(e: MotionEvent) = recyclerView.findChildViewUnder(e.x, e.y)
?.let {
(recyclerView.getChildViewHolder(it) as? ViewHolderWithDetails<Word>)?.getItemDetail()
}
}
Напишем также интерфейс для получение данных из ViewHolder и реализуем его.
interface ViewHolderWithDetails<TItem> {
fun getItemDetail(): ItemDetails<TItem>
}
class WordDetails(private val adapterPosition: Int, private val selectedKey: Word?) : ItemDetails<Word>() {
override fun getSelectionKey() = selectedKey
override fun getPosition() = adapterPosition
}
inner class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), ViewHolderWithDetails<Word> {
override fun getItemDetail() = WordDetails(adapterPosition, items.getOrNull(adapterPosition))
}
Везде стандартный код. Удивительно, почему разработчики support library не добавили классическую реализацию сами.
Сформируем трекер в Activity.
val tracker = SelectionTracker
.Builder<Word>(
// идентифицируем трекер в контексте
"someId",
recyclerView,
// для Long ItemKeyProvider реализован в виде StableIdKeyProvider
WordKeyProvider(items),
WordLookup(recyclerView),
// существуют аналогичные реализации для Long и String
StorageStrategy.createParcelableStorage(Word::class.java)
).build()
Поправим ViewHolder, добавим реакцию на изменение состояния выбора.
fun setActivatedState(isActivated: Boolean) {
itemView.isActivated = isActivated
}
Добавим трекер в адаптер, переопределим onBindViewHolder с payload. Если изменения касаются только состояния выбора, то в payloads будет находиться константа SelectionTracker.SELECTION_CHANGED_MARKER.
override fun onBindViewHolder(holder: WordViewHolder, position: Int, payloads: List<Any>) {
holder.setActivatedState(tracker.isSelected(items[position]))
if (SelectionTracker.SELECTION_CHANGED_MARKER !in payloads) {
holder.changeText(items[position])
}
}
Tracker готов и работает, как часы. Добавим немного красоты и смысла. Пусть AppBar меняет цвет, заголовок начнет отображать количество выбранных элементов и добавляется кнопка Clear в меню, когда пользователь что-нибудь выбирает. Для этого есть ActionMode и поддержка его в AppCombatActivity.
Первым делом напишем реализацию ActionMode.Callback.
class ActionModeController(
private val tracker: SelectionTracker<*>
) : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.action_menu, menu)
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
tracker.clearSelection()
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = true
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = when (item.itemId) {
R.id.action_clear -> {
mode.finish()
true
}
else -> false
}
}
Добавим observer к SelectionTracker и свяжем изменения в трекере с ActionMode в Activity.
tracker.addObserver(object : SelectionTracker.SelectionObserver<Any>() {
override fun onSelectionChanged() {
super.onSelectionChanged()
if (tracker.hasSelection() && actionMode == null) {
actionMode = startSupportActionMode(ActionModeController(tracker))
setSelectedTitle(tracker.selection.size())
} else if (!tracker.hasSelection()) {
actionMode?.finish()
actionMode = null
} else {
setSelectedTitle(tracker.selection.size())
}
}
})
}
private fun setSelectedTitle(selected: Int) {
actionMode?.title = "Selected: $selected"
}
Теперь точно все. Наслаждаемся простотой и красотой.
Мы сделали стандартный вариант. Кратко отмечу, что в Builder много методов для кастомизации процесса выбора. Например, с помощью метода withSelectionPredicate(predicate: SelectionPredicate) можно ограничить количество выбранных элементов или запретить выбор особых элементов. Также в Builder предусмотрены методы по добавлению поведения, которое может конфликтовать с selection при традиционном способе добавления. Например, при помощи withOnDragInitiatedListener(listener: OnDragInitiatedListener) можно настроить Drag&Drop.
Slices
Самой странной новинкой оказался Slice. Google посвятила очень мало времени объяснениям сообществу, что это за диковина. Есть только код и документации к половине классов. Давайте разбираться.
За основу возьму код отсюда, потому что они придумали, как обходить баги с Permission в Android P DP1. Хочу отметить, что Slices не является новинкой support library. Фича появилась в Android SDK 28, а в support ареал обитания расширен до 24 версии SDK. На этом можно завершить рассказ и продолжить его через несколько лет. Пока minSdkVersion может быть максимум 19, поговорим в общем об идее этой технологии и о том, зачем она вообще нужна.
Slices — библиотека, которая позволит запрашивать из одного приложения (клиент или хост) часть или статичный кусочек другого приложения (отправитель или провайдер). Очень похоже на описание RemoteViews, которое часто используется для программирования кастомных виджетов и уведомлений.
Slice — это данные в каркасе без дизайна и интерактивности, как HTML без CSS и Js. Дизайн будет подстраиваться под тему приложения-хоста. Пример слайса.
Отправитель — это ContentProvider, которому нужно реализовать простой метод onBindSlice(sliceUri: Uri): Slice и внутри метода сформировать Slice. У нас провайдер будет отсылать время и количество вызовов.
class SliceContentProvider : SliceProvider() {
private var counter = 0
override fun onBindSlice(sliceUri: Uri): Slice {
return when (sliceUri.path) {
"/time" -> createTimeSlice(sliceUri)
else -> throw IllegalArgumentException("Bad url")
}
}
override fun onCreateSliceProvider(): Boolean {
Toast.makeText(context, "Slice content provider is launched", Toast.LENGTH_LONG).show()
return true
}
private fun createTimeSlice(sliceUri: Uri): Slice = ListBuilder(context, sliceUri)
.apply {
counter++
setHeader(
ListBuilder.HeaderBuilder(this)
.setTitle("What's the time now?")
)
addRow(
ListBuilder.RowBuilder(this)
.setTitle("It is ${SimpleDateFormat("HH:mm").format(Calendar.getInstance().time)}")
)
addRow(
ListBuilder.RowBuilder(this)
.setTitle("Slice has called $counter times")
)
}
.build()
}
Клиенту нужно сделать запрос по URI к провайдеру, запросить через него slice, получить и передать его в SliceView. Все действия производятся через SliceManager. Важно не забыть про permission.
private val baseSliceUri: Uri = Uri.parse("content://ru.touchin.provider/")
private val timeSliceUri = baseSliceUri.buildUpon().appendPath("time").build()
private lateinit var sliceManager: SliceManager
override fun onCreate(savedInstanceState: Bundle?) {
// стандартные процедуры инициализации View
sliceManager = SliceManager.getInstance(this)
findViewById<View>(R.id.get_slice).setOnClickListener {
tryShowingSlice(timeSliceUri)
}
}
override fun onStart() {
super.onStart()
if (providerAppNotInstalled(packageManager, baseSliceUri.authority)) {
showMissingProviderDialog(this, { finish() }, baseSliceUri)
return
}
}
private fun tryShowingSlice(sliceUri: Uri) {
if (sliceManager.missingPermission(sliceUri, appName = getString(R.string.app_name))) {
// запрашиваем permission сложным образом из-за Android P DP1
}
} else {
getSliceAndBind(sliceUri)
}
}
private fun getSliceAndBind(sliceUri: Uri) {
sliceView.setSlice(sliceManager.bindSlice(sliceUri))
}
SliceManager предоставляет возможность подписаться с помощью SliceLiveData на изменения Slice в провайдере и внутри подписки обновлять SliceView. К сожалению, оно сейчас не работает. Мы использовали менее реактивный вариант.
Запускаем провайдер, запускаем приложение. Наблюдаем результат работы. Все круто. Забавно, что счетчик инкрементируется два раза.
В большинстве случаев RemoteView используется для виджетов и уведомлений. Slices плохо подходят под эти цели, они мало кастомизируемые и, как я уже писал, подстраиваются под дизайн приложения. Идеально подходят под приложения, которые используют данные других приложений. Под категорию всеобъемлющих подходят голосовые ассистенты — Google Assistant, Алиса и так далее. Как было замечено в блоге компании Novada, с помощью конструктора slice можно собирать слайсы, очень похожие на ответы для Google Assistant.
И тут самое время для теории.
Возьмем за основу то, что Slice сделан для программирования ответов в Google Assistant — стратегически важный продукт для компании. Очевидно, что мы живем во времена, когда графический интерфейс постепенно вытесняется голосовым: растет популярность домашних ассистентов и есть прогресс в разработке голосового искусственного интеллекта посредством ИИ, нейронный сетей и других хайповых технологий.
Для Google самым логичным вариантом было бы развивать и наращивать Google Assistant, чтобы за год-два он стал мощным инструментом. Slice — теоретически отличный инструмент для накачки дополнениями от сторонних разработчиков. Так ассистент станет мощнее, все действия можно проводить через него и отпадет надобность в рабочих столах и иконках. Тогда Google Assistant станет основой для Android.
На данный момент нам ничего не рассказали толком про Slice: ни целей, ни преимуществ над RemoteView. Хотя по количеству кода в новой версии support Slice занимает чуть ли не первое место. Поэтому я думаю, на ближайшей I/O нам будут подробно рассказывать про Slice. И возможно расскажут о планах эволюции ОС или даже представят версию Android с голосовым интерфейсом для разработчиков.
Но все это спекуляция и желание автора раскрыть теорию заговора и добраться к истине. Единственное, что можно сказать на сто процентов, на Google I/O нас ждет развязка истории.
Новые элементы:
MaterialCardView и MaterialButton
MaterialCardView наследуется от CardView и практически ничем не отличается от него. Добавлена только возможность задавать границы карточки и в качестве background используется другой drawable. Найдите 10 отличий.
MaterialButton является наследником AppCombatButton и тут различия заметны. Разработчики сюда добавили больше способов кастомизировать кнопку: цвет ripple эффекта, разные радиусы кнопок, границы, как у MaterialCardView.
Chip и ChipGroup
Тут и слова лишние.
BottomAppBar
Самый интересный и неожиданный виджет в данной подборке, хотя идея очень проста, AppBar разместить внизу. Пользователю с маленькими руками и большими экранами неудобно дотягиваться до кнопки вызова меню или просто кнопки на AppBar вверху. Но никакой другой пользы в этом элементе нет.
Меню на BottomAppBar нужно добавлять искусственно, для этого есть метод replaceMenu(@MenuRes int newMenu).
Дизайнеры классно придумали, как сочетать FloatingActionButton и BottomAppBar. Но без кнопки BottomAppBar смотрится лишним. Убирается вырез, остается подбородок с кнопками меню с одной стороны. Проблему с меню на больших экранах можно было бы решить интересней, например по длинному нажатию на FloatingActionButton трансформировать ее в меню внизу экрана.
Список коротких нововведений:
- Android KTX, который анонсировали ранее. Куча open-source extension-ов на Kotlin. Очень полезно.
- HEIF Writer. Новый формат кодирования одного или последовательности изображений дошел до Android через год после анонса на ios. Здесь не идёт речь о полной замене форматов, как у Apple. Просто библиотечка с конвертацией.
- Browser Actions — протокол для кастомизации контекстного меню браузера под определенный url. Кастомизация ограничивается добавлением нескольких MenuItem-ов со своими иконкой, текстом и Intent-ом. Протокол подразумевает реализацию логики также со стороны браузера. В Chrome пока не реализовано.
Для тех, кто хочет поковыряться:
- Используйте Android studio 3.1 и выше. Эти версии пока не в релизе, но работают стабильно, я работал с 3.2.
- Немного пошаманить в build.gradle с версиями. Ну и, естественно, нужно добавить нужные зависимости.
android { compileSdkVersion 'android-P' defaultConfig { targetSdkVersion 'P' // или 28 } }
- Пока код, который использовал support 28, запускался только на эмуляторе с Android P. Все, что старее, ругалось и выдавало кучу ошибок при попытке запуска.
Список новых фич не окончательный. Если анализировать changelog библиотеки за предыдущие 2-3 года и экстраполировать данные на этот год, то в мае нас ожидает ещё много-много интересного. Ждём.
Полезные ссылки:
- Весь код из статьи.
- Хорошая статья про BottomAppBar. Отсюда я брал код для демонстрации.
- Подробный рассказ про Slices опять не от Google. Некоторые идеи и основу для кода брал отсюда.
- Подробный разбор полезных мелочей из Android KTX