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:
@@ -32,6 +32,7 @@ import 'package:flutter/physics.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'input_diagnostics.dart';
|
import 'input_diagnostics.dart';
|
||||||
|
import 'pinch_scale_solver.dart';
|
||||||
|
|
||||||
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
|
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
|
||||||
/// is owned exclusively by the drawing `Listener`.
|
/// is owned exclusively by the drawing `Listener`.
|
||||||
@@ -116,6 +117,18 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
double _lastRawScale = 1.0;
|
double _lastRawScale = 1.0;
|
||||||
double _lastAppliedScale = 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) -----------
|
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
|
||||||
|
|
||||||
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
|
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
|
||||||
@@ -169,6 +182,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
_lastRawScale = 1.0;
|
_lastRawScale = 1.0;
|
||||||
_lastAppliedScale = _scaleStart!;
|
_lastAppliedScale = _scaleStart!;
|
||||||
|
_rawScaleAtBaseline = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||||
@@ -184,6 +198,10 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
_lastRawScale = details.scale;
|
_lastRawScale = details.scale;
|
||||||
_lastAppliedScale = _scaleStart!;
|
_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();
|
InputDiagnostics.instance.recordRebaseline();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -242,10 +260,15 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
// for a pure scale+translate matrix — no inversion, no live read-back —
|
// for a pure scale+translate matrix — no inversion, no live read-back —
|
||||||
// so an interleaved/transient matrix write can't survive into the next
|
// so an interleaved/transient matrix write can't survive into the next
|
||||||
// frame: every frame is fully re-derived from clean inputs.
|
// frame: every frame is fully re-derived from clean inputs.
|
||||||
final double targetScale = clampDouble(
|
// Absolute target scale, normalized against the baseline so a
|
||||||
_scaleStart! * details.scale,
|
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
|
||||||
widget.minScale,
|
// pinch_scale_solver.dart for the full rationale.
|
||||||
widget.maxScale,
|
final double targetScale = absolutePinchScale(
|
||||||
|
scaleStart: _scaleStart!,
|
||||||
|
rawScaleAtBaseline: _rawScaleAtBaseline,
|
||||||
|
rawScale: details.scale,
|
||||||
|
minScale: widget.minScale,
|
||||||
|
maxScale: widget.maxScale,
|
||||||
);
|
);
|
||||||
final Offset focal = details.localFocalPoint;
|
final Offset focal = details.localFocalPoint;
|
||||||
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
|
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
|
||||||
|
|||||||
41
lib/editor/canvas/pinch_scale_solver.dart
Normal file
41
lib/editor/canvas/pinch_scale_solver.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// lib/editor/canvas/pinch_scale_solver.dart
|
||||||
|
//
|
||||||
|
// Pure math for the pen canvas's absolute pinch-zoom. Extracted so the
|
||||||
|
// re-baseline behavior (the subtle part) can be unit-tested without simulating
|
||||||
|
// a flaky multi-pointer gesture.
|
||||||
|
//
|
||||||
|
// The pinch is driven ABSOLUTELY: the scale shown is always
|
||||||
|
// scaleStart * (rawScale / rawScaleAtBaseline)
|
||||||
|
// where `scaleStart` is the matrix scale captured at the current baseline and
|
||||||
|
// `rawScaleAtBaseline` is the recognizer's cumulative `details.scale` at that
|
||||||
|
// same baseline. Dividing by `rawScaleAtBaseline` re-normalizes the cumulative
|
||||||
|
// scale so it reads 1.0 at the baseline instant.
|
||||||
|
//
|
||||||
|
// Why this matters: a baseline is captured at gesture start AND on every
|
||||||
|
// pointer-count change (a finger blips 2→1→2, routine on Windows touch). At
|
||||||
|
// gesture start `details.scale` is 1.0, so a naive `scaleStart * rawScale` is
|
||||||
|
// correct. But at a MID-GESTURE re-baseline `details.scale` is whatever the
|
||||||
|
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
|
||||||
|
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
|
||||||
|
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show clampDouble;
|
||||||
|
|
||||||
|
/// Returns the absolute target scale for a pinch frame.
|
||||||
|
///
|
||||||
|
/// [scaleStart] — matrix scale captured at the current baseline.
|
||||||
|
/// [rawScaleAtBaseline] — recognizer cumulative `details.scale` at that
|
||||||
|
/// baseline (1.0 at gesture start; the live value at a re-baseline).
|
||||||
|
/// [rawScale] — the recognizer's current cumulative `details.scale`.
|
||||||
|
/// Result is clamped to [minScale, maxScale].
|
||||||
|
double absolutePinchScale({
|
||||||
|
required double scaleStart,
|
||||||
|
required double rawScaleAtBaseline,
|
||||||
|
required double rawScale,
|
||||||
|
required double minScale,
|
||||||
|
required double maxScale,
|
||||||
|
}) {
|
||||||
|
final double cumulative =
|
||||||
|
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
|
||||||
|
return clampDouble(scaleStart * cumulative, minScale, maxScale);
|
||||||
|
}
|
||||||
64
test/pinch_scale_solver_test.dart
Normal file
64
test/pinch_scale_solver_test.dart
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Proves the absolute pinch-zoom math, focused on the re-baseline case that
|
||||||
|
// produced the on-device flicker: when a finger blips (2→1→2) mid-pinch the
|
||||||
|
// viewer captures a fresh baseline, and the OLD code multiplied that fresh
|
||||||
|
// scaleStart by the recognizer's still-cumulative details.scale — popping the
|
||||||
|
// zoom to a wrong value and snapping back. absolutePinchScale() normalizes
|
||||||
|
// against the baseline so the pop cannot happen.
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/canvas/pinch_scale_solver.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const min = 0.5, max = 8.0;
|
||||||
|
|
||||||
|
double solve(double scaleStart, double baseline, double raw) =>
|
||||||
|
absolutePinchScale(
|
||||||
|
scaleStart: scaleStart,
|
||||||
|
rawScaleAtBaseline: baseline,
|
||||||
|
rawScale: raw,
|
||||||
|
minScale: min,
|
||||||
|
maxScale: max,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('at gesture start (baseline 1.0) target tracks raw directly', () {
|
||||||
|
// scaleStart 1.0, baseline 1.0: pinch out to raw 2.0 → scale 2.0.
|
||||||
|
expect(solve(1.0, 1.0, 2.0), closeTo(2.0, 1e-9));
|
||||||
|
// pinch in to raw 0.5 → scale 0.5.
|
||||||
|
expect(solve(1.0, 1.0, 0.5), closeTo(0.5, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a re-baseline at the SAME instant does not change the scale', () {
|
||||||
|
// Pinch in: start(2.0,1.0) → raw 0.6 gives scale 1.2 (well inside clamp).
|
||||||
|
final before = solve(2.0, 1.0, 0.6);
|
||||||
|
expect(before, closeTo(1.2, 1e-9));
|
||||||
|
|
||||||
|
// A finger blips: the viewer re-baselines RIGHT HERE — scaleStart becomes
|
||||||
|
// the current scale (1.2) and rawScaleAtBaseline becomes the current raw
|
||||||
|
// (0.6). Re-evaluating the same instant must yield the SAME scale (no pop).
|
||||||
|
final after = solve(1.2, 0.6, 0.6);
|
||||||
|
expect(after, closeTo(before, 1e-9),
|
||||||
|
reason: 're-baseline must be continuous, not a jump');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after a re-baseline the pinch stays smooth (no pop)', () {
|
||||||
|
// Re-baselined at scale 1.2 / raw 0.6. Continue pinching in: raw 0.54.
|
||||||
|
// Correct: 1.2 * (0.54 / 0.6) = 1.08 — a gentle 10% step, monotonic.
|
||||||
|
final next = solve(1.2, 0.6, 0.54);
|
||||||
|
expect(next, closeTo(1.08, 1e-9));
|
||||||
|
|
||||||
|
// The OLD bug multiplied the fresh scaleStart by the un-normalized raw:
|
||||||
|
// 1.2 * 0.54 = 0.648 — a sudden ~46% drop (the flicker). Guard against it.
|
||||||
|
expect(next, greaterThan(1.0),
|
||||||
|
reason: 'must NOT collapse to scaleStart*raw (the old 0.648 pop)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('result is clamped to [min, max]', () {
|
||||||
|
expect(solve(4.0, 1.0, 4.0), max); // 16 → 8
|
||||||
|
expect(solve(1.0, 1.0, 0.1), min); // 0.1 → 0.5
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degenerate baseline (0) is treated as no cumulative change', () {
|
||||||
|
expect(solve(2.0, 0.0, 5.0), closeTo(2.0, 1e-9));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user