SelectionTracker Para Seleção de Itens no RecyclerView Android

Investir em Você é Barra de Ouro a R$ 2,00. Cadastre-se e receba grátis conteúdos Android sem precedentes! Você receberá um email de confirmação. Somente depois de confirma-lo é que eu poderei lhe enviar os conteúdos semanais exclusivos. Os artigos em PDF são entregues somente para os inscritos na lista.

Email inválido.
Blog /Android /SelectionTracker Para Seleção de Itens no RecyclerView Android

SelectionTracker Para Seleção de Itens no RecyclerView Android

Vinícius Thiengo
(6962) (3)
Go-ahead
"O método consciente de tentativa e erro é mais bem-sucedido que o planejamento de um gênio isolado."
Peter Skillman
Prototipagem Android
Capa do curso Prototipagem Profissional de Aplicativos
TítuloAndroid: Prototipagem Profissional de Aplicativos
CategoriasAndroid, Design, Protótipo
AutorVinícius Thiengo
Vídeo aulas186
Tempo15 horas
ExercíciosSim
CertificadoSim
Acessar Curso
Quer aprender a programar para Android? Acesse abaixo o curso gratuito no Blog.
Lendo
TítuloCraftsmanship Limpo: Disciplinas, Padrões e ética
CategoriaDesenvolvimento Web
Autor(es)Robert C. Martin
EditoraAlta Books
Edição
Ano2023
Páginas416
Conteúdo Exclusivo
Investir em Você é Barra de Ouro a R$ 2,00. Cadastre-se e receba gratuitamente conteúdos Android sem precedentes!
Email inválido

Tudo bem?

Neste artigo vamos, passo a passo, ao estudo da biblioteca SelectionTracker, biblioteca que amplia a capacidade do RecyclerView, permitindo que seja possível a seleção de itens, incluindo a persistência da seleção em caso de reconstrução de atividade.

Como projeto de exemplo teremos a tela de favoritos de um aplicativo de filmes, tela onde o usuário poderá remover alguns dos filmes selecionados anteriormente.

Animação do app Android ThiengosFlix

E não deixe de se inscrever 📩 na lista de emails do Blog para ter acesso aos conteúdos exclusivos sobre desenvolvimento Android.

A seguir os tópicos abordados:

Qual o objetivo da biblioteca SelectionTracker?

Primeiro é importante lembrar que o RecyclerView, ao menos quando nos primeiros contatos, não é nada trivial como um ListView ou um GridView.

Partindo desse ponto já temos que colocar um algoritmo de marcação de itens, único ou múltiplos, se torna algo ainda menos simples.

O time do Google Android liberou a biblioteca SelectionTracker com o objetivo de facilitar a inclusão da funcionalidade de seleção de itens no RecyclerView. A biblioteca foi liberada em 2018 junto ao sucessor da Support Library, o AndroidX.

Com a SelectionTracker é também possível:

  • Escolher como os itens podem ser selecionados:
    • Toque / clique;
    • Gesto específico;
    • Drag.
  • Definir quais e quantos itens podem ser selecionados;
  • Ter fácil acesso aos itens selecionados.

Mas, como desenvolvedor Android que somos, sabemos que nem tudo "são flores".

Caso você precise de algo muito simples, a marcação de um único item em lista, por exemplo, você provavelmente achará o número de classes necessárias, para a configuração de um objeto SelectionTracker, uma escolha em demasiado.

Conhecer o RecyclerView é necessário

Antes de continuar, caso você ainda não conheça o RecyclerView, a seguir deixo dois links aqui do Blog onde somente falo sobre este framework de lista, que por sinal é o melhor framework de lista disponível no Android:

Apesar de os conteúdos indicados serem parte de uma Play List, eles são passíveis de serem compreendidos sem que o vídeo 1 da Play List, sobre o Toolbar, seja assistido.

O vinculo da SelectionTracker

A biblioteca SelectionTracker vem em um diferente pacote do que contém o RecyclerView. Há também um "melhor roteiro" para adicionarmos a biblioteca a algum projeto.

O roteiro de vinculação da SelectionTracker, indicado pela documentação oficial, é como a seguir:

  • Primeiro desenvolva a configuração de lista do projeto, utilizando RecyclerView, lista de dados e adapter;
  • Logo após adicione a referência da SelectionTracker no Gradle Nível de Aplicativo;
  • Então implemente uma versão da classe abstrata ItemKeyProvider;
  • Depois implemente uma versão da classe abstrata ItemDetailsLookup;
  • Assim implemente uma versão da ItemDetails, outra classe abstrata;
  • Implemente uma SelectionPredicate;
  • Atualize o código do adapter do RecyclerView para conter um objeto SelectionTracker e também o algoritmo de mudança de UI dos itens selecionados / não selecionados;
  • Inicialize um objeto SelectionTracker e vincule ele ao RecyclerView alvo.

Para lhe animar: apesar da extensa lista acima para uma única funcionalidade, a maioria dos códigos são boilerplate, somente têm de estar no projeto, não exigindo esforço de lógica.

O primeiro tópico da lista acima é implementado no projeto de exemplo na segunda parte do artigo. Nesta primeira parte vamos direto ao estudo da biblioteca SelectionTracker.

Classe de exemplo

Antes de partirmos para os códigos oficiais de SelectionTracker, vamos a uma simples classe de domínio que utilizaremos como referência.

Segue classe Car:

class Car(
val id: Long,
val model: String,
val brand: String )

Instalação da biblioteca

A SelectionTracker foi adicionada ao Android junto ao pacote AndroidX, mas nós também temos uma versão dela na Support Library, que é exatamente a que utilizaremos aqui enquanto ainda não temos um conteúdo aqui no Blog sobre a AndroidX.

No Gradle Nível de Aplicativo, build.gradle (Module: app), adicione:

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

 

Na época da construção deste conteúdo a versão estável mais atual era a 28.0.0.

Implementando a ItemKeyProvider

A classe abstrata ItemKeyProvider é a entidade que fornece acesso as chaves estáveis dos itens, acesso interno à biblioteca SelectionTracker.

Resumidamente: uma "chave estável" é um identificador que se mantém o mesmo independente da mudança de posição ou mudança de propriedade de um objeto.

Um exemplo simples de chave estável é o ID de um usuário em banco de dados. Podemos mudar alguns atributos do usuário, como a idade dele, mas o ID se manterá o mesmo.

Essas chaves de acesso, que são possíveis via implementação de uma ItemKeyProvider, são o primeiro ponto que permite que internamente a biblioteca SelectionTracker tenha acesso a todos os itens do RecyclerView.

Antes de partirmos direto para o código de uma implementação de ItemKeyProvider é importante saber que nossas chaves de seleção podem ser de algum dos três tipos a seguir: Parcelable (ou subclasses); String; e Long.

Long e String certamente serão os tipos mais utilizados por serem mais comuns como identificadores únicos de objetos. Mas caso o seu identificador único seja uma Uri, por exemplo, que é uma subclasse de Parcelable, então utilize o tipo Parcelable.

Segue implementação de ItemKeyProvider:

/*
* Subclasse de ItemKeyProvider fornece acesso a chaves de seleção
* estáveis, podendo ser de três tipos: Parcelable (e suas
* subclasses); String e Long.
* */
class CarKeyProvider( val cars: List<Car> ):
ItemKeyProvider<Long>( ItemKeyProvider.SCOPE_MAPPED ) {

/*
* Retorna a chave de seleção na posição do adaptador fornecida ou
* então retorna null.
* */
override fun getKey( position: Int )
= cars[position].id

/*
* Retorna a posição correspondente à chave de seleção, ou
* RecyclerView.NO_POSITION em caso de null em getKey().
* */
override fun getPosition( key: Long )
= cars.indexOf(
cars.filter{
car -> car.id == key
}.single()
)
}

 

Note que como "chave estável de seleção" foi utilizado o id do objeto Car, isso, pois sabemos que ele se manterá o mesmo independente da mudança ou reconstrução de objeto.

Há muitos exemplos da SelectionTracker onde a posição do item em lista é utilizada como chave de acesso. Não recomendo isso, pois em caso de remoção de item, por exemplo, as chaves de seleção de alguns itens são passíveis de serem alteradas, mesmo com o objeto sendo o mesmo.

A implementação de ItemKeyProvider exige a codificação dos métodos getKey() e getPosition() além da definição do escopo de acesso às chaves de seleção logo no construtor de ItemKeyProvider. O parâmetro cars é somente parte da lógica de negócio do exemplo.

