Oferecer compatibilidade com dispositivos dobráveis e de duas telas usando a biblioteca Jetpack WindowManager

Este codelab prático ensinará os conceitos básicos de desenvolvimento para dispositivos dobráveis e de tela dupla. Quando terminar, você poderá aprimorar seu app para que ele seja compatível com dispositivos como o Microsoft Surface Duo e o Samsung Galaxy Z Fold 2.

Pré-requisitos

Para concluir este codelab, você precisará de:

  • experiência na criação de apps Android;
  • experiência com atividades, fragmentos, vinculação de visualizações e layouts xml;
  • experiência em adicionar dependências aos seus projetos;
  • experiência de instalação e uso de emuladores de dispositivo. Neste codelab, você usará um emulador dobrável e/ou de tela dupla.

O que você irá aprender

  • Criar um app simples e aprimorá-lo para que seja compatível com dispositivos dobráveis e de tela dupla.
  • Usar a Jetpack WindowManager para trabalhar com novos formatos de dispositivos.

Pré-requisitos

  • Android Studio 4.2 ou uma versão mais recente
  • Um dispositivo ou emulador dobrável. Se você está usando o Android Studio 4.2, existem alguns emuladores dobráveis que podem ser usados, conforme mostrado na imagem abaixo:

7a0db14df3576a82.png

  • Para usar um emulador de tela dupla, faça o download do emulador do Microsoft Surface Duo para sua plataforma (Windows, MacOS ou GNU/Linux) aqui.

Os dispositivos dobráveis oferecem uma tela maior e uma interface do usuário mais versátil do que aquela disponível anteriormente em um dispositivo móvel. Outra vantagem é que, quando dobrados, esses dispositivos costumam ser menores do que um tablet de tamanho comum, tornando-os mais portáteis e funcionais.

No momento da redação deste codelab, existem dois tipos de dispositivos dobráveis:

  • Dispositivos dobráveis de tela única, que pode ser dobrada. Os usuários podem executar vários apps na mesma tela, ao mesmo tempo, usando o modo Multi-Window.
  • Dispositivos dobráveis de duas telas, unidas por uma articulação. Esses dispositivos também podem ser dobrados, mas têm duas regiões de tela lógica.

affbd6daf04cfe7b.png

Da mesma forma que tablets e outros dispositivos móveis de tela única, os dobráveis podem:

  • executar um app em uma das regiões de exibição;
  • executar dois apps lado a lado, cada um em uma região de exibição diferente, usando o modo Multi-Window.

Ao contrário dos dispositivos de tela única, os dobráveis também são compatíveis com diferentes posições. Elas podem ser usadas para exibir conteúdo de maneiras diferentes.

f2287b68f32b59e3.png

Os dispositivos dobráveis podem oferecer várias posições quando um app é estendido (exibido) em toda a região de exibição (usando todas as regiões em dispositivos dobráveis de tela dupla).

Os dispositivos dobráveis também podem oferecer posições dobradas, como o modo de mesa, para que você possa ter uma divisão lógica entre a parte da tela plana e aquela inclinada para você, e o modo tenda, que permite a visualização do conteúdo como se o dispositivo estivesse usando um gadget de apoio.

A biblioteca Jetpack WindowManager foi criada para ajudar os desenvolvedores a fazer ajustes nos apps e aproveitar a nova experiência que esses dispositivos oferecem aos usuários. A Jetpack WindowManager ajuda desenvolvedores de aplicativos a oferecer compatibilidade com novos formatos de dispositivos. Ela também oferece uma superfície de API comum para diferentes recursos da WindowManager, tanto nas versões antigas quanto nas novas.

Principais recursos

A versão 1.0.0-alpha03 da Jetpack WindowManager contém a classe FoldingFeature, que descreve uma dobra na tela flexível ou uma articulação entre dois painéis de exibição física. A API dela fornece acesso a informações importantes relacionadas ao dispositivo:

Com a classe WindowManager principal, é possível acessar informações importantes, como:

  • getCurrentWindowMetrics(): retorna a WindowMetrics de acordo com o estado atual do sistema. Esse valor se baseia no estado atual do janelamento do sistema.
  • getMaximumWindowMetrics(): retorna a maior WindowMetrics de acordo com o estado atual do sistema. Esse valor se baseia no maior estado de janelamento do sistema. Por exemplo, para atividades no modo de várias janelas, as métricas retornadas são baseadas nos limites que teriam se o usuário expandisse a janela para cobrir a tela inteira.

Clone o repositório do GitHub ou faça o download do exemplo de código do app que você está aprimorando:

git clone https://github.com/googlecodelabs/android-foldable-codelab

Declarar dependências

