Lazy layout hexagonal no Jetpack Compose

Nelson Glauber
8 min readJun 18, 2023

--

A implementação de UI no Android com o Jetpack Compose é sem dúvida muito simples comparado ao que tínhamos anteriormente utilizando Views e XML. Isso obviamente também se aplica às listagens. Utilizar um LazyColumn, LazyRow, LazyHorizontalGrid ou LazyVerticalGrid, é muito mais simples do que o boilerplate de uma RecyclerView.

A ideia dessa postagem começou com esse vídeo do Android Developers:

O vídeo mostra uma listagem onde os itens possuem um formato hexagonal. Por ser um vídeo curto, ele não mostra como implementar os principais detalhes… Foi então que eu achei a versão completa desse vídeo… 👇🏼

Mesmo neste vídeo de cerca de 4 minutos, detalhes importantes de como essa listagem foi implementada não foram explicados:

  • Como deixar um item em formato hexagonal?
  • Como fazer os itens da lista ficarem corretamente alinhados horizontalmente e verticalmente?
  • Como ajustar a listagem para exibir corretamente em portrait e landscape?

Nesse artigo vou tentar explicar como eu resolvi os itens listados acima.

Como deixar um item no formato hexagonal?

Para que um elemento de UI tenha um formato hexagonal, é preciso criar um objeto que implemente a interface Shape. A única função a ser implementada é a createOutline(Size,LayoutDirection,Density) que é exatamente onde o formato do Shape é definido utilizando um objeto Path.

val HexagonItemShape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val minSize = min(size.width, size.height)
val angleRadians = Math.toRadians(60.0).toFloat()
val radius = minSize / 2f
return Outline.Generic(
Path().apply {
(0..5).forEach { i ->
val currentAngle = angleRadians * i
val x = radius + radius * cos(currentAngle)
val y = radius + radius * sin(currentAngle)
if (i == 0) moveTo(x, y) else lineTo(x, y)
}
close()
}
)
}
}

Sem dúvida, a parte mais complicada desse código é a criação do Path. Se você não é bom em geometria (assim como eu), pode ser que a imagem abaixo ajude no entendimento.

Definição de um Shape hexagonal no Jetpack Compose

Como todo elemento de UI, sua área é representada por um quadrilátero. Então é preciso calcular cada coordenada x e y de cada vértice do hexágono dentro da área do componente. É atribuído a minSize o menor tamanho entre a altura e largura — para garantir que o hexágono caiba na área, e então é calculado o raio (radius = minSize / 2).

O cálculo da posição de cada vértice do hexágono é feito da seguinte forma:

x = center_x + raio * cosseno(angulo desejado em radianos)
y = center_y + raio * seno(angulo desejado em radianos)

Neste exemplo, como a altura e a largura são iguais, o center_x e center_y serão os mesmos: o valor do raio.

x = raio + raio * cosseno(angulo desejado em radianos)
y = raio + raio * seno(angulo desejado em radianos)

Dentro do bloco forEach a primeira posição é do ângulo de 0º (que fica no meio, do lado direito), então apenas movemos a posição do Path para posição calculada usando moveTo(x,y). Como um círculo tem 360º e queremos um hexágono (6 lados), cada nova posição é calculada incrementando o ângulo em 60º (6 x 60 = 360) e uma linha é traçada da posição anterior para a nova usando lineTo(x,y). Ao invocar o método close(), o Path é encerrado formando a forma geométrica desejada.

Lista de itens no formato hexagonal

Para listar os itens, primeiramente é preciso definir o item da lista que terá o formato hexagonal.

@Composable
fun HexagonItem(text: String, hexagonSize: Dp) {
Box(
modifier = Modifier
.size(hexagonSize)
.clip(HexagonItemShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.Center
) {
Text(text = text)
}
}

Conforme apresentado no vídeo que motivou esse artigo, basta utilizar a função clip passando o HexagonItemShape como parâmetro e o item terá esse formato. Para listar os itens hexagonais é bem simples…

@Composable
fun CustomLazyListScreen() {
val list = remember {
(0..50).map { "Item $it" }.toList()
}
BoxWithConstraints(Modifier.fillMaxWidth()) {
val itemSize = (if (maxHeight < maxWidth) maxHeight else maxWidth) * .5f
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(list) {
HexagonItem(
text = it,
hexagonSize = itemSize
)
}
}
}
}

Foi criada uma lista de strings simples para utilizar como conteúdo da tela. Para não estabelecer um tamanho fixo para o hexágono (como feito no vídeo), foi utilizado o BoxWithConstraints de modo a obter a largura/altura máxima da tela (maxWidth e maxHeight) e definir o tamanho do hexágono como 50% da largura ou altura da tela (o que for menor). Isso é importante para que a tela funcione bem tanto em portrait quanto em landscape. Ao executar a aplicação, o seguinte resultado deve ser exibido:

Lista de itens em formato hexagonal

Ao seguir apenas a orientação do vídeo, definindo o verticalArrangement com um valor negativo, os itens ficarão sobrepostos, mas eles não ficarão alinhados corretamente.

Como fazer os itens ficarem alinhados?

É preciso fazer os seguintes ajustes na função CustomLazyListScreen:

