Fazendo Parecer Vivo
Até este ponto, o menu se abre, os botões expandem suavemente e a animação escalonada parece polida. Do ponto de vista funcional, estamos prontos. No entanto, algo ainda não está certo. O que falta é um círculo que localmente se arredonda em direção a cada item emergente, dando-lhe o "jelly" pelo qual todo o projeto é nomeado.
Pensando no Problema de Forma Diferente
Meu primeiro instinto foi construir um Shape personalizado. O Compose já nos permite definir contornos arbitrários, então isso parecia o lugar natural para começar.
Rapidamente ficou claro que não era a abstração certa. Um Shape descreve o que um contorno parece. Mas o que eu precisava era algo que pudesse decidir como partes individuais daquele contorno deveriam se mover em cada quadro.
Não anime uma forma... Anime os pontos que a definem...
Assim que comecei a pensar dessa forma, a solução ficou muito mais simples.
Representando um Círculo como Dados
Ao invés de tratar o botão como um círculo, JellyFab trata-o como uma coleção de pontos espaçados uniformemente ao longo do seu contorno.
val sampleCount = 36
val points = MutableList(sampleCount) { index ->
val angle =
-Math.PI / 2 +
index * (2 * Math.PI / sampleCount)
Offset(
(cx + baseRadius * cos(angle)).toFloat(),
(cy + baseRadius * sin(angle)).toFloat()
)
}Se conectássemos esses pontos, simplesmente redesenharíamos o círculo original. A parte interessante vem a seguir. Em vez de mover toda a forma, apenas movemos os pontos mais próximos do botão de ação que está se expandindo. Essa única decisão é o que cria a ilusão de uma superfície macia.
Esticando a Superfície
Cada ação primária já tem um ângulo. Esses mesmos ângulos agora se tornam direções de deformação. À medida que o item do menu expande, os pontos próximos são empurrados para fora enquanto o resto do círculo permanece quase perfeitamente imóvel.
val push = baseRadius * bounceFactor * value
for (point in points) {
val direction = point - center
val angle = atan2(direction.y, direction.x)
val difference = normalizeAngleRad(angle - bulgeAngle)
val fall = exp(-(diff * diff) / (2 * 0.6f * 0.6f))
val len = sqrt(direction.x * direction.x + direction.y * direction.y)
val newLen = len + push * fall
...
}Observe o que está acontecendo aqui. Agora, estamos pensando em como cada ponto se relaciona com a direção de um bulge. Cada ponto faz uma pergunta simples:
Quão perto estou da direção que está se expandindo?
Quanto mais próximo estiver, mais ele se move. Quanto mais distante estiver, menos muda. Tudo o resto vem dessa ideia.
Por que uma curva Gaussiana?
Uma interrupção abrupta produziria algo como isso:
████████░░░░░░░░
Todos os pontos dentro da região se moveriam a mesma quantidade. Todos os pontos fora dela não se moveriam em absoluto. O resultado seria mecânico. Em vez disso, JellyFab usa uma queda Gaussiana.
val falloff = exp(-(difference * difference) / (2 * sigma * sigma))Se você já aplicou um desfoque no Photoshop ou usou uma curva em forma de sino na estatística, já viu essa função antes. A mesma ideia funciona perfeitamente aqui.
- Pontos diretamente à frente da ação de expansão recebem toda a empurrada.
- Pontos ligeiramente ao lado recebem uma empurrada menor.
- Pontos do lado oposto do botão mal se movem em absoluto.
Em vez de criar um saliência aguda, a deformação se mescla naturalmente com o resto do círculo.
Do Ponto de Volta para um Blob
Após a deformação, o botão não é mais um círculo. São apenas 36 pontos deslocados. Conectá-los com linhas retas funciona tecnicamente... mas o resultado parece como um modelo low-poly.
Em vez disso, os pontos são costurados juntos usando curvas quadráticas de Bézier.
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (...) {
quadraticTo(...)
}
close()
}Ao invés de desenhar do ponto para o ponto, cada segmento se curva suavemente através do ponto anterior antes de alcançar a metade do próximo. O resultado é uma superfície contínua que parece orgânica em vez de poligonal.