Para usar a Jetpack WindowManager, é necessário adicionar a dependência a ela.

  1. Primeiro, adicione o repositório Maven do Google ao seu projeto.
  2. Adicione a dependência do artefato no arquivo build.gradle do seu app ou módulo:
dependencies {
    implementation "androidx.window:window:1.0.0-alpha03"
}

Como usar a WindowManager

A Jetpack WindowManager pode ser usada com muita facilidade. Para isso, registre seu app para detectar mudanças de configuração.

Primeiro, inicialize a instância da WindowManager para ter acesso à API dela. Para inicializá-la, implemente o seguinte código na sua atividade:

private lateinit var wm: WindowManager

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

        wm = WindowManager(this)
}

O construtor principal permite somente um parâmetro: um contexto visual, como uma Activity ou um ContextWrapper ao redor de uma atividade. Em segundo plano, esse construtor usará um WindowBackend padrão. Essa é uma classe de servidor de apoio que fornecerá informações para essa instância.

Após criar a instância da WindowManager, você poderá registrar um callback para saber quando as mudanças de posição acontecem, quais recursos o dispositivo têm e quais são os limites desses recursos, se houver. Além disso, conforme mencionado anteriormente, é possível ver as métricas atuais e máximas de acordo com o estado atual do sistema.

  1. Abra o Android Studio.
  2. Clique em File > New > New Project > Empty Activity para criar um novo projeto.
  3. Clique em Next, aceite as propriedades e os valores padrão e clique em Finish.

Agora, crie um layout simples para que você possa ver as informações que a WindowManager relatará. Para isso, você precisará criar a pasta de layout e o arquivo de layout específico:

  1. Clique em File > New > Android resource directory.
  2. Na nova janela, selecione um Resource Type layout e clique em OK.
  3. Vá para a estrutura do projeto e, em src/main/res/layout, crie um novo arquivo de recurso de layout (File > New > Layout resource file) chamado activity_main.xml.
  4. Abra o arquivo e adicione este conteúdo como seu layout:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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/constraint_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/window_metrics"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/window_metrics"
       android:textSize="20sp"
       app:layout_constraintBottom_toTopOf="@+id/layout_change"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       app:layout_constraintVertical_chainStyle="packed" />

   <TextView
       android:id="@+id/layout_change"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/layout_change_text"
       android:textSize="20sp"
       app:layout_constrainedWidth="true"
       app:layout_constraintBottom_toTopOf="@+id/configuration_changed"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/window_metrics" />

   <TextView
       android:id="@+id/configuration_changed"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/configuration_changed"
       android:textSize="20sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

Você criou um layout simples com base em um ConstraintLayout com três TextViews. As visualizações são restritas entre elas para que fiquem alinhadas ao centro do pai (e da tela).

  1. Abra o arquivo MainActivity.kt e adicione o seguinte código:

window_manager/MainActivity.kt

class MainActivity : AppCompatActivity() {
  1. Crie uma classe interna, que ajudará você a processar o resultado dos callbacks:
inner class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
   override fun accept(newLayoutInfo: WindowLayoutInfo) {
       printLayoutStateChange(newLayoutInfo)
   }
}

As funções que as classes internas usam são funções simples que imprimirão as informações obtidas da WindowManager usando os componentes da sua IU (TextView):

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}
  1. Declare uma variável WindowManager lateinit:
private lateinit var wm: WindowManager
  1. Crie uma variável que gerenciará os callbacks usando a WindowManager com as classes internas já criadas:
private val layoutStateChangeCallback = LayoutStateChangeCallback()
  1. Adicione uma vinculação para poder acessar as diferentes visualizações:
private lateinit var binding: ActivityMainBinding
  1. Agora, crie uma função que se estenda do Executor para que você possa fornecê-la ao callback como o primeiro parâmetro, que será usado quando o callback for chamado. Nesse caso, você criará uma função que será executada na linha de execução de IU. Se preferir, você pode criar uma que não seja executada na linha de execução de IU.
private fun runOnUiThreadExecutor(): Executor {
   val handler = Handler(Looper.getMainLooper())
   return Executor() {
       handler.post(it)
   }
}
  1. No onCreate da MainActivity, inicialize o WindowManager lateinit:
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   wm = WindowManager(this)
}

Agora, a instância da WindowManager tem a Activity como único parâmetro e usará a implementação de back-end padrão da WindowManager.

  1. Encontre a função que você adicionou na etapa 5. Adicione esta linha logo após o cabeçalho da função:
binding.windowMetrics.text =
   "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
       "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

Neste caso, você está definindo o valor da TextView da window_metrics usando os valores que as funções currentWindowMetrics.bounds.flattenToString() e maximumWindowMetrics.bounds.flattenToString() contêm.