São possíveis dois tipos de escopos:

  • ItemKeyProvider.SCOPE_CACHED: fornece acesso a dados armazenados em cache com base em itens que foram vinculados recentemente na exibição em tela. Esse escopo permiti ao provedor um acesso mais eficiente aos dados, mas somente àqueles em cache, tendo ainda mais limitações ante ao outro escopo;
  • ItemKeyProvider.SCOPE_MAPPED: comumente utilizado, fornece acesso a todos os dados, independentemente de estarem vinculados a uma exibição ou não. Permite seleção de intervalo, quando utilizando o mouse, algo não suportado por SCOPE_CACHED.

Em getKey() e em getPosition() é possível utilizar qualquer lógica de negócio, mas que retorne, respectivamente: a chave estável de seleção do item e a posição do item de acordo com a chave de seleção. Não há problemas se a posição mudar de acordo com a saída ou entrada de novos itens, o importante é que a chave estável de um item (objeto) se mantenha.

Implementando a ItemDetailsLookup

A classe abstrata ItemDetailsLookup permite que internamente a biblioteca SelectionTracker acesse informações dos itens do RecyclerView alvo, itens que receberam algum evento (toque, clique) que gera um objeto MotionEvent.

O parágrafo acima deve ter lhe deixado confuso, pois ele indica a função de ItemDetailsLookup como sendo muito similar a função de ItemKeyProvider, tirando somente a responsabilidade de gerencia das chaves estáveis de acesso.

Como acontece com ItemKeyProviderItemDetailsLookup é uma das classes obrigatórias que permitem, aos códigos internos da biblioteca SelectionTracker, acesso a informações dos itens do RecyclerView alvo.

A outra classe obrigatória que fecha a trinca, e que discutiremos na próxima seção, é a ItemDetails.

Segue implementação de ItemDetailsLookup:

/*
* ItemDetailsLookup permite que a biblioteca de seleção acesse
* informações sobre os itens do RecyclerView que receberam um
* MotionEvent. Ele é efetivamente uma factory para instâncias
* de ItemDetails que são submetidas a backup (ou extraídas)
* de uma ocorrência de RecyclerView.ViewHolder.
* */
class DetailsLookup( val rvList: RecyclerView ):
ItemDetailsLookup<Long>() {

/*
* Retorna o ItemDetails para o item sob o evento
* (MotionEvent) ou nulo caso não haja um.
* */
override fun getItemDetails( event: MotionEvent ): ItemDetails<Long>? {

val view = rvList.findChildViewUnder( event.x, event.y )

if( view != null ){
val holder = rvList.getChildViewHolder( view )

/*
* CarsAdapter é um adapter convencional vinculado ao
* RecyclerView alvo. O ViewHolder dele se chama
* CarHolder.
*
* O bloco if() é necessário somente se DetailsLookup
* estiver em um contexto onde mais de um ViewHolder
* estiver sendo utilizado.
* */
if( holder is CarsAdapter.CarHolder ){
return holder.itemDetails
}
}

return null
}
}

 

Note que a parametrização em ItemDetailsLookup tem de ser do mesmo tipo da chave de seleção escolhida e já definida em ItemKeyProvider. Em nosso caso: Long.

O algoritmo em getItemDetails() é o comumente utilizado para o fácil acesso ao objeto ItemDetails vinculado ao item acionado pelo usuário.

Ainda chegaremos ao código do adapter CarsAdapter para mostrar como ficará a propriedade itemDetails.

Implementando a ItemDetails

A classe abstrata ItemDetails fornece acesso às informações de um item específico do RecyclerView, acesso interno a biblioteca SelectionTracker.

Em resumo: a implementação de ItemDetails é o último ponto que permite a biblioteca SelectionTracker acesso aos itens do RecyclerView.

Segue implementação de ItemDetails:

/*
* Uma implementação de ItemDetails fornece à biblioteca de seleção
* acesso a informações sobre um item RecyclerView específico. Esta
* classe é um componente chave no controle dos comportamentos da
* biblioteca de seleção no contexto de uma atividade específica.
* */
class Details(
var car: Car? = null,
var adapterPosition: Int = -1
) : ItemDetailsLookup.ItemDetails<Long>() {

/*
* Retorna a posição do adaptador do item
* (ViewHolder.adapterPosition).
* */
override fun getPosition()
= adapterPosition

/*
* Retorna a entidade que é a chave de seleção do item.
* */
override fun getSelectionKey()
= car!!.id

/*
* Retorne "true" se o item tiver uma chave de seleção. Se true
* não for retornado o item em foco (acionado pelo usuário) não
* será selecionado.
*
* Aqui é possível colocar a lógica de negócio necessária para
* indicar quais itens podem ou não ser selecionados.
* */
override fun inSelectionHotspot( e: MotionEvent )
= true
}

 

Como obrigatório, somente as implementações de getPosition()getSelectionKey(). Mas se não for fornecida a implementação de inSelectionHotspot(), os itens não serão passíveis de seleção, pois por padrão o retorno é false.

Dentro dos métodos sobrescritos anteriormente, pode vir qualquer lógica de negócio, aqui colocamos a lógica que atende ao exemplo.

O construtor está com propriedades mutáveis que aceitam valores null e -1, pois objetos ItemDetails são reaproveitados dentro de instâncias ViewHolder do adapter do RecyclerView alvo. Essa é uma decisão de projeto, para não inflar a memória com inúmeros novos objetos ItemDetails.

Atualização da classe adaptadora

A classe adaptadora precisa ter:

  • Uma propriedade do tipo SelectionTracker para poder ser trabalhado os itens selecionados ou não selecionados;
  • Uma propriedade do tipo ItemDetails no ViewHolder, isso para ser possível passar informações de item aos códigos da biblioteca de seleção.

Primeiro a adição da propriedade selectionTracker:

class CarsAdapter( val cars: List<Car> ): 
RecyclerView.Adapter<CarsAdapter.CarHolder>() {

lateinit var selectionTracker: SelectionTracker<Long>
...
}

 

Agora a adição de Details, que implementa ItemDetails:

class CarsAdapter( val cars: List<Car> ):
RecyclerView.Adapter<CarsAdapter.CarHolder>() {

...
inner class CarHolder( itemView: View ):
RecyclerView.ViewHolder( itemView ) {

val itemDetails: Details
...

init {
itemDetails = Details()
...
}
...
}
}

 

Assim o algoritmo de atualização de item, incluindo a UI, que é invocado a partir do método onBindViewHolder():

class CarsAdapter( val cars: List<Car> ):
RecyclerView.Adapter<CarsAdapter.CarHolder>() {

...
override fun onBindViewHolder(
holder: CarHolder,
position: Int ) {

holder.setModel( cars[ position ], position )
}
...

inner class CarHolder( itemView: View ):
RecyclerView.ViewHolder( itemView ) {

...
fun setModel( car: Car, position: Int ){

tvModel.text = car.model
tvBrand.text = car.brand

/*
* São nos blocos condicionais a seguir que devem vir os
* algoritmos de atualização de UI, isso para indicar os
* itens selecionados e itens não selecionados. Seguindo
* as dicas da comunidade, sempre utilize o isActivated
* no View container de item.
* */
itemDetails.car = car
itemDetails.adapterPosition = adapterPosition

if( selectionTracker.isSelected( itemDetails.getSelectionKey() ) ){

itemView.setBackgroundColor( Color.YELLOW )
itemView.isActivated = true
}
else{
itemView.setBackgroundColor( Color.WHITE )
itemView.isActivated = false
}
}
}
}

 

Note que não há algoritmo pronto para a atualização da UI de qualquer item, isso é feito na "unha" no método invocado pelo onBindViewHolder().

O método onBindViewHolder() é invocado para todos os itens em tela e também quando algum deles é selecionado (ou deixa de ser selecionado) pelo usuário.

Importante: Devido ao vinculo de um objeto SelectionTracker ao RecyclerView, o ouvidor de clique adicionado (OnClickListener) somente funcionará se houver dois toques (cliques) rápidos no item, mas mesmo assim a biblioteca de seleção também será ativada. De qualquer forma, não é comum um contexto onde é possível trabalhar a seleção de itens e também alguma ação extra quando houver o toque / clique em algum dos itens.

Iniciando o objeto de seleção

O que ainda falta é a inicialização do objeto SelectionTracker e o vinculo dele ao adapter do RecyclerView alvo.

