A preservação de estado e o armazenamento permanente são aspectos não triviais dos apps de tinta digital, especialmente no Compose. Os objetos de dados principais, como propriedades de pincel e os pontos que formam um traço, são complexos e não são mantidos automaticamente. Isso exige uma estratégia deliberada para salvar o estado durante cenários como mudanças de configuração e salvar permanentemente os desenhos de um usuário em um banco de dados.
Preservação de estado
No Jetpack Compose, o estado da interface é gerenciado usando
remember
e
rememberSaveable.
Embora o
rememberSaveable
ofereça preservação automática de estado em mudanças de configuração, os recursos integrados dele
são limitados a tipos de dados primitivos e objetos que implementam
Parcelable ou
Serializable.
Para objetos personalizados que contêm propriedades complexas, como
Brush, é necessário definir mecanismos explícitos de
serialização e desserialização usando um salvador de estado personalizado. Ao definir um Saver personalizado para o objeto Brush, é possível preservar os atributos essenciais do pincel quando ocorrem mudanças de configuração, conforme mostrado no exemplo de brushStateSaver a seguir.
fun brushStateSaver(converters: Converters): Saver<MutableState<Brush>, SerializedBrush> = Saver(
save = { converters.serializeBrush(it.value) },
restore = { mutableStateOf(converters.deserializeBrush(it)) },
)
Em seguida, use o Saver personalizado para
preservar o estado do pincel selecionado:
val currentBrush = rememberSaveable(saver = brushStateSaver(Converters())) { mutableStateOf(defaultBrush) }
Armazenamento permanente
Para ativar recursos como salvar, carregar documentos e possível colaboração em tempo real, armazene traços e dados associados em um formato serializado. Para a API Ink, é necessário fazer a serialização e a desserialização manualmente.
Para restaurar um traço com precisão, salve o Brush e o StrokeInputBatch dele.
Brush: inclui campos numéricos (tamanho, epsilon), cor eBrushFamily.StrokeInputBatch: uma lista de pontos de entrada com campos numéricos.
O módulo Storage simplifica a serialização compacta da parte mais complexa: o
StrokeInputBatch.
Para salvar um traço:
- Serialize o
StrokeInputBatchusando a função de codificação do módulo de armazenamento. Armazene os dados binários resultantes. - Salve separadamente as propriedades essenciais do pincel do traço:
- A enumeração que representa a família de pincéis. Embora a instância possa ser serializada, isso não é eficiente para apps que usam uma seleção limitada de famílias de pincéis.
colorLongsizeepsilon
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 carregar um objeto de traço:
- Recupere os dados binários salvos para o
StrokeInputBatche desserialize-os usando a função decode() do módulo de armazenamento. - Recupere as propriedades
Brushsalvas e crie o pincel. Crie o traço final usando o pincel recriado e o
StrokeInputBatchdesserializado.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) }
Fazer zoom, movimentar e girar
Se o app tiver suporte a zoom, movimento panorâmico ou rotação, você precisará fornecer a transformação
atual para InProgressStrokes. Isso ajuda os traços recém-desenhados a corresponder à posição e à escala dos traços atuais.
Para isso, transmita um Matrix ao parâmetro pointerEventToWorldTransform. A matriz precisa representar o inverso da transformação que você aplica à tela de traços finalizados.
@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
)
}
}
Exportar traços
Talvez seja necessário exportar a cena de traço como um arquivo de imagem estática. Isso é útil para compartilhar o desenho com outros aplicativos, gerar miniaturas ou salvar uma versão final e não editável do conteúdo.
Para exportar uma cena, renderize os traços em um bitmap fora da tela em vez de
diretamente na tela. Use o
Android's Picture API, que permite gravar desenhos em uma tela sem
precisar de um componente de interface visível.
O processo envolve criar uma instância Picture, chamar beginRecording() para receber um Canvas e usar o CanvasStrokeRenderer atual para desenhar cada traço nesse Canvas. Depois de gravar todos os comandos de desenho, você
pode usar o Picture para criar um Bitmap,
que pode ser compactado e salvo em um arquivo.
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 dados e conversores auxiliares
Defina uma estrutura de objeto de serialização que espelhe os objetos necessários da API Ink.
Use o módulo de armazenamento da API Ink para codificar e decodificar StrokeInputBatch.
Objetos de transferência de dados
@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,
)
}
}