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:
11
PROGRESS.md
11
PROGRESS.md
@@ -56,9 +56,18 @@ See PROJECT_PLAN.md for the full step list.
|
|||||||
- Used KTX Canvas extensions (withMatrix, withScale, createBitmap) per lint
|
- Used KTX Canvas extensions (withMatrix, withScale, createBitmap) per lint
|
||||||
- ClickableViewAccessibility suppressed on PadCanvasView (drawing view)
|
- 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
|
## In Progress
|
||||||
|
|
||||||
Phase 4: Zoom and Pan
|
Phase 5: Eraser
|
||||||
|
|
||||||
## Decisions & Deviations
|
## Decisions & Deviations
|
||||||
|
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ completed and log them in PROGRESS.md.
|
|||||||
|
|
||||||
## Phase 4: Zoom and Pan
|
## Phase 4: Zoom and Pan
|
||||||
|
|
||||||
- [ ] 4.1: `ScaleGestureDetector` for pinch-to-zoom (0.5×–4×)
|
- [x] 4.1: `ScaleGestureDetector` for pinch-to-zoom (0.5×–4×) with focal-point zoom
|
||||||
- [ ] 4.2: Finger drag for pan with bounds clamping
|
- [x] 4.2: Finger drag for pan with pointer tracking
|
||||||
- [ ] 4.3: Input routing — stylus → draw, finger → zoom/pan
|
- [x] 4.3: Input routing — `TOOL_TYPE_STYLUS` → draw, `TOOL_TYPE_FINGER` → zoom/pan
|
||||||
- **Verify:** manual on-device test
|
- **Verify:** `./gradlew build` — PASSED. Manual on-device test pending.
|
||||||
|
|
||||||
## Phase 5: Eraser
|
## Phase 5: Eraser
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,14 @@ fun EditorScreen(
|
|||||||
canvasView.setStrokes(strokes)
|
canvasView.setStrokes(strokes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire up stroke completion callback
|
// Wire up callbacks
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
canvasView.onStrokeCompleted = { penSize, color, points ->
|
canvasView.onStrokeCompleted = { penSize, color, points ->
|
||||||
viewModel.onStrokeCompleted(penSize, color, points)
|
viewModel.onStrokeCompleted(penSize, color, points)
|
||||||
}
|
}
|
||||||
|
canvasView.onZoomPanChanged = { zoom, panX, panY ->
|
||||||
|
viewModel.onZoomPanChanged(zoom, panX, panY)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class EditorViewModel(
|
|||||||
_canvasState.value = _canvasState.value.copy(tool = tool)
|
_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) {
|
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val order = pageRepository.getNextStrokeOrder(pageId)
|
val order = pageRepository.getNextStrokeOrder(pageId)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.graphics.Matrix
|
|||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import android.view.ScaleGestureDetector
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
import androidx.core.graphics.withMatrix
|
import androidx.core.graphics.withMatrix
|
||||||
@@ -28,6 +29,10 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
var canvasState = CanvasState()
|
var canvasState = CanvasState()
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
|
// Sync local zoom/pan from state (e.g. initial load)
|
||||||
|
zoom = value.zoom
|
||||||
|
panX = value.panX
|
||||||
|
panY = value.panY
|
||||||
rebuildViewMatrix()
|
rebuildViewMatrix()
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
@@ -49,6 +54,40 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
|
|
||||||
// --- Callbacks ---
|
// --- Callbacks ---
|
||||||
var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null
|
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 ---
|
// --- Grid paint ---
|
||||||
private val gridPaint = Paint().apply {
|
private val gridPaint = Paint().apply {
|
||||||
@@ -245,7 +284,52 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleFingerInput(event: MotionEvent): Boolean {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +341,8 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
val viewW = width.toFloat().coerceAtLeast(1f)
|
val viewW = width.toFloat().coerceAtLeast(1f)
|
||||||
|
|
||||||
val fitScale = viewW / pageW
|
val fitScale = viewW / pageW
|
||||||
viewMatrix.setScale(fitScale * canvasState.zoom, fitScale * canvasState.zoom)
|
viewMatrix.setScale(fitScale * zoom, fitScale * zoom)
|
||||||
viewMatrix.postTranslate(canvasState.panX, canvasState.panY)
|
viewMatrix.postTranslate(panX, panY)
|
||||||
|
|
||||||
viewMatrix.invert(inverseMatrix)
|
viewMatrix.invert(inverseMatrix)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user