Skip to content

Draw and capture input

Compose surface

InkDrawingSurface handles primary mouse/touch/stylus gestures and frame updates. Render finished strokes in drawContent; retain the new stroke in onStrokeFinished so it stays visible after release.

package wiki

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.ink.geometry.ImmutableAffineTransform
import com.vivenotes.byteink.compose.InkDrawingSurface
import com.vivenotes.byteink.compose.rememberInkMeshRenderer
import com.vivenotes.byteink.compose.drawInk
import com.vivenotes.byteink.compose.rememberInkAuthoringController
import com.vivenotes.byteink.kit.AuthoredViveStroke
import com.vivenotes.byteink.kit.StoredInkStroke
import com.vivenotes.byteink.kit.ViveInkTool
import java.util.UUID

@Composable
fun DrawingExample(onRowReady: (StoredInkStroke) -> Unit) {
    val tool = remember { ViveInkTool(sizeDp = 3f) }
    val controller = rememberInkAuthoringController()
    val renderer = rememberInkMeshRenderer()
    val completed = remember { mutableStateListOf<AuthoredViveStroke>() }
    val density = LocalDensity.current.density
    val pageToPixels = remember(density) {
        ImmutableAffineTransform(density, 0f, 0f, 0f, density, 0f)
    }
    DisposableEffect(controller) {
        onDispose { controller.close() }
    }
    InkDrawingSurface(
        controller = controller,
        brush = tool.brush,
        modifier = Modifier.fillMaxSize(),
        strokeToView = pageToPixels,
        renderer = renderer,
        onStrokeFinished = { finished ->
            val authored = tool.complete(
                stroke = finished,
                id = UUID.randomUUID().toString(),
                pageId = "page-1",
                seq = completed.size + 1, // Demo sequence; a repository allocates real values.
                createdAt = System.currentTimeMillis(),
            )
            completed.add(authored)
            onRowReady(authored.row)
        },
        drawContent = {
            completed.forEach { drawInk(renderer, it.canonicalStroke, pageToPixels) }
        },
    )
}

The example renders page dp at device density, serializes each completed stroke, and displays its canonical geometry. It returns new rows through onRowReady; your repository persists them.

Surface parameter Use
controller One controller per attached surface
brush Native brush captured at pointer down
modifier Layout size, background and other Compose modifiers
strokeToView Page-to-local-pixel transform; include density and zoom
renderer Defaults to path rendering; pass rememberInkMeshRenderer() for mesh effects, textures and prediction shading
enabled false cancels and disables authoring
inputSource Optional native adapter; default Compose input when null
onStrokeFinished Captured completion callback; retain/store its Stroke
drawContent Finished ink/paper drawn before the wet stroke

Changing a tool or transform during a gesture does not alter its captured brush/transform. Explicitly call cancel() on a page/tool switch if the old gesture must be discarded. Removing or disabling the surface cancels it automatically. rememberInkAuthoringController() remembers a controller; the owner still closes it.

Controller lifecycle

For application-managed input, use the same controller directly:

package wiki

import androidx.ink.geometry.ImmutableAffineTransform
import androidx.ink.strokes.Stroke
import com.vivenotes.byteink.compose.InkAuthoringController
import com.vivenotes.byteink.compose.InkPointerSample
import com.vivenotes.byteink.kit.ViveInkTool

fun pointerStroke(): Stroke = InkAuthoringController().use { controller ->
    val tool = ViveInkTool(sizeDp = 3f)
    controller.begin(
        brush = tool.brush,
        sample = InkPointerSample(x = 20f, y = 100f, uptimeMillis = 1_000L),
        strokeToView = ImmutableAffineTransform(2f, 0f, 0f, 0f, 2f, 0f),
    )
    controller.append(InkPointerSample(x = 80f, y = 100f, uptimeMillis = 1_016L))
    if (controller.isUpdateNeeded()) controller.advance(uptimeMillis = 1_016L)
    requireNotNull(controller.finish(
        InkPointerSample(x = 180f, y = 100f, uptimeMillis = 1_032L),
    ))
}
Call/state Behavior
begin(brush, sample, strokeToView) Starts a gesture and makes the first dot visible
append(sample) Buffers input; returns false for idle, wrong-tool, older or duplicate observations
hasPendingInputs Schedule a frame even when native shape state has not changed yet
isUpdateNeeded() Buffered input or timed brush behavior needs a frame
advance(uptimeMillis) Processes buffered input once; returns whether geometry needed updating
revision Read in the draw block to observe processed geometry changes
liveStroke Current wet stroke; read it on the controller's thread and leave mutation to the controller
strokeToView Frozen transform for the active gesture
finish(sample?) Settles inputs and returns independent canonical Stroke, or null when idle
cancel() Discards active ink; safe while idle
close() Discards input and releases references; controller cannot restart

Custom frame loops must observe both isDrawing and hasPendingInputs, then use isUpdateNeeded(). append() alone does not advance geometry. Keep event and frame times in the same monotonic millisecond clock; passing epoch milliseconds to advance is incorrect.

Pressure and device adapters

InkPointerSample(x, y, uptimeMillis, toolType, pressure) uses local surface pixels. Coordinates must be finite; time nonnegative; measured pressure must be finite in [0, 1]. Mouse and touch use pressure = null.

If pressure is absent at begin, the whole gesture stays pressure-free. If present, later missing values hold the last measured value. Calligraphy uses speed when pressure is unavailable.

An InkInputSource delivers Begin, Move, Finish, and Cancel serially on the Compose UI thread. Return a subscription that stops callbacks when closed. Preserve real event timestamps and tool/pressure data; report one active gesture at a time. Pass the adapter through inputSource to bypass Compose's pointer path.

Hardware pressure depends on what the desktop platform/Compose reports; a native adapter can supply it through this seam.

Brush choices

val marker = ViveInkTool(familyId = ViveBrushes.MARKER, sizeDp = 3f)
val dashed = ViveInkTool(familyId = ViveBrushes.DASHED_LINE, sizeDp = 3f)
val nib = ViveInkTool(
    familyId = ViveBrushes.calligraphy(pressure = 3),
    stabilization = 2,
    sizeDp = 6f,
)
val highlight = ViveInkTool(
    familyId = ViveBrushes.HIGHLIGHTER,
    stabilization = 0,
    colorArgb = 0x80ffff00.toInt(),
    sizeDp = 18f,
    colorFollowsTheme = false,
)
Stabilization Model
0 Passthrough
1, 2, 3, 4, 5 Sliding windows of 20, 40, 60, 90, 120 ms at 180 Hz

Highlighter uses its pinned stock input model and stores 0 as “not applicable.” ViveInkTool validates levels 0..5 and known authoring families; ViveBrushes lookup helpers clamp levels and provide legacy fallbacks.

Complete input/surface parameters · Complete brush/tool parameters