Preservación del estado y almacenamiento persistente

La conservación del estado y el almacenamiento persistente son aspectos no triviales de las apps de tinta, en especial en Compose. Los objetos de datos principales, como las propiedades del pincel y los puntos que forman un trazo, son complejos y no se conservan automáticamente. Esto requiere una estrategia deliberada para guardar el estado en situaciones como los cambios de configuración y guardar de forma permanente los dibujos de un usuario en una base de datos.

Preservación del estado

En Jetpack Compose, el estado de la IU se suele administrar con remember y rememberSaveable. Si bien rememberSaveable ofrece conservación automática del estado en los cambios de configuración, sus capacidades integradas se limitan a los tipos de datos primitivos y los objetos que implementan Parcelable o Serializable.

Para los objetos personalizados que contienen propiedades complejas, como Brush, debes definir mecanismos explícitos de serialización y deserialización con un guardador de estado personalizado. Si defines un Saver personalizado para el objeto Brush, puedes conservar los atributos esenciales del pincel cuando se producen cambios en la configuración, como se muestra en el siguiente ejemplo de brushStateSaver.

fun brushStateSaver(converters: Converters): Saver<MutableState<Brush>, SerializedBrush> = Saver(
    save = { converters.serializeBrush(it.value) },
    restore = { mutableStateOf(converters.deserializeBrush(it)) },
)

Luego, puedes usar el Saver personalizado para conservar el estado del pincel seleccionado:

val currentBrush = rememberSaveable(saver = brushStateSaver(Converters())) { mutableStateOf(defaultBrush) }

Almacenamiento persistente

Para habilitar funciones como guardar y cargar documentos, y la posible colaboración en tiempo real, almacenamos los trazos y los datos asociados en un formato serializado. En el caso de la API de Ink, es necesaria la serialización y deserialización manuales.

Para restablecer un trazo con precisión, guarda su Brush y StrokeInputBatch.

El módulo Storage simplifica la serialización compacta de la parte más compleja: StrokeInputBatch.

Para guardar un trazo, haz lo siguiente:

  • Serializa el StrokeInputBatch con la función de codificación del módulo de almacenamiento. Almacena los datos binarios resultantes.
  • Guarda por separado las propiedades esenciales del pincel del trazo:
    • Es la enumeración que representa la familia de pinceles. Si bien la instancia se puede serializar, esto no es eficiente para las apps que usan una selección limitada de familias de pinceles.
    • colorLong
    • size
    • epsilon
fun serializeStroke(stroke: Stroke): SerializedStroke {
  val serializedBrush = serializeBrush(stroke.brush)
  val encodedSerializedInputs = ByteArrayOutputStream().use
    {
      stroke.inputs.encode(it)
      it.toByteArray()
    }

  return SerializedStroke(
    inputs = encodedSerializedInputs,
    brush = serializedBrush
  )
}

Para cargar un objeto de trazo, haz lo siguiente:

  • Recupera los datos binarios guardados para StrokeInputBatch y deserialízalos con la función decode() del módulo de almacenamiento.
  • Recupera las propiedades de Brush guardadas y crea el pincel.
  • Crea el trazo final con el pincel recreado y el objeto StrokeInputBatch deserializado.

    fun deserializeStroke(serializedStroke: SerializedStroke): Stroke {
      val inputs = ByteArrayInputStream(serializedStroke.inputs).use {
        StrokeInputBatch.decode(it)
      }
      val brush = deserializeBrush(serializedStroke.brush)
      return Stroke(brush = brush, inputs = inputs)
    }
    

Cómo controlar el zoom, el desplazamiento lateral y la rotación

Si tu app admite el acercamiento, el desplazamiento o la rotación, debes proporcionar la transformación actual a InProgressStrokes. Esto ayuda a que los trazos recién dibujados coincidan con la posición y la escala de los trazos existentes.

Para ello, pasa un Matrix al parámetro pointerEventToWorldTransform. La matriz debe representar la inversa de la transformación que aplicas al lienzo de trazos terminados.