Esses valores fornecem informações úteis sobre as métricas da área ocupada pela janela. Conforme ilustrado na imagem abaixo, em um emulador de tela dupla, você recebe a CurrentWindowMetrics que se ajusta às dimensões do dispositivo que espelha. Também é possível ver as métricas quando o app é executado no modo de tela única:

b032c729d6dce292.png

Abaixo, você pode ver como as métricas mudam quando o app é estendido para as telas, refletindo a área maior da janela usada pelo app:

b72ca8a63b65e4c1.png

As métricas atual e máxima da janela têm os mesmos valores, já que o app está sempre em execução e ocupando toda a área de exibição disponível, tanto na tela única quanto na dupla.

Em um emulador dobrável com dobra horizontal, os valores são diferentes quando o app é estendido para a tela física inteira e quando usa o modo de várias janelas:

5cb5270ee0e42320.png

Como você pode ver na imagem à esquerda, as duas métricas têm o mesmo valor, já que o app em execução está usando toda a área de exibição, que é a atual e a máxima disponível.

Mas, na imagem à direita, com o app executado no modo de várias janelas, é possível ver como as métricas atuais mostram as dimensões da área em que o app é executado na área específica (superior) desse modo. Também é possível ver como as métricas máximas mostram a área máxima de exibição do dispositivo.

As métricas fornecidas pela WindowManager são muito úteis para saber a área da janela que o app está usando ou pode usar.

Agora, você registrará as alterações no layout para reconhecer o recurso do dispositivo (se é uma articulação ou um dispositivo dobrável) e os limites dele.

A função que precisamos usar tem esta assinatura:

public void registerLayoutChangeCallback (
                Executor executor,
                Consumer<WindowLayoutInfo> callback)

Essa função usa o tipo WindowLayoutInfo. Essa classe tem os dados que você precisa analisar quando o callback é chamado. A classe contém internamente um List< DisplayFeature>, que retorna uma lista de DisplayFeatures encontrados no dispositivo que cruza com o app. A lista pode ser vazia se não houver recurso de exibição que cruze com o app.

Essa classe implementa o DisplayFeature e, quando você receber o List<DisplayFeature> como resultado, poderá transmitir (os itens) ao FoldingFeature, onde você verá informações como a posição do dispositivo, o tipo de recurso do dispositivo e os limites dele.

Vamos ver como você pode usar esse callback e visualizar as informações fornecidas por ele. Para o código que você já adicionou na etapa anterior (Criar o app de exemplo):

  1. Modifique o método onAttachedToWindow:
override fun onAttachedToWindow() {
   super.onAttachedToWindow()
  1. Use a instância da WindowManager registrando-se no callback de mudanças do layout e usando o executor implementado antes como o primeiro parâmetro:
   wm.registerLayoutChangeCallback(
       runOnUiThreadExecutor(),
       layoutStateChangeCallback
   )
}

Vamos ver como são as informações que esse callback fornece. Se você executar esse código no emulador de tela dupla, terá:

49a85b4d10245a9d.png

Como você pode ver, a WindowLayoutInfo está vazia. Ela tem um List<DisplayFeature> vazio. Mas, se você tem um emulador com uma articulação no meio, por que não recebe as informações da WindowManager?

A WindowManager fornece os dados da LayoutInfo (tipo de recurso, limites de recursos e posição do dispositivo) somente quando o app é estendido para telas (físicas ou não). Na figura anterior, em que o app é executado no modo de tela única, a WindowLayoutInfo está vazia.

Levando isso em consideração, você saberá o modo em que o app está sendo executado (de uma tela ou estendido) para que possa fazer mudanças em sua IU/UX, adaptadas a essas configurações específicas, proporcionando uma experiência melhor para os usuários.

Em dispositivos que não têm duas telas físicas (normalmente, eles não têm uma articulação física), os apps podem ser executados lado a lado no modo de várias janelas. Nesses dispositivos, quando o app é executado no modo de várias janelas, ele atua da mesma forma em que uma tela única, como no exemplo anterior. Quando o app é executado em todas as telas lógicas, ele age como quando é estendido. Você pode ver isso na próxima figura:

ecdada42f6df1fb8.png

Como podemos ver, quando o app é executado no modo de várias janelas, ele não cruza com o recurso dobrável. Portanto, a WindowManager retornará uma List<LayoutInfo> vazia.

Em resumo, você receberá dados da LayoutInfo somente quando o app cruzar o recurso do dispositivo (dobrável ou articulado). Se ele não fizer isso, você não receberá informações. 564eb78fc85f6d3e.png