Aqui a inicialização ocorrerá em uma atividade, mas no projeto de exemplo será utilizado um fragmento. Em ambos os casos, a inicialização do objeto de seleção deve ocorrer depois que o RecyclerView já foi configurado junto ao adapter e ao LayoutManager escolhido.

Segue:

...
lateinit var selectionTracker: SelectionTracker<Long>

...
fun initSelectionTracker(){

selectionTracker = SelectionTracker.Builder<Long>(
"id-unico-do-objeto-de-selecao",
recyclerViewCars,
CarKeyProvider( carsList ),
DetailsLookup( recyclerViewCars ),
StorageStrategy.createLongStorage()
)
.build()

(recyclerViewCars.adapter as CarsAdapter).selectionTracker = selectionTracker
}
...

 

A parametrização de SelectionTracker.Builder tem que ser do tipo da chave estável definida em ItemKeyProvider.

Há três possíveis chamadas de estratégia de armazenamento de chave estável:

  • StorageStrategy.createLongStorage() para chaves do tipo Long;
  • StorageStrategy.createStringStorage() para chaves do tipo String;
  • StorageStrategy.createParcelableStorage( Class::class.java ) para chaves do tipo Parcelable.

Assim já podemos realizar a execução do projeto:

SelectionTracker em ação

Retendo os itens selecionados com o mesmo estado

Para que os itens se mantenham com o mesmo estado, selecionados ou não, em caso de reconstrução de atividade ou fragmento, basta vincular o objeto SelectionTracker ao onSaveInstanceState como a seguir:

...
override fun onCreate( savedInstanceState: Bundle? ) {
super.onCreate( savedInstanceState )
...

if( savedInstanceState != null ){
selectionTracker.onRestoreInstanceState( savedInstanceState )
}
}

override fun onSaveInstanceState( outState: Bundle? ) {
super.onSaveInstanceState( outState )
selectionTracker.onSaveInstanceState( outState!! )
}
...

 

Executando o projeto e rotacionando a tela, temos:

SelectionTracker com onRestoreInstanceState() em uso

No caso do fragmento seriam necessárias as seguintes configurações:

...
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {

/*
* Para manter o objeto do fragmento em memória. Propriedade
* nativa de Fragment.
* */
retainInstance = true

...
}


override fun onActivityCreated( savedInstanceState: Bundle? ) {
super.onActivityCreated( savedInstanceState )
...

if( savedInstanceState != null ){
selectionTracker.onRestoreInstanceState( savedInstanceState )
}
}

override fun onSaveInstanceState( outState: Bundle ) {
super.onSaveInstanceState( outState )
selectionTracker.onSaveInstanceState( outState )
}
...

 

No projeto de exemplo na segunda parte do artigo um objeto SelectionTracker será utilizado junto a um fragmento, incluindo as configurações necessárias para que o fragmento não seja sobrescrito por um novo na atividade host.

Definindo quais e quantos itens podem ser selecionados

Para a definição de quais e quantos itens podem ser selecionados, podemos utilizar uma implementação de SelectionTracker.SelectionPredicate.

A seguir a classe Predicate:

/*
* SelectionTracker.SelectionPredicate é utilizada para definir
* quais itens poderão ser selecionados e quantos deles.
*
* A parametrização deve ser do tipo da chave estável definida
* em ItemKeyProvider.
* */
class Predicate: SelectionTracker.SelectionPredicate<Long>() {

/*
* Retorne true se puder ter múltipla seleção e false para
* somente uma seleção.
* */
override fun canSelectMultiple()
= false

/*
* Retorne true se o item referente a key puder ser definido
* como nextState.
* */
override fun canSetStateForKey( key: Long, nextState: Boolean )
= if( key == 54.toLong() )
false
else
true

/*
* Retorne true se o item referente a position puder ser definido
* como nextState.
* */
override fun canSetStateAtPosition( position: Int, nextState: Boolean )
= if( position == 5 )
false
else
true
}

 

Quando realizando testes, o método canSetStateAtPosition() não é invocado em nenhum momento.

Agora a inserção de uma instância de Predicate na configuração de selectionTracker:

...
selectionTracker = SelectionTracker.Builder<Long>(
"id-unico-do-objeto-de-selecao",
recyclerViewCars,
CarKeyProvider( carsList ),
DetailsLookup( recyclerViewCars ),
StorageStrategy.createLongStorage()
)
.withSelectionPredicate( Predicate() )
.build()
...

 

Executando o projeto e tentando mais de uma seleção ou então tentando a seleção no item de ID 54 (Cargo, FIAT), temos:

SelectionTracker com SelectionPredicate ativo

Ouvidor de mudança de estado de item

É possível colocar um Observer junto a instância de SelectionTracker para ouvir às mudanças de estado dos itens:

...
selectionTracker.addObserver(
object : SelectionTracker.SelectionObserver<Long>(){

override fun onItemStateChanged(
key: Long,
selected: Boolean ) {
super.onItemStateChanged( key, selected )

val car = carsList.filter{ c -> c.id == key }.single()

/* TODO */
}

override fun onSelectionChanged() {
super.onSelectionChanged()
/* TODO */
}

override fun onSelectionRefresh() {
super.onSelectionRefresh()
/* TODO */
}

override fun onSelectionRestored() {
super.onSelectionRestored()
/* TODO */
}
}
)
...

 

A parametrização de SelectionObserver deve ser do tipo da chave estável definido em ItemKeyProvider.

Obtendo itens selecionados, selecionando e removendo seleção

Em código dinâmico é possível obter os itens selecionados:

...
val keysSelectedItems : Selection<Long> = selectionTracker.selection
...

 

Na verdade as chaves estáveis dos itens selecionados é que serão retornadas.

É possível selecionar e remover seleção de itens pelas chaves estáveis deles:

...
val ids = mutableListOf<Long>( 25L, 896L, 478L )

/*
* true para seleção e false para remoção de seleção.
* */
selectionTracker.setItemsSelected( ids, true )
...

 

Uma outra maneira de remover todas as seleções é com o método:

...
selectionTracker.clearSelection()
...

 

Os métodos select() e deselect() de SelectionTracker também permitem a seleção e remoção de seleção.

Pontos negativos

  • Realmente toda a exigência de classes em heranças poderia ser reduzida em métodos de um único SelectionTracker.Builder;
  • O método canSetStateAtPosition() de SelectionPredicate, até momento da construção deste artigo, não estava funcional;
  • Não há um método de remoção do objeto SelectionTracker junto ao RecyclerView alvo, algo que complica o trabalho com o SelectionTracker em delay, quando ele não é vinculado ao RecyclerView logo na configuração deste framework de lista;
  • A adição ou remoção de itens ao RecyclerView pode causar uma Exception caso o notifyDataSetChanged() não seja invocado logo depois da adição ou remoção e do clearSelection(). Isso, pois internamente na SelectionTracker a atualização de chaves e posições está ocorrendo somente depois do notifyDataSetChanged() ser invocado, algo não informado em documentação.

Pontos positivos

  • A configuração para manter o estado dos itens, selecionados ou não, é muito simples;
  • A biblioteca permite que sejam definidas "n" maneiras de seleção de item e não somente por meio do toque ou clique;
  • É simples limitar quais itens podem ou não ser selecionados, incluindo a limitação da quantidade de itens.

Considerações finais

Obviamente que existem inúmeros algoritmos e APIs de terceiros que permitem a fácil seleção de itens em RecyclerView, mas com a biblioteca SelectionTracker, mesmo que não de maneira simples, temos muitas opções somente para a seleção de itens em RecyclerView, algo que a deixa como uma API completa.

Este é o primeiro ano da biblioteca, ainda há muitos problemas, principalmente de interface pública.

Algumas classes podem ser removidas no cenário de "necessidade de implementação", a ItemDetails é uma delas, pode se tornar mais um método em SelectionTracker.Builder.

A API já é passível de ser utilizada em produção, segundo os testes que realizei. E agora é aguardar a evolução dela.

Há outras possibilidades com a SelectionTracker, possibilidades que serão abordadas em futuros artigos aqui do Blog.

Projeto Android

Para projeto de exemplo vamos a adição da funcionalidade de remoção de itens em uma lista de favoritos de um aplicativo de filmes.

O projeto vai ser desenvolvido em duas partes:

  • Na primeira vamos a construção da tela de favoritos sem a funcionalidade de seleção e remoção;
  • Na parte dois adicionaremos a funcionalidade de remover filmes de favoritos.

