Reference for structuring an Ink Compose app with MVVM and Hilt, implementing a linear undo and redo history stack, and adding a geometry-based stroke eraser.
MVVM architecture overview
Compose UI Layer (DrawingScreen -> DrawingSurface + DrawingToolbox)
│ Observes StateFlows via collectAsStateWithLifecycle
▼
ViewModel Layer (DrawingViewModel)
│ Manages uiState, currentBrush, isEraserMode, canUndo/canRedo, history
▼
Repository Layer (StrokeRepository)
│ Runs serializeStroke / deserializeStroke on Dispatchers.IO
▼
Data Layer (Room AppDatabase + DocumentDao)
Define a single immutable UI state class and a ViewModel that owns the stroke
history, active brush, and eraser state:
data class DrawingUiState( val strokes: List<Stroke> = emptyList(), val documentTitle: String = "", )
Linear undo and redo history stack
Maintain a snapshot history list (MutableList<List<Stroke>>) and a
historyIndex pointer inside the ViewModel. Because Stroke instances are
immutable, each snapshot only allocates a lightweight list reference without
duplicating stroke geometry.
DrawingViewModel with undo, redo, and eraser
When the user adds, erases, or clears strokes after an undo, truncate all
entries after historyIndex before appending the new snapshot. Cap history
growth with maxHistorySize (such as 50) for long sessions, and persist
strokes on Dispatchers.IO after every mutation (onStrokesFinished,
endErase, undo, redo, and clearStrokes):
class DrawingViewModel( private val repository: StrokeRepository, val textureStore: AppTextureBitmapStore, private val documentId: Long = 1L, ) : ViewModel() { private val _uiState = MutableStateFlow(DrawingUiState()) val uiState: StateFlow<DrawingUiState> = _uiState.asStateFlow() private val _selectedBrush = MutableStateFlow( Brush.createWithComposeColor( family = StockBrushes.pressurePen(), color = Color.Black, size = 5f, epsilon = 0.1f, ) ) val currentBrush: StateFlow<Brush> = _selectedBrush.asStateFlow() fun getCurrentBrush(): Brush = _selectedBrush.value private val _isEraserMode = MutableStateFlow(false) val isEraserMode: StateFlow<Boolean> = _isEraserMode.asStateFlow() private val _canUndo = MutableStateFlow(false) val canUndo: StateFlow<Boolean> = _canUndo.asStateFlow() private val _canRedo = MutableStateFlow(false) val canRedo: StateFlow<Boolean> = _canRedo.asStateFlow() private val history = mutableListOf<List<Stroke>>() private var historyIndex = -1 private val maxHistorySize = 50 private var previousPoint: MutableVec? = null private val eraserPadding = 50f init { viewModelScope.launch { val initialStrokes = repository.loadStrokes(documentId) _uiState.update { it.copy(strokes = initialStrokes) } history.clear() history.add(initialStrokes) historyIndex = 0 updateUndoRedoState() } } private fun updateStrokes(newStrokes: List<Stroke>) { // Truncate any future redo states when a new edit occurs after undo if (historyIndex < history.size - 1) { history.subList(historyIndex + 1, history.size).clear() } history.add(newStrokes) historyIndex++ while (history.size > maxHistorySize) { history.removeAt(0) historyIndex-- } _uiState.update { it.copy(strokes = newStrokes) } updateUndoRedoState() } private fun updateUndoRedoState() { _canUndo.value = historyIndex > 0 _canRedo.value = historyIndex < history.size - 1 } fun onStrokesFinished(finishedStrokes: List<Stroke>) { val current = history.getOrElse(historyIndex) { emptyList() } updateStrokes(current + finishedStrokes) viewModelScope.launch { saveStrokes() } } fun undo() { if (_canUndo.value) { historyIndex-- _uiState.update { it.copy(strokes = history[historyIndex]) } updateUndoRedoState() viewModelScope.launch { saveStrokes() } } } fun redo() { if (_canRedo.value) { historyIndex++ _uiState.update { it.copy(strokes = history[historyIndex]) } updateUndoRedoState() viewModelScope.launch { saveStrokes() } } } fun clearStrokes() { if (_uiState.value.strokes.isNotEmpty()) { updateStrokes(emptyList()) viewModelScope.launch { saveStrokes() } } } fun setEraserMode(enabled: Boolean) { _isEraserMode.value = enabled } fun startErase() { previousPoint = null } fun endErase() { previousPoint = null viewModelScope.launch { saveStrokes() } } fun erase(x: Float, y: Float) { val strokesBefore = history.getOrElse(historyIndex) { emptyList() } val strokesAfter = eraseIntersectingStrokes(x, y, strokesBefore) if (strokesAfter.size != strokesBefore.size) { updateStrokes(strokesAfter) } } private fun eraseIntersectingStrokes( currentX: Float, currentY: Float, currentStrokes: List<Stroke>, ): List<Stroke> { val prev = previousPoint previousPoint = MutableVec(currentX, currentY) if (prev == null) return currentStrokes val segment = MutableSegment(prev, MutableVec(currentX, currentY)) val parallelogram = MutableParallelogram() .populateFromSegmentAndPadding(segment, eraserPadding) val strokesToRemove = currentStrokes.filter { stroke -> stroke.shape.intersects(parallelogram, AffineTransform.IDENTITY) } return if (strokesToRemove.isNotEmpty()) { currentStrokes - strokesToRemove.toSet() } else { currentStrokes } } private suspend fun saveStrokes() { val strokesToSave = history.getOrElse(historyIndex) { emptyList() } repository.saveStrokes(documentId, strokesToSave) } }
Geometry-based stroke eraser details
The eraser tests whether each stroke's PartitionedMesh (stroke.shape)
intersects a padded MutableParallelogram swept between consecutive drag
points (MutableSegment).
- Eraser padding (
eraserPadding): Use10fto30ffor precise erasing,40fto60f(with50fby default) for finger or stylus erasing, or80fand higher for wide-area erasing. - Lifecycle: Reset
previousPoint = nullin bothstartEraseandendEraseso a new drag doesn't connect to the final point of the preceding drag.
Geometry conversions and pan or zoom erasing
Use ink-geometry-compose and ink-geometry extensions to convert between
Compose or Android types and Ink geometry types, or to transform eraser hit
tests when the canvas is panned or zoomed:
fun populatePointFromOffset(reusableVec: MutableVec, offset: Offset): Offset { reusableVec.populateFrom(offset) return reusableVec.toOffset() } fun eraseWithPanZoom( strokes: List<Stroke>, screenEraserBox: MutableParallelogram, worldToScreenAndroidMatrix: AndroidMatrix, worldToScreenComposeMatrix: ComposeMatrix? = null, ): List<Stroke> { val meshToEraserTransform: AffineTransform = if (worldToScreenComposeMatrix != null) { ImmutableAffineTransform.from(worldToScreenComposeMatrix) ?: AffineTransform.IDENTITY } else { MutableAffineTransform().populateFrom(worldToScreenAndroidMatrix) } return strokes.filterNot { stroke -> stroke.shape.intersects(screenEraserBox, meshToEraserTransform) } } fun getStrokeBounds(stroke: Stroke): Pair<Rect?, RectF?> { val boundingBox = stroke.shape.computeBoundingBox() ?: return null to null return boundingBox.toRect() to boundingBox.toRectF() }
Composable screen wiring and repository interface
Observe ViewModel flows with collectAsStateWithLifecycle so collection
pauses when the app goes into the background, and recreate
CanvasStrokeRenderer in the composable layer when textureStore.generation
changes:
interface StrokeRepository { suspend fun saveStrokes(documentId: Long, strokes: List<Stroke>) suspend fun loadStrokes(documentId: Long): List<Stroke> } @Composable fun DrawingScreen( viewModel: DrawingViewModel, modifier: Modifier = Modifier, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentBrush by viewModel.currentBrush.collectAsStateWithLifecycle() val isEraserMode by viewModel.isEraserMode.collectAsStateWithLifecycle() val textureStore = viewModel.textureStore val cacheGen by textureStore.generation.collectAsStateWithLifecycle() val renderer = remember(textureStore, cacheGen) { CanvasStrokeRenderer.create(textureStore) } CompositionLocalProvider(LocalTextureStore provides textureStore) { DrawingSurface( strokes = uiState.strokes, canvasStrokeRenderer = renderer, currentBrush = currentBrush, onGetNextBrush = { viewModel.getCurrentBrush() }, onStrokesFinished = { viewModel.onStrokesFinished(it) }, isEraserMode = isEraserMode, modifier = modifier, onErase = { x, y -> viewModel.erase(x, y) }, onEraseStart = { viewModel.startErase() }, onEraseEnd = { viewModel.endErase() }, ) } }
Common pitfalls
- Launching
saveStrokesinsideViewModel.onCleared:viewModelScopeis already cancelled whenonClearedruns, so coroutines launched there cancel immediately. Persist strokes after each mutation instead. - Not resetting
previousPointinstartEraseandendErase: Leaving a stalepreviousPointcreates a long segment across the canvas on the next touch down, erasing unintended strokes. - Not calling
change.consumein the eraser gesture: Unconsumed drag events propagate to parent or sibling gesture handlers. - Forgetting to seed
historyoninit: Always addinitialStrokes, even when empty, and sethistoryIndex = 0so that the first user stroke can be undone.