O que acontece quando você estende o app para as telas? Em um emulador de tela dupla, a LayoutInfo terá um objeto FoldingFeature que fornece dados sobre o recurso do dispositivo: um HINGE, os limites desse recurso: Rect (0, 0- 1434, 1800) e a posição (estado) do dispositivo: FLAT

13edea3ff94baae4.png

O tipo de dispositivo, conforme mencionado anteriormente, pode receber dois valores: FOLD e HINGE,, como também expostos no código-fonte:

@IntDef({
       TYPE_FOLD,
       TYPE_HINGE,
})
  • type = TYPE_HINGE. Esse emulador de duas telas espelha um dispositivo Surface Duo real com uma articulação física. É isso que a WindowManager informa.
  • Rect (0, 0 - 1434, 1800) representa o retângulo delimitador do recurso dentro da janela do aplicativo no espaço de coordenadas da janela. Ao ler as especificações de dimensão do dispositivo Surface Duo, você verá que a localização da articulação atende a esses limites relatados (esquerdo, superior, direito, inferior).
  • Há três valores que representam a posição (estado) do dispositivo:
  • STATE_HALF_OPENED, a articulação do dispositivo dobrável está em uma posição intermediária entre o estado aberto e fechado. Há um ângulo não plano entre as partes da tela flexível ou entre os painéis da tela física.
  • STATE_FLAT, o dispositivo dobrável está completamente aberto, o espaço em tela apresentado ao usuário é plano.
  • STATE_FLIPPED, o dispositivo dobrável está invertido, com as partes flexíveis da tela ou as telas físicas voltadas para direções opostas.
@IntDef({
       STATE_HALF_OPENED,
       STATE_FLAT,
       STATE_FLIPPED,
})

Por padrão, o emulador fica aberto em 180 graus, de modo que a posição retornada pela WindowManager é STATE_FLAT.

Se você mudar a posição do emulador usando os sensores virtuais para a posição aberta, a WindowManager enviará uma notificação sobre a nova posição: STATE_HALF_OPENED.

7cfb0b26d251bd1.png

É possível cancelar o registro deste callback quando você não precisar mais dele. Basta chamar esta função da API WindowManager:

public void unregisterDeviceStateChangeCallback (Consumer<DeviceState> callback)

Um bom local para cancelar o registro do callback é no método onDestroy ou onDetachedFromWindow:

override fun onDetachedFromWindow() {
   super.onDetachedFromWindow()
   wm.unregisterLayoutChangeCallback(layoutStateChangeCallback)
}

Como usar a WindowManager para adaptar sua IU/UX

Como foi visto nas figuras que mostram as informações de layout da janela, essas informações foram cortadas pelo recurso de exibição, conforme você pode ver aqui novamente:

4ee805070989f322.png

Essa não é a melhor experiência que você pode oferecer aos usuários. Você pode usar as informações fornecidas pela WindowManager para ajustar sua IU/UX.

Como vimos anteriormente, quando seu app se estende por todas as regiões de exibição, ele também cruza com o recurso do dispositivo. Portanto, a WindowManager fornece informações de layout de janela como recursos e limites de exibição. Quando o app é estendido para várias janelas, você precisa usar essas informações e ajustar sua IU/UX.

O que você fará é ajustar a IU/UX que tem atualmente no momento da execução quando o app for estendido, para que nenhuma informação importante seja cortada/oculta pelo recurso de exibição. Você criará uma visualização que espelha o recurso de exibição do dispositivo e será usada como referência para restringir a TextView que é cortada/oculta, para que você não perca mais informações.

Para a aprendizagem, você colorirá essa nova visualização para ver facilmente que ela está localizada no mesmo lugar que o recurso de exibição de dispositivo real e tem as mesmas dimensões.

  1. Adicione a nova visualização que você usará como referência do recurso do dispositivo no activity_main.xml.

res/layout/activity_main.xml

<View
   android:id="@+id/device_feature"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:background="@android:color/holo_red_dark"
   android:visibility="gone" />
  1. No MainActivity.kt, acesse a função usada para exibir as informações dos callbacks da WindowManager e adicione uma nova chamada de função no caso "if-else" em que havia um recurso de exibição:

window_manager/MainActivity.kt

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
           "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToDeviceFeatureBoundaries(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

Você adicionou a função alignViewToDeviceFeatureBoundaries que recebe a WindowLayoutInfo como parâmetro.

  1. Dentro da nova função, crie seu ConstraintSet para aplicar as novas restrições às suas visualizações:
private fun alignViewToDeviceFeatureBoundaries(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)
  1. Agora, acesse os limites do recurso de exibição usando a WindowLayoutInfo:
val rect = newLayoutInfo.displayFeatures[0].bounds
  1. Com a WindowLayoutInfo fornecida na variável rect, defina a altura correta para sua visualização de referência:
