Widgets no Android. Um recurso raro para descobrir

Olá Habr! Meu nome é Alexander Khakimov, sou desenvolvedor Android da FINCH.

Aconteceu que seu design era para iOS e você precisa adaptá-lo para Android? Em caso afirmativo, seus designers costumam usar widgets? Infelizmente, um widget é um caso raro para muitos desenvolvedores, porque raramente alguém trabalha com ele.Neste

artigo, mostrarei em detalhes como criar um widget, que vale a pena prestar atenção e compartilhará meu caso.

Criação de widget


Para criar um widget, você precisa saber:

  1. Recursos dos componentes do widget.
  2. Recursos de exibição do widget na grade da tela.
  3. Apresenta atualizações de widget.

Analisaremos cada item separadamente.

Recursos dos componentes do widget


Qualquer desenvolvedor que tenha trabalhado com os RemoteViews pelo menos uma vez está familiarizado com este item. Se você é um desses, sinta-se à vontade para avançar para o próximo ponto.

O RemoteViews foi projetado para descrever e gerenciar hierarquias de Views que pertencem a um processo em outro aplicativo. Usando o gerenciamento de hierarquia, você pode alterar propriedades ou chamar métodos que pertencem ao modo de exibição, que faz parte de outro aplicativo. O RemoteViews inclui um conjunto limitado de componentes da biblioteca de componentes padrão android.widget.

A exibição de widgets internos funciona em um processo separado (geralmente essa é a tela inicial); portanto, para alterar a interface do widget, use a extensão BroadcastReceiver - AppWidgetProvider, que funciona em nosso aplicativo.

Recursos de exibição do widget na "grade" da tela


De fato, esse ponto não é tão complicado, se você observar as diretrizes oficiais :
Cada widget deve definir um minWidth e um minHeight, indicando a quantidade mínima de espaço que deve consumir por padrão. Quando os usuários adicionam um widget à tela inicial, ele geralmente ocupa mais do que a largura e a altura mínimas especificadas. As telas iniciais do Android oferecem aos usuários uma grade de espaços disponíveis nos quais eles podem colocar widgets e ícones. Essa grade pode variar de acordo com um dispositivo; por exemplo, muitos aparelhos oferecem uma grade 4x4 e os tablets podem oferecer uma grade 8x7 maior.

Traduzindo para o russo: cada widget deve definir sua largura e altura mínimas para indicar o espaço mínimo que ocupará por padrão.

imagem
Exemplo de configurações de widget ao criar no Android Studio Um

widget que foi adicionado à tela inicial geralmente ocupa mais espaço do que a largura e a altura mínimas da tela que você definiu. As telas iniciais do Android fornecem aos usuários uma grade de espaços disponíveis nos quais widgets e ícones podem ser localizados. Essa grade pode variar de acordo com o dispositivo; por exemplo, muitos telefones oferecem grades 4x4 e tablets podem oferecer grades grandes 8x4.

A partir disso, fica claro que a grade do dispositivo pode ser qualquer coisa e o tamanho da célula pode variar, dependendo do tamanho da grade. Assim, o conteúdo do widget deve ser projetado com esses recursos em mente.

A largura e a altura mínimas do widget para um determinado número de colunas e linhas podem ser calculadas usando a fórmula:

minSideSizeDp = 70 × n - 30, onde n é o número de linhas ou colunas.No

momento, a grade mínima máxima que você pode definir é 4x4. Isso garante que seu widget seja exibido em todos os dispositivos.

Apresenta atualizações de widget


Como AppWidgetProvider é essencialmente uma extensão do BroadcastReceiver, você pode fazer o mesmo com um BroadcastReceiver regular. O AppWidgetProvider simplesmente analisa os campos correspondentes do Intent recebido no onReceive e chama os métodos de interceptação com os extras recebidos.

A dificuldade surgiu com a frequência de atualização do conteúdo - o ponto principal é a diferença na operação interna de widgets no iOS e Android. O fato é que os dados nos widgets do iOS são atualizados quando o widget se torna visível para o usuário. No Android, esse evento não existe. Não podemos descobrir quando o usuário vê o widget.

Para widgets no Android, o método de atualização recomendado é uma atualização do timer. As configurações do timer são definidas pelo parâmetro do widget updatePeriodMillis. Infelizmente, essa configuração não permite atualizar o widget mais de uma vez a cada 30 minutos. Abaixo vou falar sobre isso em mais detalhes.

Caso do widget


Além disso, falaremos sobre o caso que tivemos na FINCH em um grande aplicativo de loteria com o aplicativo Stoloto para participação em loterias estaduais.

