يُعدّ الحفاظ على الحالة والتخزين الدائم من الجوانب المهمة في تطبيقات الكتابة بالحبر الرقمي، خاصةً في Compose. إنّ عناصر البيانات الأساسية، مثل خصائص الفرشاة والنقاط التي تشكّل ضربة فرشاة، معقّدة ولا يتم الاحتفاظ بها تلقائيًا. ويتطلّب ذلك استراتيجية مدروسة لحفظ الحالة أثناء سيناريوهات مثل تغييرات الإعدادات وحفظ رسومات المستخدم بشكل دائم في قاعدة بيانات.
State preservation
In Jetpack Compose, UI state is typically managed using
remember
and
rememberSaveable.
While
rememberSaveable
offers automatic state preservation across configuration changes, its built-in
capabilities are limited to primitive data types and objects that implement
Parcelable or
Serializable.
For custom objects that contain complex properties, such as
Brush, you must define explicit
serialization and deserialization mechanisms using a custom state saver. By
defining a custom Saver
for the Brush object, you can preserve the brush's essential attributes when
configuration changes occur, as shown in the following brushStateSaver
example.
fun brushStateSaver(converters: Converters): Saver<MutableState<Brush>, SerializedBrush> = Saver(
save = { converters.serializeBrush(it.value) },
restore = { mutableStateOf(converters.deserializeBrush(it)) },
)
You can then use the custom Saver to
preserve the selected brush state:
val currentBrush = rememberSaveable(saver = brushStateSaver(Converters())) { mutableStateOf(defaultBrush) }
التخزين الثابت
To enable features such as document saving, loading, and potential real-time collaboration, store strokes and associated data in a serialized format. For the Ink API, manual serialization and deserialization are necessary.
To accurately restore a stroke, save its Brush and StrokeInputBatch.
Brush: Includes numeric fields (size, epsilon), color, andBrushFamily.StrokeInputBatch: A list of input points with numeric fields.
The Storage module simplifies compactly serializing the most complex part: the
StrokeInputBatch.
To save a stroke:
- Serialize the
StrokeInputBatchusing the storage module's encode function. Store the resulting binary data. - Separately save the essential properties of the stroke's Brush:
- The enum that represents the brush family &mdash Although the instance can be serialized, this is not efficient for apps that use a limited selection of brush families
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
)
}
To load a stroke object:
- Retrieve the saved binary data for the
StrokeInputBatchand deserialize it using the storage module's decode() function. - Retrieve the saved
Brushproperties and create the brush. Create the final stroke using the recreated brush and the deserialized
StrokeInputBatch.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) }
Handle zoom, pan, and rotation
If your app supports zooming, panning, or rotation, you must provide the current
transformation to InProgressStrokes. This helps newly drawn strokes match the
position and scale of your existing strokes.
You do this by passing a Matrix to the pointerEventToWorldTransform
parameter. The matrix should represent the inverse of the transformation you
apply to your finished strokes canvas.
@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
)
}
}
Export strokes
قد تحتاج إلى تصدير مشهد ضربات الفرشاة كملف صورة ثابتة. ويكون ذلك مفيدًا لمشاركة الرسم مع تطبيقات أخرى أو إنشاء صور مصغّرة أو حفظ نسخة نهائية غير قابلة للتعديل من المحتوى.
لتصدير مشهد، يمكنك عرض ضربات الفرشاة على صورة نقطية خارج الشاشة بدلاً من عرضها على الشاشة مباشرةً. استخدِم
Android's Picture API، الذي يتيح لك تسجيل الرسومات على لوحة رسم بدون الحاجة إلى مكوّن مرئي في واجهة المستخدم.
تتضمّن العملية إنشاء مثيل Picture، واستدعاء beginRecording() للحصول على Canvas، ثم استخدام CanvasStrokeRenderer الحالي لرسم كل ضربة على Canvas. بعد تسجيل جميع أوامر الرسم، يمكنك استخدام Picture لإنشاء Bitmap،
والذي يمكنك بعد ذلك ضغطه وحفظه في ملف.
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)
}
Data object and converter helpers
Define a serialization object structure that mirrors needed Ink API objects.
Use the Ink API's storage module to encode and decode StrokeInputBatch.
Data transfer objects
@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,
)
}
}