set.constrainHeight(
   R.id.device_feature,
   rect.bottom - rect.top
)
  1. Agora, ajuste a visualização com a largura do elemento de exibição, com base na coordenada direita - coordenada esquerda, para que você saiba a largura do recurso do dispositivo:
set.constrainWidth(R.id.device_feature, rect.right - rect.left)
  1. Defina as restrições de alinhamento à sua referência de visualização para que ela se alinhe com o pai nos lados inicial e superior:
set.connect(
   R.id.device_feature, ConstraintSet.START,
   ConstraintSet.PARENT_ID, ConstraintSet.START, 0
)
set.connect(
   R.id.device_feature, ConstraintSet.TOP,
   ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
)

Você também pode adicionar isso diretamente no xml como atributos para a visualização, em vez de aqui no código.

Em seguida, é necessário abranger todos os possíveis posicionamentos de recurso do dispositivo: dispositivos que têm um recurso de exibição posicionado verticalmente (como o emulador de tela dupla) e dispositivos que têm o recurso de exibição posicionado horizontalmente (como o emulador dobrável com a dobra horizontal).

  1. Para o primeiro cenário, top == 0 indica que o recurso do dispositivo será posicionado verticalmente (como no emulador de tela dupla):
if (rect.top == 0) {
  1. Agora, aplique a margem à sua visualização de referência para que ela seja posicionada exatamente na mesma posição em que está o recurso de exibição.
  2. Em seguida, aplique a restrição à TextView que você quer posicionar melhor para evitar o recurso de exibição, de forma que a restrição considere o recurso:
set.setMargin(R.id.device_feature, ConstraintSet.START, rect.left)
set.connect(
   R.id.layout_change, ConstraintSet.END,
   R.id.device_feature, ConstraintSet.START, 0
)

Recursos da tela horizontal

O dispositivo do usuário pode ter um recurso de exibição localizado horizontalmente (como o emulador dobrável na horizontal).

Dependendo da IU, é possível ter uma barra de ferramentas ou de status para exibir. Assim, é recomendável ter a altura delas para que você possa ajustar a representação do recurso de exibição perfeitamente à sua interface.

No nosso app de exemplo, temos a barra de status e a de ferramentas:

val statusBarHeight = calculateStatusBarHeight()
val toolBarHeight = calculateToolbarHeight()

Uma implementação simples das funções para fazer esses cálculos (localizadas fora da nossa função atual) é:

private fun calculateToolbarHeight(): Int {
   val typedValue = TypedValue()
   return if (theme.resolveAttribute(android.R.attr.actionBarSize, typedValue, true)) {
       TypedValue.complexToDimensionPixelSize(typedValue.data, resources.displayMetrics)
   } else {
       0
   }
}

private fun calculateStatusBarHeight(): Int {
   val rect = Rect()
   window.decorView.getWindowVisibleDisplayFrame(rect)
   return rect.top
}

De volta à função principal em "else-statement", em que você gerencia o recurso de dispositivo horizontal, você pode usar a altura da barra de status e de ferramentas para a margem. Isso porque os limites do recurso de exibição não consideram nenhum elemento da IU que temos e são extraídos das coordenadas (0,0). É necessário considerar esses elementos para colocar nossa visualização de referência no local correto:

} else {
   //Device feature is placed horizontally
   val statusBarHeight = calculateStatusBarHeight()
   val toolBarHeight = calculateToolbarHeight()
   set.setMargin(
       R.id.device_feature, ConstraintSet.TOP,
       rect.top - statusBarHeight - toolBarHeight
   )
   set.connect(
       R.id.layout_change, ConstraintSet.TOP,
       R.id.device_feature, ConstraintSet.BOTTOM, 0
   )
}

A próxima etapa é mudar a visibilidade da visualização de referência para visível, para que você possa vê-la no seu exemplo (colorida com vermelha) e, mais importante, para que as restrições sejam aplicadas. Se a visualização desaparecer, não haverá restrições para aplicar:

set.setVisibility(R.id.device_feature, View.VISIBLE)

A etapa final é aplicar o ConstraintSet que você criou ao ConstraintLayout, para que todos os ajustes e mudanças no IU sejam usados:

    set.applyTo(constraintLayout)
}

Agora, a TextView que entrou em conflito com o recurso de exibição de dispositivo considera onde o recurso está localizado, para que o conteúdo dele nunca seja cortado ou oculto:

80993d3695a9a60.png

No emulador de tela dupla (à esquerda), podemos ver como a TextView que exibia o conteúdo nas telas e era cortada pela articulação não é mais cortada, então não há informações ausentes.