A tarefa do aplicativo é simplificar e tornar transparente para o usuário a escolha de uma loteria e a compra de um bilhete. Portanto, a funcionalidade necessária do widget é bastante simples: mostre os jogos recomendados pelo usuário para compra e toque em para ir para o correspondente. A lista de jogos é determinada no servidor e atualizada regularmente.

No nosso caso, o design do widget incluiu dois estados:

  • Para usuário autorizado
  • Para um usuário não autorizado

Um usuário autorizado precisa mostrar seus dados de perfil: o status da carteira interna, o número de tickets aguardando o sorteio e a quantidade de vitórias perdidas. Para cada um desses elementos, é fornecida uma transição para a tela dentro do aplicativo, diferente dos outros.

imagem

imagem

Como você deve ter notado, outro recurso para um usuário autorizado é o botão "atualizar", mas mais sobre isso mais tarde.

Para implementar a exibição de dois estados, levando em consideração o design, usei o RemoteAdapter como uma implementação do RemoteViewsService para gerar cartões de conteúdo.

E agora um pouco de código e como tudo funciona por dentro. Se você já teve experiência com o widget, sabe que qualquer atualização dos dados do widget começa com o método onUpdate:

override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        injector.openScope(this, *arrayOf(this))
        // update each of the widgets with the remote adapter
        appWidgetIds
            .forEach {
                updateWidget(context, appWidgetManager, it)
          }
    }

Estamos escrevendo uma atualização para cada instância do nosso widget.

private fun updateWidget(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetId: Int
    ) {
// remoteViews   widgetId
        val remoteViews = RemoteViews(
            context.packageName,
            R.layout.app_widget_layout
...
//        
...
//    remoteViews
updateRemoteAdapter(context, remoteViews, appWidgetId)
 
//   remoteViews 
appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
// collection view  
appWidgetManager.notifyAppWidgetViewDataChanged(
            appWidgetId,
            R.id.lvWidgetItems
        )
    }

Atualizando o adaptador.

private fun updateRemoteAdapter(context: Context, remoteViews: RemoteViews, appWidgetId: Int) {
//   RemoteViewsService   RemoteAdapter   
        val adapterIntent = Intent(context, StolotoAppWidgetRemoteViewsService::class.java).apply {
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
        }
        remoteViews.setRemoteAdapter(R.id.lvWidgetItems, adapterIntent)
// actionIntent  pendingIntent      
        val actionIntent = Intent(context, StolotoAppWidgetProvider::class.java).apply {
            action = WIDGET_CLICK_ACTION
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
        }
        val pendingIntent = PendingIntent.getBroadcast(
            context, 0, actionIntent,
            PendingIntent.FLAG_UPDATE_CURRENT
        )
// pendingIntent      
        remoteViews.setPendingIntentTemplate(R.id.lvWidgetItems, pendingIntent)
    }

Estamos escrevendo a implementação do nosso serviço. Nele, é importante indicar qual implementação da interface RemoteViewsService.RemoteViewsFactory usar para gerar conteúdo.

class StolotoAppWidgetRemoteViewsService : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
        StolotoAppWidgetRemoteViewsFactory(
            this.applicationContext,
            intent
        )
}

Este é realmente um invólucro fino sobre o adaptador. Graças a ele, podemos associar nossos dados à visualização de coleta remota. RemoteViewsFactory fornece métodos para gerar RemoteViews para cada item no conjunto de dados. O construtor não tem requisitos - tudo o que faço é passar o contexto nele.

A seguir, algumas palavras sobre os principais métodos:

  1. onCreate - criando um adaptador.
  2. getLoadingView - o método sugere retornar a Visualização, que o sistema exibirá em vez dos itens da lista enquanto eles estão sendo criados. Se você não criar nada aqui, o sistema utilizará uma Visualização padrão.
  3. getViewAt - o método sugere a criação de itens da lista. Aí vem o uso padrão do RemoteViews.
  4. onDataSetChanged é chamado quando uma solicitação é recebida para atualizar os dados na lista. Essa. Neste método, preparamos os dados para a lista. O método é aprimorado pela execução de código longo e pesado.
  5. onDestroy é chamado quando a última lista que o adaptador usado é excluída (um adaptador pode ser usado por várias listas).
  6. O RemoteViewsFactory vive enquanto todas as instâncias da lista estão ativas, para que possamos armazenar dados atuais, por exemplo, uma lista de itens atuais.

Defina uma lista de dados que mostraremos:

private val widgetItems = ArrayList<WidgetItem>()

Ao criar o adaptador, começamos a carregar os dados. Aqui você pode executar com segurança quaisquer tarefas difíceis, incluindo entrar silenciosamente na rede, bloqueando o fluxo.

override fun onCreate() {
        updateDataSync()
}

Ao chamar o comando para atualizar dados, também chamamos updateDataSync ()

   override fun onDataSetChanged() {
        updateDataSync()
    }