Você pode acessar o projeto completo no GitHub: https://github.com/viniciusthiengo/thiengos-flix-kotlin-android.

Mesmo com o projeto no GitHub, não deixe de acompanha-lo em artigo, pois ainda mais coisas serão acrescentadas sobre o estudo da biblioteca SelectionTracker.

Protótipo estático

A seguir as telas do protótipo estático do projeto:

Tela de entrada

Tela de entrada

Listagem de filmes favoritos

Listagem de filmes favoritos

Menu gaveta aberto

 Menu gaveta aberto

Favoritos selecionados para remoção

 Favoritos selecionados para remoção

Favoritos selecionados removidos

Favoritos selecionados removidos

 


Iniciando o projeto

Em seu Android Studio inicie um novo projeto Kotlin:

  • Nome da aplicação: ThiengosFlix;
  • API mínima: 16 (Android Jelly Bean);
  • Atividade inicial: Navigation Drawer Activity;
  • Nome da atividade inicial: MainActivity;
  • Para todos os outros campos, mantenha os valores já definidos por padrão.

Ao final desta primeira parte teremos a seguinte arquitetura no Android Studio:

Arquitetura Android Studio do projeto ThiengosFlix

Configurações Gradle

Abaixo as configurações do Gradle Nível de Projeto, ou build.gradle (Project: ThiengosFlix):

buildscript {
ext.kotlin_version = '1.3.0'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}

