From 3fc9751fc474f980126ac045f27847d1a21e07a0 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 14:34:42 -0700 Subject: [PATCH] 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) --- PROGRESS.md | 11 ++- PROJECT_PLAN.md | 8 +- .../engpad/ui/editor/EditorScreen.kt | 5 +- .../engpad/ui/editor/EditorViewModel.kt | 4 + .../engpad/ui/editor/PadCanvasView.kt | 90 ++++++++++++++++++- 5 files changed, 109 insertions(+), 9 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 5ade77a..c7c7db2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,9 +56,18 @@ See PROJECT_PLAN.md for the full step list. - Used KTX Canvas extensions (withMatrix, withScale, createBitmap) per lint - ClickableViewAccessibility suppressed on PadCanvasView (drawing view) +### Phase 4: Zoom and Pan (2026-03-24) + +- [x] 4.1: ScaleGestureDetector with focal-point zoom (0.5×–4×) +- [x] 4.2: Finger drag for pan with multi-pointer tracking (handles finger + swaps during pinch gestures) +- [x] 4.3: Input routing by tool type already in place from Phase 3 +- Zoom/pan state managed locally in PadCanvasView for responsiveness, + synced to ViewModel on gesture end + ## In Progress -Phase 4: Zoom and Pan +Phase 5: Eraser ## Decisions & Deviations diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 250a97a..c0c92f4 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -64,10 +64,10 @@ completed and log them in PROGRESS.md. ## Phase 4: Zoom and Pan -- [ ] 4.1: `ScaleGestureDetector` for pinch-to-zoom (0.5×–4×) -- [ ] 4.2: Finger drag for pan with bounds clamping -- [ ] 4.3: Input routing — stylus → draw, finger → zoom/pan -- **Verify:** manual on-device test +- [x] 4.1: `ScaleGestureDetector` for pinch-to-zoom (0.5×–4×) with focal-point zoom +- [x] 4.2: Finger drag for pan with pointer tracking +- [x] 4.3: Input routing — `TOOL_TYPE_STYLUS` → draw, `TOOL_TYPE_FINGER` → zoom/pan +- **Verify:** `./gradlew build` — PASSED. Manual on-device test pending. ## Phase 5: Eraser diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt index 3371360..a4e7974 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt @@ -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()) { diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt index 5207239..50909f8 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt @@ -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) diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt index 511a219..0033266 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt @@ -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) }