@Composable
fun CustomLazyListScreen() {
//...
BoxWithConstraints(Modifier.fillMaxWidth()) {
// ...
val overlap = (-110).dp // MAGIC NUMBER #1!!!
LazyColumn(
//. ..
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(overlap),
) {
itemsIndexed(list) { index, item ->
HexagonItem(
text = item,
index = index,
hexagonSize = itemSize
)
}
}
}
}

Foi configurado na LazyColumn para que os itens sejam exibidos centralizados (horizontalAligment) e seguindo a orientação do vídeo, foi utilizado um espaçamento negativo no arranjo vertical (verticalArrangement) utilizando a função Arragement.spacedBy. Perceba que foi utilizado um "número mágico" 🪄(assim como foi feito no vídeo), mas que será explicado mais adiante.

Outra mudança foi que o HexagonItem, agora recebe o índice to item da lista, que será necessário para posicionar o componente na listagem. Os itens pares ficarão à esquerda e os ímpares à direita.

@Composable
fun HexagonItem(text: String, index: Int, hexagonSize: Dp) {
val paddingValue = 150.dp // MAGIC NUMBER #2
Box(
modifier = Modifier
.padding(
start = if (index % 2 == 1) paddingValue else 0.dp,
end = if (index % 2 == 0) paddingValue else 0.dp,
)
.size(hexagonSize)
.clip(HexagonItemShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.Center
) {
Text(text = text)
}
}

Aqui temos mais um "número mágico" sendo utilizado para adicionar um espaçamento (padding) à esquerda ou à direita do componente dependendo do alinhamento.

Ao executar a aplicação, o resultado será algo similar à imagem a seguir:

Listagem com itens hexagonais funcionando parcialmente

Perfeito! Temos uma lista com os itens hexagonais perfeitamente alinhados! 👏🏼

Mas ainda há um problema: ao colocar a aplicação em landscape, os itens não são exibidos corretamente… 🤔

Listagem com itens hexagonais com problema em landscape

Lembra dos números mágicos no código? Então, eles não podem ser mágicos. 🤦🏻‍♂️ Precisam ser calculados corretamente. 🤓

Como ajustar a listagem para exibir em landscape?

Ao observar na figura utilizada para explicar o desenho do hexágono, é possível perceber que a altura do hexágono é menor que a altura do quadrado no qual ele é desenhado. Então é preciso calcular esse diferença para poder ter o valor correto da sobreposição dos itens da lista. A função itemOverlap listada a seguir realiza esse trabalho.

private fun itemOverlap(density: Density, size: Dp): Dp {
return with(density) {
val sizeInPx = size.toPx()
val radius = sizeInPx / 2f
val y = radius + radius * sin(Math.toRadians(240.0))
(- radius - y).toFloat().toDp()
}
}

A função recebe como parâmetro um objeto Density, já que é necessário fazer o cálculo em pixels (não em Dp) por conta de precisão. Para explicar essa função, vou recorrer novamente a uma figura…

Cálculo do valor da sobreposição (overlap) dos elementos da lista

O valor do tamanho da sobreposição (overlap) deve ser calculada baseada na diferença entre o raio e o topo do hexágono. Para esse cálculo, foi obtida a posição x,y do ângulo de 240º, mas poderia ser no de 300º — ou com variação do cálculo, com os ângulos de 60º ou 120º.

Agora, basta utilizar a função na CustomLazyListScreen:

@Composable
fun CustomLazyListScreen() {
//...
BoxWithConstraints(Modifier.fillMaxWidth()) {
val itemSize = (if (maxHeight < maxWidth) maxHeight else maxWidth) * .5f
val overlap = itemOverlap(LocalDensity.current, itemSize)
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(overlap)
) {
//...
}
}
}

O problema da sobreposição dos itens está resolvido. Agora é preciso resolver o outro número mágico na função HexagonItem.

@Composable
fun HexagonItem(text: String, index: Int, hexagonSize: Dp) {
val paddingValue = hexagonSize * .75f
// ...
}

Esse foi bem mais fácil resolver. Só é multiplicar por 0.75!💡 Mas esse não é outro "número mágico"? 🤔 Não! Aqui estamos definindo que o espaçamento é de 75% do tamanho do hexágono como pode ser observado na figura abaixo:

Cálculo do padding do item da lista

Pronto! Agora, ao executar a aplicação ela deverá funcionar em portrait e landscape. Entretanto, os hexágonos não terão nenhum espaço entre eles.

Caso queira adicionar um espaçamento entre os itens, é só ajustar o valor que preferir…

// CustomLazyListScreen
val overlap = itemOverlap(LocalDensity.current, itemSize) + 4.dp

// HexagonItem
val paddingValue = 8.dp + hexagonSize * .75f

O resultado pode ser visto a seguir:

Conclusão

Esse artigo demonstra mais uma vez como é simples fazer as coisas no Jetpack Compose. A maior dificuldade desse exemplo foi minha grande deficiência em geometria, pois não tive aulas no colégio e depois realmente não me dediquei a aprender. Tomara que não tenha escrito nenhum absurdo, mas essa foi minha linha de raciocínio.😅

O código completo encontra-se no meu GitHub, neste repositório, e o arquivo específico é esse aqui. 👈🏼

Caso eu tenha cometido algum erro, ou se você achar uma implementação mais simples e eficiente, por gentileza, deixe seu o comentário. 😉

4br4ç05,
nglauber

--

--

Nelson Glauber

Android Developer. Google Developer Expert for Android/Kotlin. Author of “Dominando o Android”.