Em um emulador dobrável (à direita), você verá uma linha vermelha clara que representa onde está o recurso de exibição, além da TextView colocada abaixo do recurso. Quando o dispositivo for dobrado (por exemplo, em 90 graus em uma posição de laptop), nenhuma informação será afetada pelo recurso.

Se você quer saber onde fica o recurso de exibição no emulador de tela dupla, já que esse é um dispositivo articulado, a visualização que representa o recurso é oculta pela articulação. No entanto, se mudarmos o app de estendido para não estendido, ele aparecerá na mesma posição que o recurso, com a altura e a largura corretas.

4dbe464ac71b498e.png

Até agora, você aprendeu a diferença entre dispositivos dobráveis e de tela única.

Um dos recursos fornecidos pelos dispositivos dobráveis é a opção de executar dois apps lado a lado, para que você possa fazer mais com menos. Por exemplo, os usuários podem exibir o app de e-mails em um lado e o app de agenda no outro. Ou podem realizar uma videochamada em uma tela e fazer anotações na outra. Existem muitas possibilidades!

Você pode aproveitar a possibilidade de ter duas telas apenas usando as APIs incluídas no framework do Android. Vamos ver algumas melhorias que você pode fazer.

Iniciar uma atividade na janela adjacente

Essa melhoria permite que seu app inicie uma nova atividade na janela adjacente para aproveitar várias áreas da janela ao mesmo tempo, sem precisar de muito trabalho.

Imagine que você tem um botão que, quando clicado, faz o app iniciar a nova atividade:

  1. Primeiro, crie a função que gerenciará o evento de clique:

intent/MainActivity.kt

private fun openActivityInAdjacentWindow() {
}
  1. Dentro da função, crie a Intent que será usada para iniciar a nova atividade. Neste caso, é a SecondActivity. Ela é uma atividade simples com uma TextView como mensagem:
val intent = Intent(this, SecondActivity::class.java)
  1. Em seguida, defina as sinalizações que iniciarão a nova atividade quando a tela adjacente estiver vazia:
intent.addFlags(
   Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or
       Intent.FLAG_ACTIVITY_NEW_TASK
)

O que essas sinalizações fazem são:

  • FLAG_ACTIVITY_NEW_TASK: se definida, essa atividade se tornará o início de uma nova tarefa na pilha de histórico.
  • FLAG_ACTIVITY_LAUNCH_ADJACENT: essa sinalização é usada no modo de várias janelas em tela dividida. Ela também funciona em dispositivos de tela dupla com telas físicas independentes. A nova atividade será exibida ao lado daquela que a inicia.

Quando a plataforma identificar uma nova tarefa, tentará usar a janela adjacente para alocá-la. A nova tarefa será iniciada acima da tarefa atual para que a nova atividade seja iniciada acima da atual.

  1. A última etapa é simplesmente iniciar a nova atividade usando a intent que criamos:
     startActivity(intent)

O app de teste resultante se comportaria como visto nas animações abaixo. Um clique em um botão inicia uma nova atividade na janela adjacente vazia.

Você pode ver isso sendo executado em um dispositivo de tela dupla e em um dispositivo dobrável no modo de várias janelas:

9696f7fa2ee1e35f.gif a2dc98dae26e3045.gif

Arrastar e soltar

O acréscimo do recurso de arrastar e soltar aos seus apps pode oferecer uma funcionalidade muito útil, que seus usuários adorariam. Essa funcionalidade permite que o app forneça conteúdo a outros apps (implementando o "arrastar"), aceite conteúdo de outros apps (implementando o "soltar") ou inclua os dois recursos. Dessa forma, o app pode fornecer e aceitar conteúdo de outros apps e de si próprio (por exemplo, conteúdo localizado em locais diferentes dentro do mesmo app).

A função "arrastar e soltar" está disponível no framework do Android desde a API 11. Mas, somente no lançamento da compatibilidade com o modo Multi-Window, na API de nível 24, foi que essa função fez mais sentido, já que começou a ser possível arrastar e soltar elementos entre apps que eram executados lado a lado na mesma tela.

Agora, com o lançamento de dispositivos dobráveis que podem ter mais área para o modo de várias janelas ou até mesmo duas telas lógicas, o recurso de arrastar e soltar é ainda mais útil. As situações em que ele pode ser usado incluem um app de tarefas que aceita (solta) texto, transformado em uma nova tarefa quando solto, ou um app de agenda que aceita (solta) conteúdo em um slot de dia/hora e se torna um evento, entre outras.

Para aproveitar essa funcionalidade, os apps precisam implementar o comportamento de arrastar para se tornarem consumidores de dados e/ou o comportamento de soltar para se tornarem produtores de dados.

No seu exemplo, você implementará a ação de arrastar em um app e a de soltar em outro, mas é possível implementar o recurso de arrastar e soltar no mesmo app.

