Gráficas desde cero: datos, ejes y animación de entrada
Construir una visualización de datos con el scope de dibujo: la transformación del dominio de los datos al espacio de píxeles, el trazado de ejes y retícula con texto medido, la animación de entrada mediante progreso leído en la fase de dibujo, y el mínimo de interacción y accesibilidad que separa un adorno de un gráfico usable.
Una gráfica es el caso de estudio perfecto del dibujo personalizado porque obliga a resolver de golpe los tres problemas del oficio: convertir números arbitrarios en coordenadas de pantalla, componer un dibujo con muchas piezas sin que el frame se dispare, y hacer que todo eso responda a un cambio de datos y a un dedo. Además obliga a algo incómodo: una gráfica que solo se ve bonita está rota, porque su función es transmitir magnitudes con precisión y no hay ninguna librería que pueda decidir por ti qué es leal a los datos.
- Transformar un dominio de datos arbitrario al espacio de píxeles con margen para los ejes.
- Trazar ejes, retícula y etiquetas midiendo el texto antes de colocarlo.
- Animar la entrada del gráfico leyendo el progreso en la fase de dibujo.
- Añadir interacción por gestos y una semántica accesible sin destruir el rendimiento.
Del dominio de los datos al espacio de píxeles
Todo gráfico es una función afín entre dos rectángulos. A un lado está el dominio: el rango real de tus valores, que puede ir de mil doscientos a mil ochocientos, o de menos cinco a cuarenta. Al otro está el lienzo útil: el área de dibujo menos el espacio que reservas para etiquetas y ejes. La conversión es una interpolación por cada eje, con la particularidad de que el vertical va invertido porque en pantalla la ordenada crece hacia abajo.
data class Escala(val minimo: Float, val maximo: Float, val desde: Float, val hasta: Float) {
fun mapear(valor: Float): Float {
val t = (valor - minimo) / (maximo - minimo)
return desde + t * (hasta - desde)
}
}
val ejeY = Escala(minimo = 0f, maximo = maxDato, desde = size.height - margenAbajo, hasta = margenArriba)
val ejeX = Escala(minimo = 0f, maximo = (datos.size - 1).toFloat(), desde = margenIzq, hasta = size.width)
Invertir el eje vertical se reduce, con esta abstracción, a pasar el borde inferior como origen y el superior como destino. Dos decisiones de diseño acompañan siempre a la escala. La primera es si el mínimo del dominio se fija en cero o en el mínimo real de los datos: anclar en cero es honesto para magnitudes absolutas, mientras que recortar exagera visualmente diferencias pequeñas, un truco de manipulación tan viejo como los gráficos de prensa. La segunda es el redondeo del rango a números legibles, porque un eje que llega a mil setecientos ochenta y tres pide una etiqueta imposible de leer; el algoritmo habitual amplía el rango hasta el siguiente múltiplo agradable de una potencia de diez.
flowchart LR D[serie de datos] --> R[dominio minimo y maximo] R --> N[redondeo a numeros legibles] N --> E[escala afin a pixeles] E --> P[construccion del Path] P --> C[cache por datos y tamano] C --> F[dibujo del frame] style F fill:#a6e3a1,color:#11111b
El redondeo a números legibles merece una función propia, porque se reutiliza en cada gráfico que escribas. La idea es tomar la magnitud del rango, quedarse con su potencia de diez y elegir el múltiplo agradable inmediatamente superior entre uno, dos, cinco y diez.
fun pasoLegible(rango: Float, divisiones: Int): Float {
val bruto = rango / divisiones
val potencia = 10f.pow(floor(log10(bruto.toDouble())).toFloat())
val normalizado = bruto / potencia
val bonito = when {
normalizado <= 1f -> 1f
normalizado <= 2f -> 2f
normalizado <= 5f -> 5f
else -> 10f
}
return bonito * potencia
}
Con ese paso, las divisiones del eje salen de multiplicar sucesivamente desde el mínimo redondeado hacia abajo, y las etiquetas dejan de tener decimales absurdos sin que nadie las toque a mano.
El flujo del diagrama esconde la decisión de rendimiento más importante de la lección: todo lo que ocurre antes de la última caja debe suceder una sola vez por cambio de datos o de tamaño, y no en cada frame. El sitio natural para ello es el bloque exterior de drawWithCache, que se reejecuta cuando cambian el tamaño o las claves que lee, o un remember con los datos como clave si el cálculo no depende del área.
Ejes, retícula y texto medido
El dibujo de la retícula es aritmética simple: se eligen unas cuantas divisiones, se mapea cada valor con la escala y se traza una línea horizontal fina de lado a lado. Lo que no es trivial es el texto, porque hasta hace poco pintar una cadena dentro del scope de dibujo obligaba a bajar al lienzo nativo. Hoy existe rememberTextMeasurer, que devuelve un medidor capaz de producir un resultado de layout de texto que después se pinta con drawText.
val medidor = rememberTextMeasurer()
Canvas(Modifier.fillMaxWidth().height(220.dp)) {
divisiones.forEach { valor ->
val y = ejeY.mapear(valor)
drawLine(Color(0xFF313244), Offset(margenIzq, y), Offset(size.width, y), 1.dp.toPx())
val etiqueta = medidor.measure(valor.toInt().toString(), estiloEtiqueta)
drawText(
textLayoutResult = etiqueta,
topLeft = Offset(0f, y - etiqueta.size.height / 2f),
)
}
}
Medir antes de colocar es lo que permite alinear la etiqueta con su línea en lugar de dejarla flotando: etiqueta.size da el ancho y el alto en píxeles, y con ellos se calcula el desplazamiento que centra el texto verticalmente sobre la retícula o lo alinea a la derecha del margen. Ese margen, por cierto, no puede ser una constante inventada: la forma correcta de fijarlo es medir la etiqueta más larga del eje y reservar su ancho, de manera que el gráfico se adapte solo cuando los datos pasan de decenas a millares.
El margen izquierdo debería derivarse del ancho de la etiqueta más ancha, y el inferior de la altura de una línea de texto más su separación. Un gráfico con márgenes fijos se rompe con una fuente grande por accesibilidad, con un idioma de números largos o con un cambio de escala; uno que mide se adapta sin que nadie lo toque.
La animación de entrada
Animar la aparición de una gráfica es donde se juntan todas las lecciones anteriores. La técnica es siempre la misma: un Animatable que va de cero a uno, lanzado desde un LaunchedEffect cuando llegan los datos, y una lectura de ese valor dentro de la lambda de dibujo para que la animación solo invalide la fase de pintado.
Lo que cambia es qué significa el progreso. En un gráfico de barras suele multiplicar la altura de cada barra, con un pequeño desfase por índice que produce la cascada característica. En un gráfico de líneas hay dos escuelas: interpolar cada ordenada desde la línea base, que hace crecer la curva hacia arriba, o dejar la curva quieta y revelarla de izquierda a derecha extrayendo con PathMeasure el fragmento que corresponde al porcentaje recorrido, que es el efecto de trazo dibujándose solo.
val progreso = remember { Animatable(0f) }
LaunchedEffect(datos) { progreso.animateTo(1f, tween(700, easing = FastOutSlowInEasing)) }
Modifier.drawWithCache {
val completo = construirPath(datos, ejeX, ejeY)
val medidor = PathMeasure().apply { setPath(completo, false) }
val visible = Path()
onDrawBehind {
visible.reset()
medidor.getSegment(0f, medidor.length * progreso.value, visible, true)
drawPath(visible, color = Color(0xFF89B4FA), style = Stroke(width = 3.dp.toPx()))
}
}
Ese fragmento condensa la disciplina completa: el camino completo y el medidor se construyen en el bloque de caché, el Path de salida se reutiliza llamando a reset en vez de crearse cada frame, y la única lectura de estado ocurre en la lambda interna. Setenta frames de animación no producen ni una recomposición ni una asignación de camino nuevo.
Si lanzas el LaunchedEffect con una clave que cambia más de lo que crees —una lista recreada en cada composición, por ejemplo— la animación se reinicia sin parar y el gráfico parpadea. Usa como clave un identificador estable de la serie, no la instancia de la colección, y recuerda que una List recién construida no es igual a la anterior salvo que sus elementos lo sean.
Interacción, accesibilidad y coste
Un gráfico serio responde al dedo. Con Modifier.pointerInput y detectTapGestures o detectHorizontalDragGestures se obtiene la posición del toque en píxeles; invertir la escala del eje horizontal devuelve el índice del dato más cercano, que se guarda en un estado para resaltar el punto y mostrar una etiqueta flotante. La inversión es literalmente la fórmula de la escala despejada, y por eso conviene tener la transformación en un objeto y no dispersa en el dibujo.
Escala aislada
La transformación entre dominio y píxeles vive en un objeto propio, no repartida por el dibujo. Así se invierte para la interacción y se prueba con tests unitarios sin necesidad de pantalla.
Cálculo cacheado
Dominio, divisiones y caminos se construyen en remember o en el bloque exterior de drawWithCache. En el frame solo quedan órdenes de pintura y la lectura del progreso.
Interacción real
Un gráfico que no responde al dedo obliga a adivinar valores. La selección por toque con etiqueta flotante convierte una ilustración en una herramienta de consulta.
Semántica audible
Una descripción que resuma rango, tendencia y extremos hace que TalkBack pueda comunicar la misma información que transmite la forma de la curva.
Queda la parte que casi todo el mundo omite. Un Canvas es, para el sistema de accesibilidad, un rectángulo mudo: TalkBack no tiene nada que anunciar. La corrección mínima es Modifier.semantics con una descripción que resuma la serie —rango, tendencia y valores extremos— y, si el gráfico es interactivo, exponer cada punto con su propia semántica para poder recorrerlos. No es un extra: un gráfico que no se puede oír no informa a una parte de tus usuarios.
Cuando pintas una barra estás afirmando algo sobre el mundo, y el usuario lo creerá sin verificarlo. Esa es la diferencia radical entre esta lección y las anteriores: un degradado mal elegido resulta feo, pero un eje que no arranca en cero miente, y lo hace con la autoridad silenciosa de lo visual. Toda la cadena que has construido aquí —dominio, redondeo, escala, píxeles— es una cadena de decisiones interpretativas disfrazadas de aritmética, y cada eslabón admite una versión honesta y otra tramposa: recortar el rango para que una diferencia del dos por ciento parezca un abismo, elegir divisiones que oculten un pico, suavizar una curva con curvas de Bézier tan generosas que inventen valores intermedios que nunca se midieron, animar una entrada tan lenta que el usuario juzgue la tendencia antes de ver el dato final. Ninguna de esas decisiones la toma la librería: las tomas tú, en las cuarenta líneas que separan una lista de números de un conjunto de coordenadas. Por eso el dibujo de datos es la frontera donde la programación gráfica deja de ser técnica y se vuelve ética profesional, y por eso las convenciones existen: el cero en el eje de magnitudes, las etiquetas legibles, los puntos reales marcados sobre la curva interpolada, la escala explícita cuando se recorta. Quien las conoce y las respeta produce gráficos que resisten el escrutinio; quien las ignora produce ilustraciones persuasivas, que es exactamente lo contrario de lo que le pidieron. Y hay un corolario práctico: el mismo rigor que exige la honestidad —tener la transformación aislada, el dominio explícito, los extremos calculados y no inventados— es el que produce código limpio, cacheable y adaptable. La gráfica correcta y la gráfica eficiente resultan ser, casi siempre, la misma gráfica.
- Escribe una función de escala reutilizable y úsala para trazar una serie de veinte valores como línea, con el eje vertical anclado en cero.
- Añade retícula y etiquetas con
rememberTextMeasurer, derivando el margen izquierdo del ancho de la etiqueta más larga. - Anima la entrada con
PathMeasurey comprueba en el inspector que la animación no provoca ni una recomposición. - Implementa la selección por toque invirtiendo la escala horizontal, y añade una descripción semántica que TalkBack pueda leer en voz alta.