Implement Phase 4: pinch-to-zoom and finger pan

- ScaleGestureDetector for pinch zoom (0.5x-4x) with focal-point anchoring
- Finger drag for pan with multi-pointer tracking
- Zoom/pan state managed locally in PadCanvasView for responsiveness,
  synced to EditorViewModel on gesture end

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:34:42 -07:00
parent a31e7e64d0
commit 3fc9751fc4
5 changed files with 109 additions and 9 deletions

View File

@@ -44,11 +44,14 @@ fun EditorScreen(
canvasView.setStrokes(strokes)
}
// Wire up stroke completion callback
// Wire up callbacks
LaunchedEffect(Unit) {
canvasView.onStrokeCompleted = { penSize, color, points ->
viewModel.onStrokeCompleted(penSize, color, points)
}
canvasView.onZoomPanChanged = { zoom, panX, panY ->
viewModel.onZoomPanChanged(zoom, panX, panY)
}
}
Column(modifier = Modifier.fillMaxSize()) {

View File

@@ -38,6 +38,10 @@ class EditorViewModel(
_canvasState.value = _canvasState.value.copy(tool = tool)
}
fun onZoomPanChanged(zoom: Float, panX: Float, panY: Float) {
_canvasState.value = _canvasState.value.copy(zoom = zoom, panX = panX, panY = panY)
}
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
viewModelScope.launch {
val order = pageRepository.getNextStrokeOrder(pageId)

View File

@@ -8,6 +8,7 @@ import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withMatrix
@@ -28,6 +29,10 @@ class PadCanvasView(context: Context) : View(context) {
var canvasState = CanvasState()
set(value) {
field = value
// Sync local zoom/pan from state (e.g. initial load)
zoom = value.zoom
panX = value.panX
panY = value.panY
rebuildViewMatrix()
invalidate()
}
@@ -49,6 +54,40 @@ class PadCanvasView(context: Context) : View(context) {
// --- Callbacks ---
var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null
var onZoomPanChanged: ((zoom: Float, panX: Float, panY: Float) -> Unit)? = null
// --- Zoom/pan state (managed locally for responsiveness) ---
private var zoom = 1f
private var panX = 0f
private var panY = 0f
private var lastTouchX = 0f
private var lastTouchY = 0f
private var activePointerId = MotionEvent.INVALID_POINTER_ID
private val scaleGestureDetector = ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val newZoom = (zoom * detector.scaleFactor)
.coerceIn(CanvasState.MIN_ZOOM, CanvasState.MAX_ZOOM)
if (newZoom != zoom) {
// Zoom around the focal point
val focusX = detector.focusX
val focusY = detector.focusY
panX += (focusX - panX) * (1 - newZoom / zoom)
panY += (focusY - panY) * (1 - newZoom / zoom)
zoom = newZoom
rebuildViewMatrix()
invalidate()
}
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
onZoomPanChanged?.invoke(zoom, panX, panY)
}
},
)
// --- Grid paint ---
private val gridPaint = Paint().apply {
@@ -245,7 +284,52 @@ class PadCanvasView(context: Context) : View(context) {
}
private fun handleFingerInput(event: MotionEvent): Boolean {
// Zoom/pan handled in Phase 4
scaleGestureDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
activePointerId = event.getPointerId(0)
lastTouchX = event.x
lastTouchY = event.y
}
MotionEvent.ACTION_MOVE -> {
if (!scaleGestureDetector.isInProgress) {
val pointerIndex = event.findPointerIndex(activePointerId)
if (pointerIndex >= 0) {
val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex)
panX += x - lastTouchX
panY += y - lastTouchY
lastTouchX = x
lastTouchY = y
rebuildViewMatrix()
invalidate()
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
activePointerId = MotionEvent.INVALID_POINTER_ID
onZoomPanChanged?.invoke(zoom, panX, panY)
}
MotionEvent.ACTION_POINTER_DOWN -> {
// When a second finger goes down, update last touch to avoid jump
val newIndex = event.actionIndex
lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(newIndex)
activePointerId = event.getPointerId(newIndex)
}
MotionEvent.ACTION_POINTER_UP -> {
val upIndex = event.actionIndex
val upId = event.getPointerId(upIndex)
if (upId == activePointerId) {
// Switch to the other finger
val newIndex = if (upIndex == 0) 1 else 0
lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(newIndex)
activePointerId = event.getPointerId(newIndex)
}
}
}
return true
}
@@ -257,8 +341,8 @@ class PadCanvasView(context: Context) : View(context) {
val viewW = width.toFloat().coerceAtLeast(1f)
val fitScale = viewW / pageW
viewMatrix.setScale(fitScale * canvasState.zoom, fitScale * canvasState.zoom)
viewMatrix.postTranslate(canvasState.panX, canvasState.panY)
viewMatrix.setScale(fitScale * zoom, fitScale * zoom)
viewMatrix.postTranslate(panX, panY)
viewMatrix.invert(inverseMatrix)
}