Criar layouts adaptáveis

A IU do app precisa ser responsiva para considerar diferentes tamanhos de tela, orientações e formatos. Um layout adaptável muda com base no espaço de tela disponível. Essas mudanças variam de ajustes de layout simples para preencher espaço até mudanças completas de layout para usar mais espaço.

Como um kit de ferramentas de IU declarativo, o Jetpack Compose é adequado para projetar e implementar layouts que se ajustam para renderizar conteúdo de maneira diferente em diversos tamanhos. Este documento contém algumas diretrizes sobre como você pode usar o Compose para tornar a interface responsiva.

Explicitar grandes mudanças de layout para elementos combináveis da tela

Ao usar o Compose para criar o layout de um app inteiro, os elementos combináveis do app e da tela ocupam todo o espaço para renderização fornecido pelo app. Nesse nível do projeto, pode fazer sentido mudar o layout geral de uma tela para aproveitar melhor o espaço disponível em telas maiores.

Evite usar valores físicos e de hardware para tomar decisões de layout. Pode ser tentador tomar decisões com base em um valor tangível fixo (o dispositivo é um tablet? A tela física tem determinada proporção?), mas as respostas a essas perguntas podem não ser úteis para determinar o espaço com que a IU pode trabalhar.

Um diagrama mostrando vários formatos de dispositivos diferentes: um smartphone, um dobrável, um tablet e um laptop

Em tablets, um app pode estar sendo executado no modo de várias janelas, o que significa que ele pode dividir a tela com outro app. No ChromeOS, um app pode estar em uma janela redimensionável. Pode haver até mais de uma tela física, como em um dispositivo dobrável. Em todos esses casos, o tamanho da tela física não é relevante para decidir como exibir conteúdo.

Em vez disso, tome decisões com base na parte real da tela alocada para o app, como as métricas de janela atuais fornecidas pela biblioteca WindowManager do Jetpack. Para ver como usar o WindowManager em um app do Compose, confira o app JetNews de exemplo.

Essa abordagem tornará seu app mais flexível, porque ele se comportará bem em todos os cenários acima. Tornar os layouts adaptáveis ao espaço de tela disponível para eles também reduz a quantidade de processamento especial para oferecer suporte a plataformas como o ChromeOS e a formatos como tablets e dobráveis.

Ao observar o espaço relevante disponível para o app, é útil converter o tamanho bruto em uma classe de tamanho significativa, conforme descrito em Classes de tamanho de janela. Esse processo agrupa os tamanhos em buckets de tamanho padrão, que são pontos de interrupção projetados para equilibrar simplicidade e flexibilidade de modo a otimizar o app para a maioria dos casos. Essas classes de tamanho se referem à janela geral do app. Portanto, use-as para decisões de layout que afetam o layout geral da tela. É possível transmitir essas classes de tamanho como estado ou executar uma lógica adicional para criar um estado derivado a fim de transmitir para elementos combináveis aninhados.

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val windowSizeClass = calculateWindowSizeClass(this)
            MyApp(windowSizeClass)
        }
    }
}
@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the top app bar.
    val showTopAppBar = windowSizeClass.heightSizeClass != WindowHeightSizeClass.Compact

    // MyScreen knows nothing about window sizes, and performs logic
    // based on a Boolean flag.
    MyScreen(
        showTopAppBar = showTopAppBar,
        /* ... */
    )
}

Essa abordagem em camadas limita a lógica do tamanho da tela a um único local, em vez de distribuí-la pelo app em muitos lugares que precisam ser sincronizados. Esse único local produz um estado, que pode ser transmitido explicitamente para outros elementos combináveis, assim como você faria para qualquer outro estado do app. A transmissão explícita do estado simplifica os elementos combináveis individuais, já que eles vão ser apenas funções combináveis normais que usam a classe de tamanho ou a configuração especificada com outros dados.

Elementos combináveis aninhados flexíveis são reutilizáveis

Os elementos compostos são mais reutilizáveis quando podem ser colocados em uma grande variedade de lugares. Se um elemento combinável presume que será sempre colocado em determinado local com um tamanho específico, é mais difícil reutilizá-lo em outro local ou com uma quantidade diferente de espaço disponível. Isso também significa que elementos combináveis individuais e reutilizáveis precisam ser evitados de forma implícita, dependendo das informações de tamanho "globais".

Vamos conferir um exemplo: imagine um elemento combinável aninhado que implemente um layout de detalhes da lista, que pode mostrar um ou dois painéis lado a lado.

Captura de tela de um app mostrando dois painéis lado a lado

Figura 1. Captura de tela de um app mostrando um layout típico de lista/detalhes. 1 é a área de lista e 2 é a área de detalhes.

Queremos que essa decisão faça parte do layout geral do app. Por isso, é necessário transmitir a decisão de um elemento combinável da tela, conforme mostrado acima:

