Tutorial Jetpack Compose: replica dell'app audio Dribbble, parte 1

Jan 27 2023
Implementazione di un'interfaccia utente completamente personalizzata con animazioni complesse: Waveform Animation In Exyte cerchiamo di contribuire all'open-source il più possibile, dal rilascio di librerie e componenti alla scrittura di articoli e tutorial. Un tipo di tutorial che facciamo è la replica: prendere un componente dell'interfaccia utente complesso, implementarlo utilizzando nuovi framework e scrivere un tutorial a fianco.

Implementazione di un'interfaccia utente completamente personalizzata con animazioni complesse: Waveform Animation

In Exyte cerchiamo di contribuire all'open-source il più possibile, dal rilascio di librerie e componenti alla scrittura di articoli e tutorial. Un tipo di tutorial che facciamo è la replica: prendere un componente dell'interfaccia utente complesso, implementarlo utilizzando nuovi framework e scrivere un tutorial a fianco. Abbiamo iniziato con SwiftUI alcuni anni fa, ma oggi finalmente entriamo in Android, utilizzando il framework UI dichiarativo di Google: Jetpack Compose.

Concetto di design del talentuoso Taras Migulko @dribbble

Struttura dello schermo

La nostra app sarà composta da 3 schermate, con transizioni animate tra di loro:

Per facilità di lettura, la replica di queste schermate verrà discussa in più articoli che trattano i seguenti elementi dell'interfaccia utente e animazioni:

  • Forma d'onda dalla prima schermata
  • Pannello delle azioni dalla seconda schermata
  • Collapsing Header dalla terza schermata
  • Trascina la transizione del gesto
  • Transizione elemento condiviso

Creazione della forma d'onda

Ecco cosa vogliamo implementare:

L'intero elemento è costituito da barre del volume verticali animate, distanziate a una distanza regolare l'una dall'altra. Diamo un'occhiata alle dimensioni di base che useremo:

Come ben sai, il mondo dei dispositivi Android è ricco di schermi di diverse dimensioni, e questo pone la seguente domanda: come presentare la forma d'onda sugli schermi dei diversi dispositivi in ​​modo che occupi tutto lo spazio disponibile? Per fare in modo che questo widget si adatti all'intera larghezza dello schermo, puoi aumentare la larghezza delle linee proporzionalmente o puoi aumentare il numero di linee. Abbiamo scelto il secondo modo per preservare la nitidezza dell'immagine. In questo caso il primo passo è calcolare il numero necessario di righe. Per fare ciò, dovremo dividere la larghezza della tela disponibile per la dimensione di una singola barra del volume (larghezza della barra + larghezza dello spazio). Inoltre, dobbiamo utilizzare toInt()invece di roundToInt()perché vogliamo utilizzare solo elementi che si adattino perfettamente alla dimensione, indipendentemente dall'arrotondamento.

val count = (canvasWidth / (barWidthFloat + gapWidthFloat)).toInt().coerceAtMost(MaxLinesCount)

val animatedVolumeWidth = count * (barWidthFloat + gapWidthFloat)
var startOffset = (canvasWidth - animatedVolumeWidth) / 2

val barMinHeight = 0f
val barMaxHeight = canvasHeight / 2f / heightDivider

val heightDivider by animateFloatAsState(
    targetValue = if (isAnimating) 1f else 6f,
    animationSpec = tween(1000, easing = LinearEasing)
)

      
                


val infiniteAnimation = rememberInfiniteTransition()
val animations = mutableListOf<State<Float>>()
val random = remember { Random(System.currentTimeMillis()) }

repeat(15) {
    val durationMillis = random.nextInt(500, 2000)
    animations += infiniteAnimation.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis),
            repeatMode = RepeatMode.Reverse,
         )
    )
}

val initialMultipliers = remember {
    mutableListOf<Float>().apply {
        repeat(MaxLinesCount) { this += random.nextFloat() }
    }
}

1) Ottieni valori di altezza casuali, nel nostro caso vengono ripetuti ogni 15 volte.

2) Aggiungi initialMultipliersa currentSizeper ridurre la possibilità che i valori si ripetano:

3) Usa l'interpolazione lineare per ridimensionare uniformemente l'altezza:

// 1
val currentSize = animations[index % animations.size].value
// 2
var barHeightPercent = initialMultipliers[index] + currentSize
if (barHeightPercent > 1.0f) {
    val diff = barHeightPercent - 1.0f
    barHeightPercent = 1.0f - diff
}
// 3
val barHeight = lerpF(barMinHeight, barMaxHeight, barHeightPercent)

// 1 draw the bar
drawLine(
    color = barColor,
    start = Offset(startOffset, canvasCenterY - barHeight / 2),
    end = Offset(startOffset, canvasCenterY + barHeight / 2),
    strokeWidth = barWidthFloat,
    cap = StrokeCap.Round,
)
// 2 calculate the offset for next bar
startOffset += barWidthFloat + gapWidthFloat

repeat(count) { index ->
    val currentSize = animations[index % animations.size].value
    var barHeightPercent = initialMultipliers[index] + currentSize
    if (barHeightPercent > 1.0f) {
        val diff = barHeightPercent - 1.0f
        barHeightPercent = 1.0f - diff
    }
    val barHeight = lerpF(barMinHeight, barMaxHeight, barHeightPercent)
    drawLine(
        color = barColor,
        start = Offset(startOffset, canvasCenterY - barHeight / 2),
        end = Offset(startOffset, canvasCenterY + barHeight / 2),
        strokeWidth = barWidthFloat,
        cap = StrokeCap.Round,
    )
    startOffset += barWidthFloat + gapWidthFloat
}