Dentro do updateDataSync, tudo também é simples. Limpamos a lista atual de itens. Faça o download dos dados do perfil e do jogo.

 private fun updateDataSync() {
        widgetItems.clear()
        updateProfileSync()
        updateGamesSync()
    }

Aqui é mais interessante

private fun updateProfileSync() {

Como é importante mostrar o perfil apenas para um usuário autorizado, precisamos fazer o download das informações do perfil apenas neste caso:

val isUserFullAuth = isUserFullAuthInteractor
            .execute()
            .blockingGet()
        if (isUserFullAuth) {
            val profile = getWidgetProfileInteractor
                .execute()
                .onErrorReturn {
                    WidgetProfile()
//           
                }
                .blockingGet()

O modelo WidgetProfile é montado a partir de diferentes fontes, portanto, a lógica de seu recebimento e seus valores padrão são organizados de forma que um valor negativo da carteira indique dados incorretos ou problemas com o recebimento.

Para a lógica de negócios, a falta de dados da carteira é crítica; portanto, no caso de uma carteira incorreta, um modelo de perfil não será criado e adicionado à lista de itens.

  if (profile.walletAmount >= 0L) {
                widgetItems.add(
                    WidgetItem.Profile(
                        wallet = profile.walletAmount.toMoneyFormat(),
                        waitingTickets = if (profile.waitingTicketsCount >= 0) profile.waitingTicketsCount.toString() else "",
                        unpaidPrizeAmount = if (profile.unpaidPrizeAmount >= 0) profile.unpaidPrizeAmoount.toMoneyFormat() else ""
                    )
                )
            }
        }
    }

O método updateGamesSync () usa getWidgetGamesInteractor e adiciona um conjunto de jogos relevantes ao widget à lista widgetItems.

Antes de prosseguir para a geração de cartões, considere o modelo WidgetItem com mais detalhes. É implementado através da classe selada kotlin, que torna o modelo mais flexível e trabalhar com ele é mais conveniente.

sealed class WidgetItem {
 
    data class Profile(
        val wallet: String,
        val waitingTickets: String,
        val unpaidPrizeAmount: String
    ) : WidgetItem()
 
    data class Game(
        val id: String,
        val iconId: Int,
        val prizeValue: String,
        val date: String
    ) : WidgetItem()
}

Crie RemoteViews e determine sua resposta através de FillInIntent

override fun getViewAt(position: Int): RemoteViews {
        return when (val item = widgetItems[position]) {
            is WidgetItem.Profile -> {
              RemoteViews(
                        context.packageName,
                        R.layout.item_widget_user_profile
                    ).apply {
                        setTextViewText(R.id.tvWidgetWalletMoney, item.wallet)
                        setTextViewText(R.id.tvWidgetUnpaidCount, item.unpaidPrizeAmount)
                        setTextViewText(R.id.tvWidgetWaitingCount, item.waitingTickets)
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileWallet, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_USER_WALLET
                            )
                        )
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileUnpaid, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_UNPAID_PRIZE
                            )
                        )
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileWaiting, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_WAITING_TICKETS
                            )
                        )
                    }
 
            is WidgetItem.Game -> {
                RemoteViews(
                    context.packageName,
                    R.layout.item_widget_game
                ).apply {
                    setImageViewResource(R.id.ivWidgetGame, item.iconId)
                    setTextViewText(R.id.tvWidgetGamePrize, item.prizeValue)
                    setTextViewText(R.id.tvWidgetGameDate, item.date)
                    setOnClickFillInIntent(
                        R.id.llWidgetGame, Intent().putExtra(
                            StolotoAppWidgetProvider.KEY_GAME_CLICK, item.id
                        )
                    )
                }
            }
        }
    }

O método setOnClickFillInIntent atribui a intenção viewId especificada, que será combinada com o pai PendingIntent para determinar o comportamento ao clicar na exibição com este viewId. Dessa forma, podemos responder aos cliques do usuário no nosso WidgetProvider.

Atualização manual do widget


Um tempo de atualização de meia hora foi definido para o nosso widget. Você pode atualizá-lo com mais frequência, por exemplo, dançando com o WorkManager, mas por que carregar sua rede e bateria? Esse comportamento nos estágios iniciais do desenvolvimento parecia adequado.

Tudo mudou quando o "negócio" percebeu que, quando o usuário olha para o widget, os dados irrelevantes são exibidos: "Aqui no meu iPhone, eu abro o widget e há os MAIS NOVOS dados do meu perfil".

A situação é comum: o iOS gera novos cartões para TODAS as exibições de widgets, pois para isso eles têm uma tela especial, e o Android não possui esses eventos para o widget em princípio. Eu tive que levar em conta que algumas loterias são realizadas a cada 15 minutos, para que o widget forneça informações atualizadas - você deseja participar de algum tipo de sorteio, mas já passou.