@Composable
fun AdaptivePane(
    showOnePane: Boolean,
    /* ... */
) {
    if (showOnePane) {
        OnePane(/* ... */)
    } else {
        TwoPane(/* ... */)
    }
}

E se você quiser que uma função combinável mude o layout de forma independente, com base no espaço disponível? Por exemplo, um card que quer mostrar detalhes adicionais, se houver espaço. Queremos executar uma lógica com base em um tamanho disponível, mas em qual tamanho especificamente?

Exemplos de dois cards diferentes: um card estreito que mostra somente um ícone e um título e um card mais amplo com o ícone, o título e uma breve descrição

Como vimos acima, precisamos evitar o uso do tamanho da tela do dispositivo. Esse tamanho não é exato nem para várias telas e nem se o app não estiver em tela cheia.

Como o elemento combinável não está no nível da tela, também não podemos usar as métricas da janela atual diretamente para maximizar a reutilização. Se o componente for colocado com padding (como para encartes) ou se houver componentes como colunas de navegação ou barras de apps, a quantidade de espaço disponível para o elemento combinável pode ser significativamente diferente do espaço geral disponível para o app.

Portanto, precisamos usar a largura em que o elemento composto é realmente renderizado. Temos duas opções para conseguir essa largura:

Caso queira mudar onde ou como o conteúdo é exibido, use um conjunto de modificadores ou um layout personalizado para tornar o layout responsivo. Isso pode ser feito de forma simples, por exemplo, preenchendo todo o espaço disponível com um filho ou criando o layout de filhos com várias colunas, se houver espaço suficiente.

Se quiser mudar o que você mostra, use BoxWithConstraints como uma alternativa mais eficiente. Esse elemento combinável oferece restrições de medição que podem ser usadas para chamar diferentes elementos com base no espaço disponível. No entanto, isso tem algumas desvantagens, porque a função BoxWithConstraints adia a composição até a fase do layout, quando essas restrições são conhecidas, fazendo com que mais trabalho seja realizado durante o layout.

@Composable
fun Card(/* ... */) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(/* ... */)
                Title(/* ... */)
            }
        } else {
            Row {
                Column {
                    Title(/* ... */)
                    Description(/* ... */)
                }
                Image(/* ... */)
            }
        }
    }
}

Garantir que todos os dados estejam disponíveis para diferentes tamanhos

Ao aproveitar melhor o espaço extra, talvez seja possível exibir mais conteúdo para o usuário em uma tela grande do que em uma tela pequena. Ao implementar um elemento combinável com esse comportamento, pode ser tentador fazer isso e carregar dados como um efeito colateral do tamanho atual.

No entanto, isso vai contra os princípios do fluxo de dados unidirecional, em que os dados podem ser elevados e simplesmente fornecidos a elementos que podem ser compostos e renderizados corretamente. Dados suficientes precisam ser fornecidos ao elemento combinável para que ele sempre tenha o necessário para ser exibido em qualquer tamanho, mesmo que parte dos dados nem sempre seja usada.

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(description)
                }
                Image(imageUrl)
            }
        }
    }
}

Com base no exemplo de Card, observe que sempre transmitimos a description para o Card. Embora a description seja usada apenas quando a largura permite a exibição, o Card sempre precisa dela, independentemente da largura disponível.

A transmissão contínua de dados deixa os layouts adaptáveis mais simples, tornando-os menos "com estado". Também evita o acionamento de efeitos colaterais ao alternar entre tamanhos, o que pode ocorrer devido a redimensionamento de janela, a mudança de orientação ou à dobra e o desdobramento de um dispositivo.

Esse princípio também permite preservar o estado em todas as mudanças de layout. Ao elevar informações que não podem ser usadas em todos os tamanhos, é possível preservar o estado do usuário conforme o tamanho do layout muda. Por exemplo, podemos elevar uma sinalização booleana showMore para que o estado do usuário seja preservado quando o redimensionamento fizer o layout alternar entre ocultar e exibir a descrição:

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    var showMore by remember { mutableStateOf(false) }

    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(
                        description = description,
                        showMore = showMore,
                        onShowMoreToggled = { newValue ->
                            showMore = newValue
                        }
                    )
                }
                Image(imageUrl)
            }
        }
    }
}

Saiba mais

Para saber mais sobre layouts personalizados no Compose, consulte os recursos abaixo.

Apps de exemplo

  • Os layouts canônicos de telas grandes são um repositório de padrões de design comprovados que oferecem uma experiência do usuário ideal em dispositivos de tela grande.
  • O JetNews mostra como projetar um app que adapta a IU para usar o espaço disponível.
  • O Reply é um exemplo adaptável para compatibilidade com dispositivos móveis, tablets e dobráveis
  • O Now in Android (link em inglês) é um app que usa layouts adaptáveis para oferecer suporte a diferentes tamanhos de tela.

Vídeos