allprojects {
repositories {
google()
jcenter()
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

 

Então as configurações iniciais do Gradle Nível de Aplicativo, ou build.gradle (Module: app):

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
compileSdkVersion 28
defaultConfig {
applicationId "thiengo.com.br.thiengosflix"
minSdkVersion 16
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"

implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support:design:28.0.0'
implementation 'com.android.support:support-v4:28.0.0'

implementation 'com.squareup.picasso:picasso:2.71828'
}

Configurações AndroidManifest

Abaixo as configurações do AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="thiengo.com.br.thiengosflix">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:hardwareAccelerated="true"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">

<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar">

<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

Configurações de estilo

Iniciando com o arquivo de definição de cores, /res/values/colors.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#000000</color>
<color name="colorPrimaryDark">#000000</color>
<color name="colorAccent">#FF0000</color>

<color name="colorItemNormal">#C0CCDA</color>

<color name="colorRedTransparent">#88FF0000</color>

<color name="colorItemBackground">#222222</color>
<color name="colorItemText">#999999</color>
<color name="colorItemStar">#F4C54F</color>
<color name="colorItemCategory">#77D353</color>

<color name="colorItemSelectedBackground">#F95F62</color>
<color name="colorItemSelectedText">#333333</color>
</resources>

 

Agora o arquivo de dimensões, /res/values/dimens.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>

<dimen name="nav_header_vertical_spacing">8dp</dimen>
<dimen name="nav_header_height">176dp</dimen>

<dimen name="fab_margin">16dp</dimen>

<dimen name="item_text_size">14sp</dimen>
<dimen name="item_star_size">17dp</dimen>
<dimen name="item_star_padding_right">4dp</dimen>
</resources>

 

Então o arquivo de Strings, /res/values/strings.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ThiengosFlix</string>

<string name="frag_favorites">Favoritos</string>

<string name="navigation_drawer_open">Abrir menu gaveta</string>
<string name="navigation_drawer_close">Fechar menu gaveta</string>

<string name="nav_header_title">ThiengosFlix</string>
<string name="nav_header_subtitle">Os filmes mais atuais em seu smart</string>
<string name="nav_header_desc">Navigation header</string>

<string name="action_settings">Buscar</string>

<string name="item_label_launch">Lançamentos</string>
<string name="item_label_more_views">Mais visualizados</string>
<string name="item_label_week_recommended">Recomendados da semana</string>
<string name="item_label_categories">Categorias</string>
<string name="item_label_directors">Diretores</string>
<string name="item_label_actors_actress">Atores / atrizes</string>
<string name="item_label_favorites">Favoritos</string>

<string name="iv_cd_star_01">Primeira estrela da avaliação</string>
<string name="iv_cd_star_02">Segunda estrela da avaliação</string>
<string name="iv_cd_star_03">Terceira estrela da avaliação</string>
<string name="iv_cd_star_04">Quarta estrela da avaliação</string>
<string name="iv_cd_star_05">Quinta estrela da avaliação</string>

<string name="favorites_removed_movie">
Filme removido com sucesso da lista de Favoritos
</string>
<string name="favorites_removed_movies">
Filmes removidos com sucesso da lista de Favoritos
</string>
</resources>

 

Assim o arquivo de definição de tema em todo o aplicativo, /res/values/styles.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>

<!--
Estilo padrão, aplicado em todo o projeto.
-->
<style
name="AppTheme"
parent="Theme.AppCompat.Light.DarkActionBar">

<item name="android:windowBackground">@drawable/background</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>

<!--
Para que a barra de topo padrão não seja utilizada e
assim somente o AppBarLayout junto ao Toolbar possam ser
usados.
-->
<style name="AppTheme.NoActionBar">

<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>

<!--
Para o correto enquadramento do AppBarLayout.
-->
<style
name="AppTheme.AppBarOverlay"
parent="ThemeOverlay.AppCompat.Dark.ActionBar"/>

<!--
Utilizado para a correta apresentação de menus de pop-up
em barra de topo.
-->
<style
name="AppTheme.PopupOverlay"
parent="ThemeOverlay.AppCompat.Light"/>
</resources>

 

E por fim o arquivo de tema com algumas características específicas para aparelhos com o Android 21, Lollipop, ou superior. Segue /res/values-v21/styles.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>

<!--
Para que a barra de topo padrão não seja utilizada e
assim somente o AppBarLayout junto ao Toolbar possam ser
usados. Somando a isso a aplicação de transparência na
statusBar.
-->
<style name="AppTheme.NoActionBar">

<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>

Classes de domínio

Teremos duas classes de domínio no pacote /domain. Abaixo o código da classe Rating, responsável por conter os dados de avaliação de filme:

class Rating( 
val stars: Float,
val amount: Int )

 

Assim a classe Movie:

class Movie(
val id: Int,
val title: String,
val urlBanner: String,
val directors: String,
val rating: Rating,
val category: String,
val resume: String )

Base de dados simulados

Como fonte de dados utilizaremos uma base mock, dados simulados.

No pacote /data adicione a classe Database como a seguir:

class Database {
companion object {
fun getMovies()
= mutableListOf(
Movie(
54,
"Capitã Marvel",
"https://www.cineriado.com.br/wp-content/uploads/2018/09/captain-marvel-1134387-1280x0.jpeg",
"Anna Boden, Ryan Fleck",
Rating(4.5F, 177),
"Fantasia / aventura",
"Captain Marvel é um futuro filme Norte americano " +
"de super-herói de 2019, baseado na personagem " +
"de mesmo nome da Marvel Comics, produzido pela " +
"Marvel Studios e distribuído pela Walt Disney " +
"Studios Motion Pictures, sendo o vigésimo " +
"primeiro filme do Universo Cinematográfico Marvel."
),
Movie(
7758,
"Vingadores 4",
"https://observatoriodocinema.bol.uol.com.br/wp-content/uploads/2018/09/Vingadores-Guerra-Infinita.png",
"Anthony Russo, Joe Russo",
Rating(4F, 82),
"Fantasia / ficção científica",
"Avengers 4 [1] é um futuro filme estadunidense de " +
"super-herói de 2019, baseado na equipe Os " +
"Vingadores da Marvel Comics, produzido pela " +
"Marvel Studios e distribuído pela Walt Disney " +
"Studios Motion Pictures, sendo a sequência de " +
"Marvel's The Avengers (2012), Avengers: Age " +
"of Ultron (2015), e Avengers: Infinity War " +
"(2018), e o vigésimo segundo filme do Universo " +
"Cinematográfico Marvel."
),
Movie(
163,
"Vidro",
"https://i.ytimg.com/vi/9zNINvh0eMc/maxresdefault.jpg",
"M. Night Shyamalan",
Rating(5F, 214),
"Fantasia / mistério",
"Glass é um futuro filme americano escrito, " +
"co-produzido e dirigido por M. Night Shyamalan.O " +
"filme destina-se a ser a terceira e última parte " +
"do que foi referido como a trilogia Eastrail 177, " +
"que inclui Unbreakable e Split."
),
Movie(
5579,
"Homem-Aranha: De Volta ao Lar 2",
"https://i.ytimg.com/vi/_H5Vcqk0URM/maxresdefault.jpg",
"Jon Watts",
Rating(4.5F, 32),
"Fantasia / ficção científica",
"Spider-Man: Far From Home é um futuro filme " +
"estadunidense de ação, comédia, aventura e ficção " +
"científica dirigido por Jon Watts, e é escrito por " +
"Chris McKenna e Erik Sommers. É produzido pela " +
"Marvel Studios e Columbia Pictures e é distribuído " +
"pela Sony."
)
)
}
}

Adaptador de favoritos

Como o RecyclerView está vinculado ao fragmento de favoritos, vamos primeiro à apresentação do MoviesAdapter.

Iniciando pelo layout de item, /res/layout/movie_item.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/item_background"
android:padding="8dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp">

<ImageView
android:id="@+id/iv_banner"
android:layout_width="116dp"
android:layout_height="172dp"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_marginRight="12dp"
android:layout_marginEnd="12dp"
android:scaleType="centerCrop" />

<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="18sp"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/iv_banner"
android:layout_toEndOf="@+id/iv_banner"
android:layout_marginBottom="8dp"
android:maxLines="1"
android:ellipsize="end" />

<TextView
android:id="@+id/tv_directors"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorItemText"
android:textSize="@dimen/item_text_size"
android:layout_below="@+id/tv_title"
android:layout_alignLeft="@+id/tv_title"
android:layout_alignStart="@+id/tv_title"
android:layout_marginBottom="9dp" />

<ImageView
android:id="@+id/iv_star_01"
android:layout_width="@dimen/item_star_size"
android:layout_height="@dimen/item_star_size"
android:layout_below="@+id/tv_directors"
android:layout_alignLeft="@+id/tv_title"
android:layout_alignStart="@+id/tv_title"
android:layout_marginRight="@dimen/item_star_padding_right"
android:layout_marginEnd="@dimen/item_star_padding_right"
android:contentDescription="@string/iv_cd_star_01"
android:scaleType="fitCenter"
android:tint="@color/colorItemStar"
android:layout_marginBottom="9dp"/>
<ImageView
android:id="@+id/iv_star_02"
android:layout_width="@dimen/item_star_size"
android:layout_height="@dimen/item_star_size"
android:layout_alignTop="@+id/iv_star_01"
android:layout_toRightOf="@+id/iv_star_01"
android:layout_toEndOf="@+id/iv_star_01"
android:layout_marginRight="@dimen/item_star_padding_right"
android:layout_marginEnd="@dimen/item_star_padding_right"
android:contentDescription="@string/iv_cd_star_02"
android:scaleType="fitCenter"
android:tint="@color/colorItemStar"/>
<ImageView
android:id="@+id/iv_star_03"
android:layout_width="@dimen/item_star_size"
android:layout_height="@dimen/item_star_size"
android:layout_alignTop="@+id/iv_star_02"
android:layout_toRightOf="@+id/iv_star_02"
android:layout_toEndOf="@+id/iv_star_02"
android:layout_marginRight="@dimen/item_star_padding_right"
android:layout_marginEnd="@dimen/item_star_padding_right"
android:contentDescription="@string/iv_cd_star_03"
android:scaleType="fitCenter"
android:tint="@color/colorItemStar"/>
<ImageView
android:id="@+id/iv_star_04"
android:layout_width="@dimen/item_star_size"
android:layout_height="@dimen/item_star_size"
android:layout_alignTop="@+id/iv_star_03"
android:layout_toRightOf="@+id/iv_star_03"
android:layout_toEndOf="@+id/iv_star_03"
android:layout_marginRight="@dimen/item_star_padding_right"
android:layout_marginEnd="@dimen/item_star_padding_right"
android:contentDescription="@string/iv_cd_star_04"
android:scaleType="fitCenter"
android:tint="@color/colorItemStar"/>
<ImageView
android:id="@+id/iv_star_05"
android:layout_width="@dimen/item_star_size"
android:layout_height="@dimen/item_star_size"
android:layout_alignTop="@+id/iv_star_04"
android:layout_toRightOf="@+id/iv_star_04"
android:layout_toEndOf="@+id/iv_star_04"
android:layout_marginRight="5dp"
android:layout_marginEnd="5dp"
android:contentDescription="@string/iv_cd_star_05"
android:scaleType="fitCenter"
android:tint="@color/colorItemStar"/>

<TextView
android:id="@+id/tv_rating_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorItemText"
android:textSize="@dimen/item_text_size"
android:layout_marginTop="-2dp"
android:layout_alignTop="@+id/iv_star_05"
android:layout_toRightOf="@+id/iv_star_05"
android:layout_toEndOf="@+id/iv_star_05" />

<TextView
android:id="@+id/tv_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorItemCategory"
android:textSize="@dimen/item_text_size"
android:layout_toRightOf="@+id/tv_rating_amount"
android:layout_toEndOf="@+id/tv_rating_amount"
android:layout_marginLeft="12dp"
android:layout_marginStart="12dp"
android:maxLines="1"
android:ellipsize="end"
android:layout_alignTop="@+id/tv_rating_amount"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"/>

<TextView
android:id="@+id/tv_resume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorItemText"
android:textSize="@dimen/item_text_size"
android:layout_below="@+id/iv_star_01"
android:layout_alignLeft="@+id/tv_title"
android:layout_alignStart="@+id/tv_title"
android:maxLines="5"
android:ellipsize="end"/>
</RelativeLayout>

 

Antes de irmos ao diagrama do layout anterior, vamos ao arquivo drawable que permite o background escuro com os cantos arredondados. Segue /res/drawable/item_background.xml:

<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">

<!-- Curvatura das pontas do shape retangular. -->
<corners android:radius="4dp" />

<!-- Cor de background. -->
<solid android:color="@color/colorItemBackground" />
</shape>

 

Com o drawable anterior conseguimos o seguinte resultado como shape de item:

Item RecyclerView com bordas arredondados e fundo escuro

A seguir o diagrama do layout movie_item.xml:

Diagrama do layout movie_item.xml

Então o código inicial do adapter MoviesAdapter, código Kotlin:

class MoviesAdapter( val movies: List<Movie> ):
RecyclerView.Adapter<MoviesAdapter.ViewHolder>() {

override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ViewHolder {

val layout = LayoutInflater
.from( parent.context )
.inflate(
R.layout.movie_item,
parent,
false
)

return ViewHolder( layout )
}

override fun getItemCount() = movies.size

override fun onBindViewHolder(
holder: ViewHolder,
position: Int )
{

holder.setModel( movies[ position ] )
}

inner class ViewHolder( itemView: View):
RecyclerView.ViewHolder( itemView ) {

val ivBanner : ImageView

val tvTitle : TextView
val tvDirectors : TextView

val ivStar_01 : ImageView
val ivStar_02 : ImageView
val ivStar_03 : ImageView
val ivStar_04 : ImageView
val ivStar_05 : ImageView

val tvRatingAmount: TextView
val tvCategory: TextView
val tvResume: TextView

init{
ivBanner = itemView.findViewById( R.id.iv_banner )

tvTitle = itemView.findViewById( R.id.tv_title )
tvDirectors = itemView.findViewById( R.id.tv_directors )

ivStar_01 = itemView.findViewById( R.id.iv_star_01 )
ivStar_02 = itemView.findViewById( R.id.iv_star_02 )
ivStar_03 = itemView.findViewById( R.id.iv_star_03 )
ivStar_04 = itemView.findViewById( R.id.iv_star_04 )
ivStar_05 = itemView.findViewById( R.id.iv_star_05 )

tvRatingAmount = itemView.findViewById( R.id.tv_rating_amount )
tvCategory = itemView.findViewById( R.id.tv_category )
tvResume = itemView.findViewById( R.id.tv_resume )
}

fun setModel( movie: Movie ){

ivBanner.contentDescription = movie.title
Picasso
.get()
.load( movie.urlBanner )
.placeholder( R.drawable.ic_load_image )
.error( R.drawable.ic_image_broken )
.into( ivBanner )

tvTitle.text = movie.title
tvDirectors.text = movie.directors

setRating( movie.rating )

tvCategory.text = movie.category
tvResume.text = movie.resume
}

private fun setRating( rating: Rating ){
tvRatingAmount.text = String.format( "(%d)", rating.amount )

setRatingStar( rating.stars, ivStar_01, 1 )
setRatingStar( rating.stars, ivStar_02, 2 )
setRatingStar( rating.stars, ivStar_03, 3 )
setRatingStar( rating.stars, ivStar_04, 4 )
setRatingStar( rating.stars, ivStar_05, 5 )
}

/*
* Método responsável por colocar a imagem de estrela
* correta em cada ImageView de estrela de avaliação.
* */
private fun setRatingStar( rating: Float, star: ImageView, position: Int ){

val idimage = if( rating >= position.toFloat() )
R.drawable.ic_star_full /* Estrela preenchida. */
else if( rating < position.toFloat() && Math.ceil( rating.toDouble() ) == position.toDouble() )
R.drawable.ic_star_half /* Estrela com metade preenchida. */
else
R.drawable.ic_star_empty /* Estrela vazia. */

star.setImageResource( idimage )
}
}
}

 

MoviesAdapter fica na raiz do projeto, como a atividade principal e fragmento de favoritos.

Fragmento de favoritos

Para o fragmento que contém o framework de lista, vamos iniciar com o layout, /res/layout/fragment_favorites.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rv_movies"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FavoritesFragment" />

 

Então o código Kotlin de FavoritesFragment:

class FavoritesFragment : Fragment() {

companion object {
const val KEY = "favorites-fragment"
}

val movies = Database.getMovies()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {

return inflater
.inflate(
R.layout.fragment_favorites,
container,
false
)
}

override fun onActivityCreated( savedInstanceState: Bundle? ) {
super.onActivityCreated( savedInstanceState )

rv_movies.setHasFixedSize( true )

val layoutManager = LinearLayoutManager( activity )
rv_movies.layoutManager = layoutManager

rv_movies.adapter = MoviesAdapter( movies )
}

/*
* Método responsável por conter o algoritmo que invoca
* o código de atualização de título da atividade.
* */
override fun onResume() {
super.onResume()
(activity as MainActivity)
.updateActivityTitle( getString( R.string.frag_favorites ) )
}
}

Atividade principal

Para a MainActivity vamos iniciar com o layout de conteúdo, /res/layout/app_bar_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimary"
tools:context=".MainActivity">

<android.support.design.widget.AppBarLayout
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:theme="@style/AppTheme.AppBarOverlay">

<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:titleTextColor="@color/colorAccent"
app:popupTheme="@style/AppTheme.PopupOverlay"/>

</android.support.design.widget.AppBarLayout>

<FrameLayout
android:id="@+id/fl_frag_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />

<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
app:srcCompat="@drawable/ic_remove"/>

</android.support.design.widget.CoordinatorLayout>

 

Abaixo o diagrama do layout anterior:

Diagrama do layout app_bar_main.xml

Assim o layout principal que contém também o layout anterior. Segue /res/layout/activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start">

<include
layout="@layout/app_bar_main"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

<android.support.design.widget.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
android:background="@color/colorPrimary"
app:itemTextColor="@drawable/side_nav_text_color"
app:headerLayout="@layout/nav_header_main"
app:menu="@menu/activity_favorites_drawer"/>

</android.support.v4.widget.DrawerLayout>

 

Abaixo o arquivo drawable responsável por manter a seleção de item menu gaveta como indicado em protótipo estático. Segue /res/drawable/side_nav_text_color.xml:

<?xml version="1.0" encoding="utf-8"?>
<selector
xmlns:android="http://schemas.android.com/apk/res/android">

<!-- Estado "Selecionado" -->
<item
android:color="@android:color/white"
android:state_checked="true" />

<!-- Estado "Pressionado" -->
<item
android:color="@android:color/white"
android:state_pressed="true" />

<!-- Estado "Normal", não selecionado -->
<item android:color="@color/colorItemNormal" />
</selector>

 

Com o drawable acima conseguimos o seguinte resultado nos itens de menu gaveta:

Itens de menu gaveta do app ThiengoFlix

Agora o layout de cabeçalho do menu gaveta, /res/layout/nav_header_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/nav_header_height"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:theme="@style/ThemeOverlay.AppCompat.Dark"
android:orientation="vertical"
android:gravity="bottom">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/colorAccent"
android:text="@string/nav_header_title"
android:textSize="26sp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:text="@string/nav_header_subtitle"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>

 

Então o código menu dos itens do NavigationView. Segue /res/menu/activity_favorites_drawer.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:showIn="navigation_view">

<group android:checkableBehavior="single">
<item
android:id="@+id/nav_launch"
android:title="@string/item_label_launch"/>
<item
android:id="@+id/nav_more_views"
android:title="@string/item_label_more_views"/>
<item
android:id="@+id/nav_week_recommended"
android:title="@string/item_label_week_recommended"/>
<item
android:id="@+id/nav_categories"
android:title="@string/item_label_categories"/>
<item
android:id="@+id/nav_directors"
android:title="@string/item_label_directors"/>
<item
android:id="@+id/nav_actors_actress"
android:title="@string/item_label_actors_actress"/>
<item
android:checked="true"
android:id="@+id/nav_favorites"
android:title="@string/item_label_favorites"/>
</group>
</menu>

 

Agora o simples menu de topo utilizado para ter um "fake" search icon. Segue /res/menu/main.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">

<item
android:icon="@drawable/ic_magnify_white_24dp"
android:title="@string/action_settings"
android:orderInCategory="100"
app:showAsAction="always"/>
</menu>

 

Então o diagrama do layout activity_main.xml:

Diagrama do layout activity_main.xml

Por fim o código Kotlin inicial da MainActivity:

class MainActivity :
AppCompatActivity(),
NavigationView.OnNavigationItemSelectedListener {

override fun onCreate( savedInstanceState: Bundle? ) {
super.onCreate( savedInstanceState )
setContentView( R.layout.activity_main )
setSupportActionBar( toolbar )

val toggle = ActionBarDrawerToggle(
this,
drawer_layout,
toolbar,
R.string.navigation_drawer_open,
R.string.navigation_drawer_close
)
drawer_layout.addDrawerListener( toggle )
toggle.syncState()

nav_view.setNavigationItemSelectedListener( this )

callFavoritesFragment()

/*
* Certificando de que desde a abertura do app o item
* de menu gaveta selecionado (Favoritos) estará com
* a formatação correta.
* */
setBoldNavigationViewItemSelected( R.id.nav_favorites )
}

override fun onBackPressed() {
if( drawer_layout.isDrawerOpen( GravityCompat.START ) ){
drawer_layout.closeDrawer( GravityCompat.START )
}
else{
super.onBackPressed()
}
}

override fun onCreateOptionsMenu( menu: Menu ): Boolean {
menuInflater.inflate( R.menu.main, menu )
return true
}

override fun onNavigationItemSelected( item: MenuItem ): Boolean {
when( item.itemId ){
R.id.nav_favorites -> {
callFavoritesFragment()
}
}

drawer_layout.closeDrawer( GravityCompat.START )

/*
* Caso você queira reaproveitar o código com alguns
* outros itens de menu passando pela mesma formatação
* de texto, então descomente a linha abaixo.
* */
/*setBoldNavigationViewItemSelected( item.itemId ) */

/*
* Retornar false para manter sempre a tela de favoritos
* do aplicativo de exemplo.
* */
return false
}

private fun callFavoritesFragment(){
var fragment = supportFragmentManager.findFragmentByTag( FavoritesFragment.KEY )

if( fragment == null ){
fragment = FavoritesFragment()

val transaction = supportFragmentManager.beginTransaction()
transaction.replace( R.id.fl_frag_container, fragment, FavoritesFragment.KEY )
transaction.commit()
}
}

/*
* Método necessário para colocar o item de menu gaveta
* selecionado com uma formatação bold, negrito.
* */
private fun setBoldNavigationViewItemSelected( selectedItemId: Int ){
val menu = nav_view.menu

for( i in 0..(menu.size() - 1) ){
val item = menu.getItem( i )

if( item.itemId == selectedItemId ){
/*
* Se for o item selecionado, coloque ele com
* negrito, utilizando SpannableString.
* */

val textItem = SpannableString( item.title )

textItem.setSpan(
StyleSpan( Typeface.BOLD ),
0,
textItem.length,
Spannable.SPAN_INCLUSIVE_INCLUSIVE )

item.title = textItem
}
else{
/*
* Basta um toString() para remover a formatação
* Spannable da String.
* */
item.title = item.title.toString()
}
}
}

/*
* Método responsável por conter o código de atualização
* de título da atividade. Seria invocado em todos os
* fragmentos relacionados aos itens de menu gaveta.
* */
fun updateActivityTitle( title: String ){
toolbar.title = title
}
}

Colocando a funcionalidade de seleção e remoção de favoritos

Até aqui temos uma tela de favoritos que ainda não permite a remoção de itens:

App ThiengosFlix sem funcionalidade de seleção e remoção

A funcionalidade de remoção de favoritos prosseguirá como no fluxograma a seguir, utilizando a biblioteca SelectionTracker:

Fluxograma da atualização do Android ThiengosFlix

Configurando a biblioteca

No Gradle Nível de Aplicativo, build.gradle (Module: app), coloque a referência a seguir em destaque e sincronize o projeto:

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

Definindo a chave estável de seleção

Nossa principal classe de domínio tem a propriedade id, que apesar de ser um Int é a melhor entidade para se tornar a chave estável para a configuração da SelectionTracker.

Isso, pois id é único para cada objeto Movie, independente da atualização que algum deles vier a sofrer.

A conversão de Int para Long, no Kotlin, é bem simples, basta invocar id.toLong(). Sendo assim vamos criar a classe que extende ItemKeyProvider com o tipo de chave sendo Long.

Antes de prosseguir, crie um novo pacote na raiz do projeto, /tracker.

Neste novo pacote adicione a classe MovieKeyProvider:

class MovieKeyProvider( val movies: List<Movie> )
: ItemKeyProvider<Long>( ItemKeyProvider.SCOPE_MAPPED ) {

/*
* Retornar a chave estável do item na posição informada.
* Em nosso caso a chave estável é o ID do filme, pois
* independente das atualizações necessárias no objeto
* o identificador único dele se manterá o mesmo.
* */
override fun getKey( position: Int )
= movies[ position ].id.toLong()

/*
* Retornar a posição do item de acordo com a chave
* estável informada como parâmetro.
* */
override fun getPosition( key: Long )
= movies
.indexOf(
movies.filter {
movie -> movie.id.toLong() == key
}
.single()
)
}

 

Em filter() o método single() foi necessário, pois ele força o retorno do primeiro item da lista de resultado de filter(). O método indexOf() aguarda um objeto Movie e não uma lista.

MovieDetails, container de objetos Movie

Antes de irmos à classe que herda de ItemDetailsLookup, vamos a construção da classe que permitirá a biblioteca de seleção acessar os dados de cada item do RecyclerView.

Ainda no pacote /tracker adicione a classe MovieDetails:

class MovieDetails(
var movie: Movie? = null,
var adapterPosition: Int = -1 ) : ItemDetailsLookup.ItemDetails<Long>() {

override fun getSelectionKey()
= movie!!.id.toLong()

override fun getPosition()
= adapterPosition

override fun inSelectionHotspot( e: MotionEvent )
= true
}

 

Como informado na primeira parte do artigo: objetos do tipo ItemDetails ficam dentro do ViewHolder do adapter do RecyclerView, logo é inteligente permitir que todas as propriedades sejam mutáveis para permitir o reaproveitamento de objetos.

Por isso movie e adapterPosition são multáveis e além de tudo têm valores pré-definidos, isso para permitir uma inicialização em código sem exigir argumentos não utilizáveis.

Colocando MovieDetails em MoviesAdapter

Mais precisamente em MoviesAdapter.ViewHolder adicione os códigos em destaque:

...
inner class ViewHolder( itemView: View):
RecyclerView.ViewHolder( itemView ) {
...

val movieDetails: MovieDetails

init{
...

movieDetails = MovieDetails()
}

fun setModel( movie: Movie, position: Int ){
...

movieDetails.movie = movie
movieDetails.adapterPosition = position
}
...
}
...

Desenvolvendo a MovieLookup

Ainda no pacote /tracker adicione a classe MovieLookup, classe responsável por permitir que a biblioteca de seleção acesse todos os itens do RecyclerView alvo:

class MovieLookup( val rvMovies: RecyclerView )
: ItemDetailsLookup<Long>() {

override fun getItemDetails( event: MotionEvent ): ItemDetails<Long>? {

val view = rvMovies.findChildViewUnder( event.x, event.y )

if( view != null ){
val holder = rvMovies.getChildViewHolder( view )

return (holder as MoviesAdapter.ViewHolder).movieDetails
}

return null
}
}

Arquivo e métodos de item selecionado / não selecionado

A seguir a imagem de protótipo contendo um item selecionado e um item não selecionado:

Item selecionado e item não selecionado

Antes de criamos os métodos que contém essas configurações de item, temos que primeiro criar um novo arquivo drawable para o background de item selecionado.

Em /res/drawable adicione o arquivo item_selected_background.xml:

<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">

<!-- Curvatura das pontas do shape retangular. -->
<corners android:radius="4dp" />

<!-- Cor de background. -->
<solid android:color="@color/colorItemSelectedBackground" />
</shape>

 

Note que todas as Strings e cores foram já colocadas em projeto na primeira parte de desenvolvimento do aplicativo de exemplo.

Assim, em MoviesAdapter.ViewHolder adicione os dois métodos a seguir:

...
inner class ViewHolder( itemView: View):
RecyclerView.ViewHolder( itemView ) {

...
private fun setUIItemSelected(){
itemView.setBackgroundResource( R.drawable.item_selected_background )

tvCategory.setTextColor( Color.YELLOW )

val textColor = ContextCompat
.getColor(
itemView.context,
R.color.colorItemSelectedText
)

tvDirectors.setTextColor( textColor )
tvRatingAmount.setTextColor( textColor )
tvResume.setTextColor( textColor )
}

private fun setUIItemNotSelected(){
itemView.setBackgroundResource( R.drawable.item_background )

tvCategory.setTextColor(
ContextCompat
.getColor(
itemView.context,
R.color.colorItemCategory
)
)

val textColor = ContextCompat
.getColor(
itemView.context,
R.color.colorItemText
)

tvDirectors.setTextColor( textColor )
tvRatingAmount.setTextColor( textColor )
tvResume.setTextColor( textColor )
}
}
...

MoviePredicate para comportamento de seleção

A classe que herda de SelectionTracker.SelectionPredicate estará presente para informar em código que todos os itens são selecionáveis e ainda no modelo de múltipla seleção.

No pacote /tracker adicione a classe MoviePredicate:

class MoviePredicate: SelectionTracker.SelectionPredicate<Long>() {

override fun canSelectMultiple()
= true

override fun canSetStateForKey( key: Long, nextStatus: Boolean )
= true

override fun canSetStateAtPosition(p0: Int, p1: Boolean)
= true
}

Inicialização da SelectionTracker

No fragmento FavoritesFragment adicione os códigos em destaque:

class FavoritesFragment : Fragment() {

companion object {
const val KEY = "favorites-fragment"
const val SELECTION_TRACKER_KEY = "selection-tracker-movie"
}

val movies = Database.getMovies()
lateinit var selectionTracker: SelectionTracker<Long>

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {

/*
* Para manter o objeto do fragmento em memória.
* */
retainInstance = true

...
}

override fun onActivityCreated( savedInstanceState: Bundle? ) {
...

configSelectionTracker( savedInstanceState )
}

/*
* Invoque este método de configuração do SelectionTracker
* somente depois de ter já finalizada a configuração do
* RecyclerView.
* */
private fun configSelectionTracker( savedInstanceState: Bundle? ){

selectionTracker = SelectionTracker.Builder<Long>(
SELECTION_TRACKER_KEY,
rv_movies,
MovieKeyProvider( movies ),
MovieLookup( rv_movies ),
StorageStrategy.createLongStorage()
)
.withSelectionPredicate( MoviePredicate() )
.build()

(rv_movies.adapter as MoviesAdapter).selectionTracker = selectionTracker

/*
* Parte da configuração para reter o estado da lista
* marcada caso haja reconstrução de atividade / fragmento.
* */
if( savedInstanceState != null ){
selectionTracker.onRestoreInstanceState( savedInstanceState )
}
}

/*
* Parte da configuração para reter o estado da lista
* marcada caso haja reconstrução de atividade / fragmento.
* */
override fun onSaveInstanceState( outState: Bundle ) {
super.onSaveInstanceState( outState )
selectionTracker.onSaveInstanceState( outState )
}
}

 

Importante notar que também todo o código de retenção de item selecionado em memória foi adicionado, mas como se trata de um fragmento é importante que na atividade host tenha o código de verificação de fragmento, como já fizemos na MainActivity na primeira parte do projeto.

Recordando o método callFavoritesFragment():

...
private fun callFavoritesFragment(){
var fragment = supportFragmentManager.findFragmentByTag( FavoritesFragment.KEY )

if( fragment == null ){
fragment = FavoritesFragment()

val transaction = supportFragmentManager.beginTransaction()
transaction.replace( R.id.fl_frag_container, fragment, FavoritesFragment.KEY )
transaction.commit()
}
}
...

Atualizando MoviesAdapter com o objeto de seleção

Em MoviesAdapter adicione os códigos em destaque a seguir:

class MoviesAdapter( val movies: List<Movie> ):
RecyclerView.Adapter<MoviesAdapter.ViewHolder>() {

lateinit var selectionTracker : SelectionTracker<Long>

...
inner class ViewHolder( itemView: View):
RecyclerView.ViewHolder( itemView ) {
...

fun setModel( movie: Movie, position: Int ){
...

/*
* Trabalhando a seleção via elementos da biblioteca
* SelectionTracker.
*
* onBindViewHolder() é invocado também quando há a
* seleção / desseleção de um item, já com o status
* alterado.
* */
movieDetails.movie = movie
movieDetails.adapterPosition = position

if( selectionTracker.isSelected( movieDetails.getSelectionKey() ) ){
itemView.isActivated = true
setUIItemSelected()
}
else{
itemView.isActivated = false
setUIItemNotSelected()
}
}
...
}

Remoção de filmes

Para finalizar, ainda faltam os códigos de remoção de itens. Em FavoritesFragment adicione os dois métodos a seguir:

...
/*
* Método responsável por remover itens selecionados,
* incluindo suas chaves de seleção.
* */
fun removeItemsSelected(){

/*
* Cláusula de guarda para quando não houver nenhum
* item selecionado não prosseguir com a execução
* do método.
* */
if( selectionTracker.selection.size() == 0 )
return

val moviesToRemove = mutableListOf<Movie>()

for( key in selectionTracker.selection ){
val movie = movies.filter{ m -> m.id.toLong() == key }.single()
moviesToRemove.add( movie )
}

selectionTracker.clearSelection()
movies.removeAll( moviesToRemove )
(rv_movies.adapter as MoviesAdapter).notifyDataSetChanged()

removeMessage( moviesToRemove.size )
}

private fun removeMessage( amountRemoved: Int ){
val messageId = if( amountRemoved == 1 )
R.string.favorites_removed_movie
else
R.string.favorites_removed_movies

Toast
.makeText(
activity,
getString( messageId ),
Toast.LENGTH_SHORT
)
.show()
}
...

 

Para saber mais sobre o padrão Cláusula de Guarda, entre no artigo a seguir: Padrão de Projeto: Cláusula de Guarda.

Agora é atualizar a MainActivity para que seja possível o acionamento do método removeItemsSelected() partindo de um clique / toque no FloatingActionButton.

Na MainActivity adicione os códigos em destaque:

class MainActivity :
AppCompatActivity(),
NavigationView.OnNavigationItemSelectedListener,
View.OnClickListener {

override fun onCreate( savedInstanceState: Bundle? ) {
...

fab.setOnClickListener( this )
}
...

override fun onClick( v: View ){
val fragment = supportFragmentManager.findFragmentByTag( FavoritesFragment.KEY )

if( fragment is FavoritesFragment ){
fragment.removeItemsSelected()
}
}
}

Testes e resultados

Abra o Android Studio, vá em "Build", então em "Rebuid project". Ao final do rebuild execute o aplicativo em seu aparelho ou emulador Android de testes.

Abrindo o aplicativo na tela de favoritos, selecionando alguns filmes e os removendo, temos:

Removendo filmes com no app Android ThiengosFlix

Assim finalizamos este primeiro conteúdo sobre a nova biblioteca de seleção do RecyclerView Android, a SelectionTracker.

Abordarei ainda mais partes desta biblioteca, então não deixe de se inscrever na 📩 lista de emails do Blog para receber em primeira mão esses e outros conteúdos Android exclusivos.

Se inscreva também no canal do Blog em: YouTube Thiengo.

Slides

Abaixo os slides com o passo a passo de configuração da SelectionTracker no RecyclerView Android:

Vídeos

Abaixo os vídeos com a atualização do projeto Android ThiengosFlix para utilizar a SelectionTracker no modo de remoção de filmes favoritos:

Para acessar o projeto de exemplo entre no GitHub dele em: https://github.com/viniciusthiengo/thiengos-flix-kotlin-android.

Conclusão

Mesmo com os problemas ainda encontrados na SelectionTracker, ela permite que a funcionalidade de seleção de itens seja colocada por completo nos RecyclerViews já existentes ou não em projeto, algo não possível quando utilizando uma API de terceiro, onde certamente o RecyclerView nativo teria de ser substituído.

A biblioteca ainda vai evoluir, mas com os conteúdos apresentados aqui já é possível utiliza-la em produção.

A SelectionTracker é ainda maior, tem inúmeras outras APIs que estaremos abordando em outros artigos e vídeos.

Com isso finalizamos o conteúdo. Caso você tenha dúvidas ou sugestões, deixe logo abaixo nos comentários.

Curtiu o conteúdo? Não esqueça de compartilha-lo. E por fim, não deixe de se inscrever na 📩 lista de emails.

Abraço.

Fontes

Create a List with RecyclerView

Documentação oficial ItemDetailsLookup

Documentação oficial ItemDetailsLookup.ItemDetails

Documentação oficial ItemKeyProvider

Documentação oficial SelectionTracker.Builder

Documentação oficial StableIdKeyProvider

Documentação oficial SelectionPredicate

Android recycler view with multiple item selections

Kotlin - Classes and Inheritance

Add selection support to RecyclerView Selection

Android - what is the meaning of StableIDs? - Resposta de Delyan

Calling a Fragment method from a parent Activity - Resposta de Dheeresh Singh e Akshay

Android changing Floating Action Button color - Resposta de Progga Ilma

getColor(int id) deprecated on Android 6.0 Marshmallow (API 23) - Resposta de Melvin

Investir em Você é Barra de Ouro a R$ 2,00. Cadastre-se e receba grátis conteúdos Android sem precedentes!
Email inválido

Relacionado

Lottie API Para Animações no AndroidLottie API Para Animações no AndroidAndroid
Data Binding Para Vinculo de Dados na UI AndroidData Binding Para Vinculo de Dados na UI AndroidAndroid
Como Impulsionar o App Android - Compartilhamento NativoComo Impulsionar o App Android - Compartilhamento NativoAndroid
Android About Page API Para Construir a Tela SobreAndroid About Page API Para Construir a Tela SobreAndroid

Compartilhar

Comentários Facebook

Comentários Blog (3)

Para código / script, coloque entre [code] e [/code] para receber marcação especifica.
Forneça seu nome válido.
Forneça seu email válido.
Forneça o comentário.
Enviando, aguarde...
23/09/2019
Thiengo boa noite, agradeço as dicas mas gostaria de saber como implementaria a função de chamar uma nova activity no kotlin pela recyclerview
Responder
Vinícius Thiengo (1) (0)
09/01/2020
Diego, tudo bem?

No caso é invocar uma Activity dentro das configurações da SelectionTracker API do RecyclerView, certo?

Se sim, o código é bem simples, na verdade é praticamente o mesmo de quando a necessidade é a de invocar um fragmento.

O core do código vai estar no método onItemStateChanged() de sua versão de SelectionTracker.SelectionObserver.

Ao invés de colocar um código de exemplo aqui, vou lhe indicar todo um algoritmo que fiz com RecyclerView, SelectionTracker e chamadas de fragmentos e atividades.

Primeiro estude todo o algoritmo do artigo a seguir: https://www.thiengo.com.br/inicio-de-projeto-e-menu-gaveta-customizado-android-m-commerce

Depois vá direto a seção "Algoritmos de abertura da AccountSettingsActivity" do seguinte artigo: https://www.thiengo.com.br/como-criar-a-ui-de-configuracoes-de-conta-de-usuario-android-m-commerce

Nesta parte você vai ver como ter chamadas de fragmentos e atividades em um mesmo código partindo de APIs RecyclerView.

Diego, é isso.

Surgindo mais dúvidas, pode perguntar.

Um excelente 2020 para você e família.

Abraço.
Responder
Renato (3) (0)
29/12/2018
Sempre fazendo o bem com eficiência , eu vou voltar alguns anos no seu blog.
Responder