Para sair desta situação desagradável e de alguma forma resolver o problema com a atualização de dados, propus e implementei uma solução testada pelo tempo - o botão "atualizar".

Adicione este botão ao layout do layout com a lista e inicialize seu comportamento quando for chamado updateWidget.

...
// Intent   AppWidgetManager.ACTION_APPWIDGET_UPDATE
val intentUpdate = Intent(context, StolotoAppWidgetProvider::class.java)
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE

//    
val ids = AppWidgetManager.getInstance(context)
   .getAppWidgetIds(ComponentName(context, StolotoAppWidgetProvider::class.java))
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)

//  intent  PendingIntent,  PendingIntent.getBroadcast()
val pendingUpdate = PendingIntent.getBroadcast(
   context,
   appWidgetId,
   intentUpdate,
   PendingIntent.FLAG_UPDATE_CURRENT
)
//  pendingIntent      ‘’
remoteViews.setOnClickPendingIntent(R.id.ivWidgetRefresh, pendingUpdate)

Os primeiros desenvolvimentos mostraram uma imagem triste: do pressionamento do botão "atualizar" até a atualização real, alguns segundos poderiam passar. Embora o widget seja gerado por nosso aplicativo, ele está realmente sob o controle do sistema e se comunica com nosso aplicativo por meio de transmissões.

Essa. Quando você clica no botão "Atualizar" do nosso widget, a cadeia começa:

  1. Obtenha a intenção na 'ação' do provedor onReceive.
  2. AppWidgetManager.ACTION_APPWIDGET_UPDATE.
  3. Chame onUpdate para todos os widgetsIds especificados em intent-e.
  4. Fique online para obter novos dados.
  5. Atualize os dados locais e exiba novos cartões de lista.

Como resultado, a atualização do widget não pareceu muito agradável, porque, ao clicar no botão, examinamos o mesmo widget por alguns segundos. Não ficou claro se os dados foram atualizados. Como resolver o problema de resposta visual?

Em primeiro lugar, adicionei a flag isWidgetLoading com acesso global por meio do interator. A função desse parâmetro é bastante simples - não mostre o botão de atualização enquanto os dados do widget estiverem sendo carregados.

Em segundo lugar, dividi o processo de carregamento de dados na fábrica em três etapas:

enum class LoadingStep {
   START,
   MIDDLE,
   END
}

START - início do download. Nesse estágio, o estado de todas as visualizações do adaptador e o sinalizador de download global mudam para "loading".

MÉDIO - o estágio do carregamento principal de dados. Após o download, o sinalizador global de download é colocado no estado "carregado" e os dados baixados são exibidos no adaptador.

FIM - final do download. O adaptador não precisa alterar os dados do adaptador nesta etapa. Esta etapa é necessária para processar corretamente o estágio de atualização das visualizações no WidgetProvider.

Vamos ver com mais detalhes a aparência da atualização do botão no provedor:

if (isFullAuthorized && !widgetLoadingStateInteractor.isWidgetLoading) {
   remoteViews.setViewVisibility(R.id.ivWidgetRefresh, View.VISIBLE)
...
//     ,    
...   
} else {
   remoteViews.setViewVisibility(
       R.id.ivWidgetRefresh,
       if (isFullAuthorized) View.INVISIBLE else View.GONE //       .
   )
}

Agora vamos ver o que acontece no adaptador:

private fun updateDataSync() {
   when (loadingStep) {
       START -> {
           widgetItems.forEach { it.isLoading = true }
           widgetLoadingStateInteractor.isWidgetLoading = true
           loadingStep = MIDDLE
           widgetManager.updateWidgets()
       }
       MIDDLE -> {
           widgetItems.clear()
           updateProfileSync()
           updateGamesSync()
           widgetLoadingStateInteractor.isWidgetLoading = false
           loadingStep = END
           widgetManager.updateWidgets()
       }
       END -> {
           loadingStep = START
       }
   }
}

imagem

Lógica do trabalho:

  1. No final das etapas START e MIDDLE, chamo o método updateWidgets para atualizar o estado de exibição gerenciado pelo provedor.
  2. START «» , MIDDLE.
  3. MIDDLE, «».
  4. MIDDLE, END.
  5. , END, «». , END loadingStep START.



Com a ajuda dessa implementação, cheguei a um compromisso entre o requisito do "negócio" para ver dados relevantes no widget e a necessidade de "puxar" a atualização com muita frequência.

Espero que o artigo tenha sido útil para você. Se você teve experiência na criação de widgets para Android, conte-nos nos comentários.

Boa sorte

All Articles