@Composable
fun ZoomableDrawingScreen(...) {
    // 1. Manage your zoom/pan state (e.g., using detectTransformGestures).
    var zoom by remember { mutableStateOf(1f) }
    var pan by remember { mutableStateOf(Offset.Zero) }

    // 2. Create the Matrix.
    val pointerEventToWorldTransform = remember(zoom, pan) {
        android.graphics.Matrix().apply {
            // Apply the inverse of your rendering transforms
            postTranslate(-pan.x, -pan.y)
            postScale(1 / zoom, 1 / zoom)
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // ...Your finished strokes Canvas, with regular transform applied

        // 3. Pass the matrix to InProgressStrokes.
        InProgressStrokes(
            modifier = Modifier.fillMaxSize(),
            pointerEventToWorldTransform = pointerEventToWorldTransform,
            defaultBrush = currentBrush,
            nextBrush = onGetNextBrush,
            onStrokesFinished = onStrokesFinished
        )
    }
}

Cómo exportar trazos

Es posible que debas exportar la escena de trazo como un archivo de imagen estática. Esto es útil para compartir el dibujo con otras aplicaciones, generar miniaturas o guardar una versión final no editable del contenido.

Para exportar una escena, puedes renderizar tus trazos en un mapa de bits fuera de la pantalla en lugar de hacerlo directamente en la pantalla. Usa Android's Picture API, que te permite grabar dibujos en un lienzo sin necesidad de un componente de IU visible.

El proceso implica crear una instancia Picture, llamar a beginRecording() para obtener un Canvas y, luego, usar tu CanvasStrokeRenderer existente para dibujar cada trazo en ese Canvas. Después de grabar todos los comandos de dibujo, puedes usar Picture para crear un Bitmap, que luego puedes comprimir y guardar en un archivo.

fun exportDocumentAsImage() {
  val picture = Picture()
  val canvas = picture.beginRecording(bitmapWidth, bitmapHeight)

  // The following is similar logic that you'd use in your custom View.onDraw or Compose Canvas.
  for (item in myDocument) {
    when (item) {
      is Stroke -> {
        canvasStrokeRenderer.draw(canvas, stroke, worldToScreenTransform)
      }
      // Draw your other types of items to the canvas.
    }
  }

  // Create a Bitmap from the Picture and write it to a file.
  val bitmap = Bitmap.createBitmap(picture)
  val outstream = FileOutputStream(filename)
  bitmap.compress(Bitmap.CompressFormat.PNG, 100, outstream)
}

Objetos de datos y asistentes de conversión

Define una estructura de objeto de serialización que refleje los objetos de la API de Ink necesarios.

Usa el módulo de almacenamiento de la API de Ink para codificar y decodificar StrokeInputBatch.

Objetos de transferencia de datos
@Parcelize
@Serializable
data class SerializedStroke(
  val inputs: ByteArray,
  val brush: SerializedBrush
) : Parcelable {
  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (other !is SerializedStroke) return false
    if (!inputs.contentEquals(other.inputs)) return false
    if (brush != other.brush) return false
    return true
  }

  override fun hashCode(): Int {
    var result = inputs.contentHashCode()
    result = 31 * result + brush.hashCode()
    return result
  }
}

@Parcelize
@Serializable
data class SerializedBrush(
  val size: Float,
  val color: Long,
  val epsilon: Float,
  val stockBrush: SerializedStockBrush,
  val clientBrushFamilyId: String? = null
) : Parcelable

enum class SerializedStockBrush {
  Marker,
  PressurePen,
  Highlighter,
  DashedLine,
}
Converters
object Converters {
  private val stockBrushToEnumValues = mapOf(
    StockBrushes.marker() to SerializedStockBrush.Marker,
    StockBrushes.pressurePen() to SerializedStockBrush.PressurePen,
    StockBrushes.highlighter() to SerializedStockBrush.Highlighter,
    StockBrushes.dashedLine() to SerializedStockBrush.DashedLine,
  )

  private val enumToStockBrush =
    stockBrushToEnumValues.entries.associate { (key, value) -> value to key
  }

  private fun serializeBrush(brush: Brush): SerializedBrush {
    return SerializedBrush(
      size = brush.size,
      color = brush.colorLong,
      epsilon = brush.epsilon,
      stockBrush = stockBrushToEnumValues[brush.family] ?: SerializedStockBrush.Marker,
    )
  }

  fun serializeStroke(stroke: Stroke): SerializedStroke {
    val serializedBrush = serializeBrush(stroke.brush)
    val encodedSerializedInputs = ByteArrayOutputStream().use { outputStream ->
      stroke.inputs.encode(outputStream)
      outputStream.toByteArray()
    }

    return SerializedStroke(
      inputs = encodedSerializedInputs,
      brush = serializedBrush
    )
  }

  private fun deserializeStroke(
    serializedStroke: SerializedStroke,
  ): Stroke? {
    val inputs = ByteArrayInputStream(serializedStroke.inputs).use { inputStream ->
        StrokeInputBatch.decode(inputStream)
    }
    val brush = deserializeBrush(serializedStroke.brush, customBrushes)
    return Stroke(brush = brush, inputs = inputs)
  }

  private fun deserializeBrush(
    serializedBrush: SerializedBrush,
  ): Brush {
    val stockBrushFamily = enumToStockBrush[serializedBrush.stockBrush]
    val brushFamily = customBrush?.brushFamily ?: stockBrushFamily ?: StockBrushes.marker()

    return Brush.createWithColorLong(
      family = brushFamily,
      colorLong = serializedBrush.color,
      size = serializedBrush.size,
      epsilon = serializedBrush.epsilon,
    )
  }
}