Implementação da ação de arrastar

Seu "app de arrastar" só terá uma TextView e acionará a ação de arrastar quando o usuário clicar em algo e manter clicado.

  1. Primeiro, crie um novo app em File > New > New Project > Empty Activity.
  2. Em seguida, acesse o activity_main.xml que já foi criado. Substitua o layout existente por este:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/drag_text_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="20dp"
       android:text="@string/drag_text"
       android:textSize="30sp" />
</LinearLayout>
  1. Agora, abra o arquivo MainActivity.kt, adicione a tag e chame a função setOnLongClickListener dela:

drag/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnLongClickListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)

       binding.dragTextView.tag = "text_view"
       binding.dragTextView.setOnLongClickListener(this)
   }
  1. Modifique a função onLongClick para que a TextView use essa funcionalidade modificada para o evento onLongClickListener.
override fun onLongClick(view: View): Boolean {
  1. Verifique se o parâmetro receptor é o tipo da View à qual você está adicionando a funcionalidade de arrastar. No seu caso, é uma TextView:
return if (view is TextView) {
  1. Crie um ClipData.item com base no texto que a TextView contém:
val text = ClipData.Item(view.text)
  1. Agora, definimos o MimeType que usaremos:
val mimeType = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
  1. Com os itens criados anteriormente, crie o pacote (uma instância da ClipData) que você usará para compartilhar os dados:
val dataToShare = ClipData(view.tag.toString(), mimeType, text)

Dar feedback aos nossos usuários é muito importante. Por isso, é uma boa ideia fornecer informações visuais sobre o que está sendo arrastado.

  1. Crie uma sombra do conteúdo que estamos arrastando para que os usuários o vejam embaixo do dedo quando a interação de arrastar acontecer:
val dragShadowBuilder = View.DragShadowBuilder(view)
  1. Como você quer autorizar a ação de arrastar e soltar entre diferentes apps, primeiro precisa definir um conjunto de sinalizações que permitirão essa funcionalidade:
val flags =
   View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ

Segundo a documentação, as sinalizações significam:

  • DRAG_FLAG_GLOBAL: indica que uma ação de arrastar pode cruzar os limites das janelas.
  • DRAG_FLAG_GLOBAL_URI_READ: quando esta sinalização for usada com a DRAG_FLAG_GLOBAL, o destinatário da ação de arrastar poderá solicitar acesso de leitura aos URIs de conteúdo contidos no objeto ClipData.
  1. Por fim, chame a função startDragAndDrop na visualização com os componentes criados para que a interação de arrastar comece:
view.startDragAndDrop(dataToShare, dragShadowBuilder, view, flags)
  1. Termine e feche a onLongClick function e a MainActivity:
         true
       } else {
           false
       }
   }
}

Implementação da ação de soltar

No seu exemplo, você está criando um app simples, que tem a funcionalidade de soltar anexa a uma EditText. Essa visualização aceitará dados de texto, que podem vir do nosso app de arrastar que usa a TextView.

Nossa EditText (ou área de soltar) mudará o plano de fundo de acordo com o estágio de arrastar em que estivermos. Com isso, você poderá fornecer aos usuários informações sobre o estado da interação de arrastar e soltar, e eles poderão ver quando será possível soltar o conteúdo.

  1. Primeiro, crie um novo app em File > New > New Project > Empty Activity.
  2. Em seguida, acesse o activity_main.xml que já foi criado. Substitua o layout existente por este:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<EditText
   android:id="@+id/drop_edit_text"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="@android:color/holo_blue_dark"
   android:gravity="top"
   android:hint="@string/drop_text"
   android:textColor="@android:color/white"
   android:textSize="30sp" />

</RelativeLayout>
  1. Agora, abra o arquivo MainActivity.kt e adicione um listener à função EditText setOnDragListener:

drop/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnDragListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)
       binding.dropEditText.setOnDragListener(this)
   }
  1. Modifique a função onDrag para que a EditText, como mencionado acima, possa usar esse callback modificado na função onDragListener.

Essa função será chamada sempre que um novo DragEvent ocorrer, como quando o dedo de um usuário entrar na área de soltar ou sair dela, quando ele soltar o dedo na área de soltar para que essa ação seja realizada, ou quando soltar o dedo fora da área e cancelar a interação de arrastar e soltar.

