Lazy layout hexagonal no Jetpack Compose
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.
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:
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:
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… 🤔
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…
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:
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