fix(zoom): kill re-baseline pinch pop

Device log showed a single-frame scale pop (cur 0.504->0.694, a
+38% jump UP while the pinch was still shrinking).

Root cause: the absolute mapping targetScale = scaleStart *
details.scale is only valid when details.scale is 1.0 at the
moment scaleStart is captured. That holds at gesture start, but
on a mid-gesture re-baseline (a finger blips 2->1->2, routine on
Windows touch) a fresh scaleStart got multiplied by the
recognizer's still-cumulative details.scale, popping the zoom
then snapping back.

Fix: track rawScaleAtBaseline and normalize details.scale against
it so the cumulative reads 1.0 at every baseline. Extracted
absolutePinchScale() pure solver + 5 unit tests covering the
exact re-baseline scenario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:30:41 +08:00
parent 5db364d1fc
commit f48e43f13d
3 changed files with 132 additions and 4 deletions

View File

@@ -32,6 +32,7 @@ import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'input_diagnostics.dart';
import 'pinch_scale_solver.dart';
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
/// is owned exclusively by the drawing `Listener`.
@@ -116,6 +117,18 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
double _lastRawScale = 1.0;
double _lastAppliedScale = 1.0;
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
/// gesture start, or the last pointer-count re-baseline). The absolute target
/// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this
/// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment.
///
/// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on
/// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at
/// its un-normalized cumulative value, so the next frame computed
/// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back
/// (the reported flicker). Normalizing kills that pop at the source.
double _rawScaleAtBaseline = 1.0;
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
@@ -169,6 +182,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = 1.0;
_lastAppliedScale = _scaleStart!;
_rawScaleAtBaseline = 1.0;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
@@ -184,6 +198,10 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = details.scale;
_lastAppliedScale = _scaleStart!;
// Re-anchor the absolute mapping: from here, cumulative scale is measured
// relative to THIS frame's details.scale (so the next good frame starts
// from _scaleStart, not _scaleStart * a stale cumulative value).
_rawScaleAtBaseline = details.scale;
InputDiagnostics.instance.recordRebaseline();
return;
}
@@ -242,10 +260,15 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
// for a pure scale+translate matrix — no inversion, no live read-back —
// so an interleaved/transient matrix write can't survive into the next
// frame: every frame is fully re-derived from clean inputs.
final double targetScale = clampDouble(
_scaleStart! * details.scale,
widget.minScale,
widget.maxScale,
// Absolute target scale, normalized against the baseline so a
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
// pinch_scale_solver.dart for the full rationale.
final double targetScale = absolutePinchScale(
scaleStart: _scaleStart!,
rawScaleAtBaseline: _rawScaleAtBaseline,
rawScale: details.scale,
minScale: widget.minScale,
maxScale: widget.maxScale,
);
final Offset focal = details.localFocalPoint;
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;