override fun onDrag(v: View, event: DragEvent): Boolean {
  1. Para reagir aos diferentes DragEvents que serão acionados, adicione uma instrução when para gerenciar os diferentes eventos:
return when (event.action) {
  1. Gerencie o ACTION_DRAG_STARTED que é acionado quando a interação de arrastar começa. Quando esse evento é acionado, a cor da área de soltar muda para que os usuários saibam que a EditText aceita conteúdo que é solto:
DragEvent.ACTION_DRAG_STARTED -> {
       setDragStartedBackground()
       true
}
  1. Gerencie o evento de arrastar ACTION_DRAG_ENTERED que é acionado quando um dedo entra na área de soltar. Mude novamente a cor do plano de fundo da área de soltar para indicar ao usuário que ela está pronta. É claro que você pode omitir esse evento e não mudar o evento em segundo plano. Isso tem apenas fins informativos.
DragEvent.ACTION_DRAG_ENTERED -> {
   setDragEnteredBackground()
   true
}
  1. Gerencie o evento ACTION_DROP agora. Ele é acionado quando os usuários soltam o dedo com o conteúdo arrastado na área de soltar, de forma que a ação de soltar possa ser realizada.
DragEvent.ACTION_DROP -> {
   handleDrop(event)
   true
}

Veremos como gerenciar a ação de soltar mais tarde.

  1. Em seguida, gerencie o evento ACTION_DRAG_ENDED. Ele é acionado após ACTION_DROP, indicando que a ação de arrastar e soltar foi concluída.

Este é um bom momento para restaurar as mudanças que você fez (por exemplo, onde mudou o plano de fundo da área de soltar) para os valores originais.

DragEvent.ACTION_DRAG_ENDED -> {
   clearBackgroundColor()
   true
}
  1. Depois, gerencie o evento ACTION_DRAG_EXITED. Ele é acionado quando os usuários saem da área de soltar (quando o dedo está nessa área, mas sai dela).

Aqui, se você mudou o fundo para destacar a entrada na área de soltar, este é um bom momento para restaurá-lo para o valor anterior.

DragEvent.ACTION_DRAG_EXITED -> {
   setDragStartedBackground()
   true
}
  1. Por fim, resolva o caso "else" da instrução "when" e feche a função onDrag:
      else -> false
   }
}

Agora, vamos ver como gerenciar a ação de soltar. Você viu que, quando o evento ACTION_DROP é acionado, temos que gerenciar a funcionalidade de soltar. Agora, você verá como fazer isso.

  1. Transmita o DragEvent como um parâmetro, já que esse objeto contém os dados que serão arrastados:
private fun handleDrop(event: DragEvent) {
  1. Dentro da função, solicite permissões de arrastar e soltar. Isso é necessário quando você está arrastando e soltando entre diferentes apps.
val dropPermissions = requestDragAndDropPermissions(event)
  1. Com o parâmetro DragEvent, é possível acessar o item clipData criado anteriormente na etapa de arrastar:
val item = event.clipData.getItemAt(0)
  1. Agora, com o item de arrastar, acesse o texto onde ele é mantido, que foi compartilhado. Esse é o texto que a TextView tinha no exemplo de arrastar:
val dragData = item.text.toString()
  1. Agora que você tem os dados reais que foram compartilhados (o texto), basta defini-los na sua área de soltar (nossa EditText) como costuma ser feito ao definir o texto em uma EditText no código:
binding.dropEditText.setText(dragData)
  1. A última etapa é liberar as permissões de arrastar e soltar solicitadas. Se você não fizer isso após a conclusão da ação de soltar, quando a atividade for destruída, as permissões serão liberadas automaticamente. Feche a função e a classe:
      dropPermissions?.release()
   }
}

Depois de implementar a ação de soltar no nosso app simples, podemos executar os dois apps lado a lado e ver como o recurso de arrastar e soltar funciona.

Na animação abaixo, veja como ele funciona, como diferentes eventos de arrastar são acionados e o que você faz ao gerenciá-los (mudando o plano de fundo da área de soltar, dependendo do DragEvent específico, e soltando o conteúdo):

d66c5c24c6ea81b3.gif

Como vimos nesse bloco de conteúdo, usar a Jetpack WindowManager nos ajuda a trabalhar com novos formatos de dispositivos, como os dobráveis.

As informações fornecidas são muito úteis para adaptar nossos apps a esses dispositivos e proporcionar uma experiência melhor quando executados neles.

Veja um resumo do que você aprendeu no codelab:

  • O que são dispositivos dobráveis.
  • Diferenças entre os vários dispositivos dobráveis.
  • Diferenças entre dispositivos dobráveis, de tela única e tablets.
  • Jetpack WindowManager. O que essa API fornece?
  • Uso da Jetpack WindowManager e adaptação dos apps a novos formatos de dispositivos.
  • Aprimorar os apps adicionando mudanças mínimas para iniciar atividades na janela adjacente vazia e implementar a ação de arrastar e soltar que funciona entre apps.

Saiba mais