[Из песочницы] Android Support Library 28. Что нового?

mo8kkhefqng8pjruydhr0xuhxeq.png

По давней традиции вместе с новой версией 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 значительно облегчает жизнь.

wgf3p60noozqsth-luriiq0t8ts.gif

Давайте разберемся на практике, как обстоит дело в support.

Добавим в gradle зависимости. Интересно, что Google выделила selection в отдельный репозиторий.

dependencies {
   implementation "com.android.support:recyclerview-selection:28.0.0-alpha1"
}


Напишем стандартный адаптер для RecyclerView.

class WordAdapter(private val items: List) : RecyclerView.Adapter() {

   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) : ItemKeyProvider(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() {

   override fun getItemDetails(e: MotionEvent) = recyclerView.findChildViewUnder(e.x, e.y)
           ?.let {
               (recyclerView.getChildViewHolder(it) as? ViewHolderWithDetails)?.getItemDetail()
           }

}


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

interface ViewHolderWithDetails {

   fun getItemDetail(): ItemDetails

}

class WordDetails(private val adapterPosition: Int, private val selectedKey: Word?) : ItemDetails() {

   override fun getSelectionKey() = selectedKey

   override fun getPosition() = adapterPosition

}

inner class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), ViewHolderWithDetails {

   override fun getItemDetail() = WordDetails(adapterPosition, items.getOrNull(adapterPosition))

}


Везде стандартный код. Удивительно, почему разработчики support library не добавили классическую реализацию сами.

Сформируем трекер в Activity.

val tracker = SelectionTracker
       .Builder(
               // идентифицируем трекер в контексте
               "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) {
   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() {
           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"
   }


Теперь точно все. Наслаждаемся простотой и красотой.

bdbuo1lkwil5vwyaxz4_tuwgces.gif

Мы сделали стандартный вариант. Кратко отмечу, что в 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(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. К сожалению, оно сейчас не работает. Мы использовали менее реактивный вариант.

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

lmn3yhpnmn4ot8r79xux-xq5btu.gif

В большинстве случаев RemoteView используется для виджетов и уведомлений. Slices плохо подходят под эти цели, они мало кастомизируемые и, как я уже писал, подстраиваются под дизайн приложения. Идеально подходят под приложения, которые используют данные других приложений. Под категорию всеобъемлющих подходят голосовые ассистенты — Google Assistant, Алиса и так далее. Как было замечено в блоге компании Novada, с помощью конструктора slice можно собирать слайсы, очень похожие на ответы для Google Assistant.

nzrtmhdtznerrvjxpq8dq1wqc5e.png

И тут самое время для теории.

Возьмем за основу то, что 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.

m1fin1jbzm7ngxwswvpmdcyc3uc.gif

Chip и ChipGroup


Тут и слова лишние.

adeaerrumxrbxh5elfkooj-gieo.gif

BottomAppBar


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

Меню на BottomAppBar нужно добавлять искусственно, для этого есть метод replaceMenu (@MenuRes int newMenu).

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

8vd3egrtl1-rxzfosy-3j2toucu.gif

Список коротких нововведений:


  • Android KTX, который анонсировали ранее. Куча open-source extension-ов на Kotlin. Очень полезно.
  • HEIF Writer. Новый формат кодирования одного или последовательности изображений дошел до Android через год после анонса на ios. Здесь не идёт речь о полной замене форматов, как у Apple. Просто библиотечка с конвертацией.
  • Browser Actions — протокол для кастомизации контекстного меню браузера под определенный url. Кастомизация ограничивается добавлением нескольких MenuItem-ов со своими иконкой, текстом и Intent-ом. Протокол подразумевает реализацию логики также со стороны браузера. В Chrome пока не реализовано.


Для тех, кто хочет поковыряться:


  1. Используйте Android studio 3.1 и выше. Эти версии пока не в релизе, но работают стабильно, я работал с 3.2.
  2. Немного пошаманить в build.gradle с версиями. Ну и, естественно, нужно добавить нужные зависимости.
    android {
       compileSdkVersion 'android-P'
       defaultConfig {
           targetSdkVersion 'P' // или 28
       }
    }
  3. Пока код, который использовал support 28, запускался только на эмуляторе с Android P. Все, что старее, ругалось и выдавало кучу ошибок при попытке запуска.


Список новых фич не окончательный. Если анализировать changelog библиотеки за предыдущие 2–3 года и экстраполировать данные на этот год, то в мае нас ожидает ещё много-много интересного. Ждём.

Полезные ссылки:


  • Весь код из статьи.
  • Хорошая статья про BottomAppBar. Отсюда я брал код для демонстрации.
  • Подробный рассказ про Slices опять не от Google. Некоторые идеи и основу для кода брал отсюда.
  • Подробный разбор полезных мелочей из Android KTX

© Habrahabr.ru