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:
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user