feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
All checks were successful
CI / Windows build (push) Successful in 18m35s
All checks were successful
CI / Windows build (push) Successful in 18m35s
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite boundary, no rotation — that machinery dropped as a no-op here). Two deliberate changes, grounded in the Rnote/Saber research: 1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via `supportedDevices`. The pen never reaches it, so a stylus stroke can no longer be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by stock IV's panEnabled updating a frame after the stroke began). Drawing is owned solely by the canvas Listener; no arena fight, no panEnabled lag. The prior _lastStylus hover hack is removed (superseded). 2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal jitter and guards the pan branch, but a single-frame multi-touch glitch could still spike details.scale, popping the zoom bigger/smaller and snapping back (the reported pinch flicker). Clamping swallows the spike; a real (gradual) pinch is unaffected since scale tracks absolutely from gesture start. Cap is far above any real pinch (~1.1-1.2x/frame), so no felt lag. Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is Flutter's proven logic verbatim. Also add an on-device input diagnostic (bug-report toggle): the existing pen readout already prints kind/pressure/buttons; now it also shows live zoom=now/min/max so the next device test captures (a) whether the side/eraser button arrives as buttons/invertedStylus, and (b) the value any residual pinch flash jumps to. 66/66 tests, analyze clean, linux build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||
import '../input/pen_config.dart';
|
||||
import '../input/pen_input_service.dart';
|
||||
import 'ink_painters.dart';
|
||||
import 'pen_interactive_viewer.dart';
|
||||
import 'pen_stroke.dart';
|
||||
|
||||
/// The active tool on the pen canvas.
|
||||
@@ -132,13 +133,6 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
/// mode / the pen is not near the page. Drives [EraserPreviewPainter].
|
||||
PenPoint? _eraserCursor;
|
||||
|
||||
/// True when the most recent pointer was a stylus. Set on stylus HOVER, which
|
||||
/// precedes contact on Windows, so the InteractiveViewer's pan is already
|
||||
/// disabled BEFORE the stroke starts — killing the 1-frame pan-steal that
|
||||
/// corrupts fast strokes (the "写字识别成单击" feel bug). A finger/mouse down
|
||||
/// flips it back so finger-pan still works.
|
||||
bool _lastStylus = false;
|
||||
|
||||
/// True when the eraser would act (eraser tool selected, or a barrel/inverted
|
||||
/// eraser signal is live).
|
||||
bool get _isEraserMode =>
|
||||
@@ -153,11 +147,6 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
? 1.0
|
||||
: widget.pageSize.height / widget.pageSize.width;
|
||||
|
||||
void _setLastStylus(bool v) {
|
||||
if (_lastStylus == v) return;
|
||||
setState(() => _lastStylus = v);
|
||||
}
|
||||
|
||||
/// Update (or clear) the eraser-preview cursor from a global pointer position.
|
||||
void _updateEraserCursor(Offset globalPosition) {
|
||||
if (_isEraserMode) {
|
||||
@@ -416,7 +405,6 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
void _onPointerHover(PointerHoverEvent event) {
|
||||
if (_isStylus(event.kind)) {
|
||||
_setLastStylus(true);
|
||||
_emitPenDebug(event);
|
||||
// Fire edge-triggered button actions (undo / toggleTool) on hover so a
|
||||
// mapped barrel press works without first touching down.
|
||||
@@ -430,7 +418,6 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
void _onPointerDown(PointerDownEvent event) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) return;
|
||||
_setLastStylus(_isStylus(event.kind));
|
||||
if (_isStylus(event.kind)) {
|
||||
_emitPenDebug(event);
|
||||
// Fire edge-triggered button actions for a direct pen-down (no prior
|
||||
@@ -482,17 +469,12 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Pan rules (pen-first):
|
||||
// - A 2+ pointer pinch ALWAYS pans (the focal-point translation is part of
|
||||
// zooming), regardless of pen state.
|
||||
// - Otherwise pan only when NOT mid-stroke AND the last pointer was not a
|
||||
// stylus. Because Windows fires stylus HOVER before contact, _lastStylus
|
||||
// is already true when the pen touches down, so the InteractiveViewer's
|
||||
// pan is disabled BEFORE the stroke's first move — no 1-frame pan-steal
|
||||
// that would corrupt a fast flick into a tap.
|
||||
final panEnabled = _activePointers.length >= 2
|
||||
? true
|
||||
: (_drawPointer == null && !_lastStylus);
|
||||
// The PEN never reaches PenInteractiveViewer's recognizer (it excludes
|
||||
// stylus), so a stylus stroke can never be stolen as a pan. panEnabled only
|
||||
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
|
||||
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
|
||||
// so a pinch re-enables pan/zoom immediately.
|
||||
final panEnabled = _drawPointer == null;
|
||||
|
||||
return Listener(
|
||||
onPointerHover: _onPointerHover,
|
||||
@@ -500,14 +482,12 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
onPointerMove: _onPointerMove,
|
||||
onPointerUp: _onPointerUp,
|
||||
onPointerCancel: _onPointerCancel,
|
||||
child: InteractiveViewer(
|
||||
child: PenInteractiveViewer(
|
||||
transformationController: widget.transformationController,
|
||||
minScale: widget.minScale,
|
||||
maxScale: widget.maxScale,
|
||||
panEnabled: panEnabled,
|
||||
scaleEnabled: true,
|
||||
constrained: false,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
child: SizedBox(
|
||||
width: widget.pageSize.width,
|
||||
height: widget.pageSize.height,
|
||||
|
||||
@@ -95,6 +95,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
String _penDebug = '';
|
||||
bool _showPenDebug = false;
|
||||
|
||||
/// Live zoom-scale diagnostic (only tracked while the overlay is on), so we
|
||||
/// can capture the value the pinch flash jumps to. `_zoomMin/_zoomMax` record
|
||||
/// the extremes seen since the overlay was last enabled.
|
||||
double _zoomNow = 1.0;
|
||||
double _zoomMin = double.infinity;
|
||||
double _zoomMax = 0.0;
|
||||
|
||||
// Tool state.
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
Color _color = Colors.black;
|
||||
@@ -119,11 +126,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
|
||||
// No-op on platforms without the plugin (W3).
|
||||
PenInputService.instance.start();
|
||||
_transform.addListener(_onTransformDebug);
|
||||
_initPersistence();
|
||||
_initPenConfig();
|
||||
_open();
|
||||
}
|
||||
|
||||
/// Track the live zoom scale for the diagnostic overlay (no-op when off).
|
||||
void _onTransformDebug() {
|
||||
if (!_showPenDebug) return;
|
||||
final s = _transform.value.getMaxScaleOnAxis();
|
||||
setState(() {
|
||||
_zoomNow = s;
|
||||
if (s < _zoomMin) _zoomMin = s;
|
||||
if (s > _zoomMax) _zoomMax = s;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initPenConfig() async {
|
||||
final controller = await PenConfigController.load();
|
||||
if (!mounted) {
|
||||
@@ -221,6 +240,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
scheduler.dispose();
|
||||
}
|
||||
_document?.dispose();
|
||||
_transform.removeListener(_onTransformDebug);
|
||||
_transform.dispose();
|
||||
_penConfig?.dispose();
|
||||
PenInputService.instance.stop();
|
||||
@@ -437,9 +457,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
_penDebug.isEmpty
|
||||
? 'hover / draw with the pen…'
|
||||
: _penDebug,
|
||||
'${_penDebug.isEmpty ? 'hover / draw with the pen…' : _penDebug}'
|
||||
'\nzoom=${_zoomNow.toStringAsFixed(2)} '
|
||||
'min=${_zoomMin.isFinite ? _zoomMin.toStringAsFixed(2) : '-'} '
|
||||
'max=${_zoomMax.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
@@ -600,8 +621,15 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_ToolButton(
|
||||
icon: Icons.bug_report_outlined,
|
||||
selected: _showPenDebug,
|
||||
tooltip: 'Pen pressure diagnostic',
|
||||
onPressed: () => setState(() => _showPenDebug = !_showPenDebug),
|
||||
tooltip: 'Pen + zoom diagnostic',
|
||||
onPressed: () => setState(() {
|
||||
_showPenDebug = !_showPenDebug;
|
||||
if (_showPenDebug) {
|
||||
_zoomMin = double.infinity;
|
||||
_zoomMax = 0.0;
|
||||
_zoomNow = _transform.value.getMaxScaleOnAxis();
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
411
lib/editor/canvas/pen_interactive_viewer.dart
Normal file
411
lib/editor/canvas/pen_interactive_viewer.dart
Normal file
@@ -0,0 +1,411 @@
|
||||
// lib/editor/canvas/pen_interactive_viewer.dart
|
||||
//
|
||||
// A focused fork of Flutter 3.44's InteractiveViewer, adapted for the pen-first
|
||||
// canvas (clean-room model shared with Saber). Two deliberate changes vs stock:
|
||||
//
|
||||
// 1. The pan/zoom ScaleGestureRecognizer is restricted to NON-stylus devices
|
||||
// (`supportedDevices` excludes stylus / invertedStylus). The pen therefore
|
||||
// never reaches this recognizer — it only draws via the canvas `Listener`.
|
||||
// This removes the gesture-arena fight and, crucially, the one-frame
|
||||
// "pan-steal" where a stylus stroke's first frame was consumed as a pan
|
||||
// (the "写字识别成单击" feel bug) because stock InteractiveViewer's
|
||||
// `panEnabled` only updated a frame after the stroke had begun.
|
||||
//
|
||||
// 2. The per-frame scale change is clamped (`_kMin/_MaxScaleChangePerFrame`).
|
||||
// Stock InteractiveViewer already damps focal jitter and guards the pan
|
||||
// branch, but a single-frame multi-touch glitch can still spike
|
||||
// `details.scale`, popping the zoom bigger/smaller for one frame and then
|
||||
// snapping back (the reported pinch flicker). Clamping the per-update change
|
||||
// swallows that spike without affecting a real (gradual) pinch, since scale
|
||||
// is tracked absolutely from gesture start and simply catches up next frame.
|
||||
//
|
||||
// Everything else (scale-about-focal math, pan, fling inertia, mouse-wheel zoom)
|
||||
// is Flutter's proven logic. The boundary/rotation/panAxis machinery is dropped
|
||||
// because this canvas always uses an infinite boundary, free pan, and no
|
||||
// rotation — so that code was provably a no-op here.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show clampDouble;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
|
||||
/// is owned exclusively by the drawing `Listener`.
|
||||
const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.unknown,
|
||||
};
|
||||
|
||||
/// Per-frame multiplicative scale-change clamp (flicker guard).
|
||||
const double _kMaxScaleChangePerFrame = 1.35;
|
||||
const double _kMinScaleChangePerFrame = 1 / _kMaxScaleChangePerFrame;
|
||||
|
||||
const double _kDrag = 0.0000135;
|
||||
|
||||
enum _GestureType { pan, scale }
|
||||
|
||||
/// Pan + zoom for the pen canvas. The pen never reaches this widget's gesture
|
||||
/// recognizer; only touch / mouse / trackpad pan and zoom the shared transform.
|
||||
class PenInteractiveViewer extends StatefulWidget {
|
||||
const PenInteractiveViewer({
|
||||
super.key,
|
||||
required this.transformationController,
|
||||
required this.child,
|
||||
this.minScale = 0.5,
|
||||
this.maxScale = 8.0,
|
||||
this.panEnabled = true,
|
||||
this.scaleEnabled = true,
|
||||
this.scaleFactor = kDefaultMouseScrollToScaleFactor,
|
||||
this.interactionEndFrictionCoefficient = _kDrag,
|
||||
}) : assert(minScale > 0),
|
||||
assert(maxScale >= minScale);
|
||||
|
||||
final TransformationController transformationController;
|
||||
final Widget child;
|
||||
final double minScale;
|
||||
final double maxScale;
|
||||
final bool panEnabled;
|
||||
final bool scaleEnabled;
|
||||
final double scaleFactor;
|
||||
final double interactionEndFrictionCoefficient;
|
||||
|
||||
@override
|
||||
State<PenInteractiveViewer> createState() => _PenInteractiveViewerState();
|
||||
}
|
||||
|
||||
class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
with TickerProviderStateMixin {
|
||||
TransformationController get _transformer => widget.transformationController;
|
||||
|
||||
final GlobalKey _childKey = GlobalKey();
|
||||
Animation<Offset>? _animation;
|
||||
Animation<double>? _scaleAnimation;
|
||||
late Offset _scaleAnimationFocalPoint;
|
||||
late AnimationController _controller;
|
||||
late AnimationController _scaleController;
|
||||
Offset? _referenceFocalPoint;
|
||||
double? _scaleStart;
|
||||
_GestureType? _gestureType;
|
||||
|
||||
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
|
||||
|
||||
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
|
||||
if (translation == Offset.zero) return matrix.clone();
|
||||
return matrix.clone()
|
||||
..translateByDouble(translation.dx, translation.dy, 0, 1);
|
||||
}
|
||||
|
||||
Matrix4 _matrixScale(Matrix4 matrix, double scale) {
|
||||
if (scale == 1.0) return matrix.clone();
|
||||
assert(scale != 0.0);
|
||||
final double currentScale = _transformer.value.getMaxScaleOnAxis();
|
||||
final double clampedTotalScale = clampDouble(
|
||||
currentScale * scale,
|
||||
widget.minScale,
|
||||
widget.maxScale,
|
||||
);
|
||||
final double clampedScale = clampedTotalScale / currentScale;
|
||||
return matrix.clone()
|
||||
..scaleByDouble(clampedScale, clampedScale, clampedScale, 1);
|
||||
}
|
||||
|
||||
bool _gestureIsSupported(_GestureType? gestureType) => switch (gestureType) {
|
||||
_GestureType.scale => widget.scaleEnabled,
|
||||
_GestureType.pan || null => widget.panEnabled,
|
||||
};
|
||||
|
||||
_GestureType _getGestureType(ScaleUpdateDetails details) {
|
||||
final double scale = widget.scaleEnabled ? details.scale : 1.0;
|
||||
return (scale - 1).abs() > 0 ? _GestureType.scale : _GestureType.pan;
|
||||
}
|
||||
|
||||
// --- Gesture lifecycle ----------------------------------------------------
|
||||
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
if (_controller.isAnimating) {
|
||||
_controller.stop();
|
||||
_controller.reset();
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_animation = null;
|
||||
}
|
||||
if (_scaleController.isAnimating) {
|
||||
_scaleController.stop();
|
||||
_scaleController.reset();
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_scaleAnimation = null;
|
||||
}
|
||||
_gestureType = null;
|
||||
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
}
|
||||
|
||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
_scaleAnimationFocalPoint = details.localFocalPoint;
|
||||
final Offset focalPointScene = _transformer.toScene(details.localFocalPoint);
|
||||
|
||||
if (_gestureType == _GestureType.pan) {
|
||||
// A 2-finger gesture can start with no scale change; allow re-typing it.
|
||||
_gestureType = _getGestureType(details);
|
||||
} else {
|
||||
_gestureType ??= _getGestureType(details);
|
||||
}
|
||||
if (!_gestureIsSupported(_gestureType)) return;
|
||||
|
||||
switch (_gestureType!) {
|
||||
case _GestureType.scale:
|
||||
assert(_scaleStart != null);
|
||||
final double desiredScale = _scaleStart! * details.scale;
|
||||
// Flicker guard: clamp the per-frame change so a single-frame touch
|
||||
// jitter can't pop the zoom and snap back. Absolute tracking means a
|
||||
// real pinch just resumes next frame.
|
||||
final double scaleChange = clampDouble(
|
||||
desiredScale / scale,
|
||||
_kMinScaleChangePerFrame,
|
||||
_kMaxScaleChangePerFrame,
|
||||
);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
|
||||
// Keep the focal point anchored under the fingers across the scale.
|
||||
final Offset focalPointSceneScaled =
|
||||
_transformer.toScene(details.localFocalPoint);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - _referenceFocalPoint!,
|
||||
);
|
||||
|
||||
// Re-anchor only when the rounded focal actually drifted (jitter damp).
|
||||
final Offset focalPointSceneCheck =
|
||||
_transformer.toScene(details.localFocalPoint);
|
||||
if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) {
|
||||
_referenceFocalPoint = focalPointSceneCheck;
|
||||
}
|
||||
|
||||
case _GestureType.pan:
|
||||
assert(_referenceFocalPoint != null);
|
||||
// Throw away near-scale frames so a stale reference can't jump the pan.
|
||||
if (details.scale != 1.0) return;
|
||||
final Offset translationChange =
|
||||
focalPointScene - _referenceFocalPoint!;
|
||||
_transformer.value =
|
||||
_matrixTranslate(_transformer.value, translationChange);
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
}
|
||||
}
|
||||
|
||||
void _onScaleEnd(ScaleEndDetails details) {
|
||||
_scaleStart = null;
|
||||
_referenceFocalPoint = null;
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_controller.reset();
|
||||
_scaleController.reset();
|
||||
|
||||
if (!_gestureIsSupported(_gestureType)) return;
|
||||
|
||||
switch (_gestureType) {
|
||||
case _GestureType.pan:
|
||||
if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
|
||||
return;
|
||||
}
|
||||
final translationVector = _transformer.value.getTranslation();
|
||||
final Offset translation =
|
||||
Offset(translationVector.x, translationVector.y);
|
||||
final FrictionSimulation frictionSimulationX = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
translation.dx,
|
||||
details.velocity.pixelsPerSecond.dx,
|
||||
);
|
||||
final FrictionSimulation frictionSimulationY = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
translation.dy,
|
||||
details.velocity.pixelsPerSecond.dy,
|
||||
);
|
||||
final double tFinal = _getFinalTime(
|
||||
details.velocity.pixelsPerSecond.distance,
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
);
|
||||
_animation = Tween<Offset>(
|
||||
begin: translation,
|
||||
end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX),
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.decelerate));
|
||||
_controller.duration = Duration(milliseconds: (tFinal * 1000).round());
|
||||
_animation!.addListener(_handleInertiaAnimation);
|
||||
_controller.forward();
|
||||
case _GestureType.scale:
|
||||
if (details.scaleVelocity.abs() < 0.1) return;
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
final FrictionSimulation frictionSimulation = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
|
||||
scale,
|
||||
details.scaleVelocity / 10,
|
||||
);
|
||||
final double tFinal = _getFinalTime(
|
||||
details.scaleVelocity.abs(),
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
effectivelyMotionless: 0.1,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: scale,
|
||||
end: frictionSimulation.x(tFinal),
|
||||
).animate(
|
||||
CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
|
||||
_scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
|
||||
_scaleAnimation!.addListener(_handleScaleAnimation);
|
||||
_scaleController.forward();
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mouse wheel / trackpad zoom ------------------------------------------
|
||||
|
||||
void _receivedPointerSignal(PointerSignalEvent event) {
|
||||
final double scaleChange;
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) {
|
||||
// Trackpad scroll → pan.
|
||||
if (!_gestureIsSupported(_GestureType.pan)) return;
|
||||
final Offset localDelta = PointerEvent.transformDeltaViaPositions(
|
||||
untransformedEndPosition: event.position + event.scrollDelta,
|
||||
untransformedDelta: event.scrollDelta,
|
||||
transform: event.transform,
|
||||
);
|
||||
final Offset focalPointScene = _transformer.toScene(event.localPosition);
|
||||
final Offset newFocalPointScene =
|
||||
_transformer.toScene(event.localPosition - localDelta);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
newFocalPointScene - focalPointScene,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.scrollDelta.dy == 0.0) return;
|
||||
scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor);
|
||||
} else if (event is PointerScaleEvent) {
|
||||
scaleChange = event.scale;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!_gestureIsSupported(_GestureType.scale)) return;
|
||||
|
||||
final Offset focalPointScene = _transformer.toScene(event.localPosition);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
final Offset focalPointSceneScaled =
|
||||
_transformer.toScene(event.localPosition);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - focalPointScene,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleInertiaAnimation() {
|
||||
if (!_controller.isAnimating) {
|
||||
_animation?.removeListener(_handleInertiaAnimation);
|
||||
_animation = null;
|
||||
_controller.reset();
|
||||
return;
|
||||
}
|
||||
final translationVector = _transformer.value.getTranslation();
|
||||
final Offset translation = Offset(translationVector.x, translationVector.y);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
_transformer.toScene(_animation!.value) - _transformer.toScene(translation),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScaleAnimation() {
|
||||
if (!_scaleController.isAnimating) {
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_scaleAnimation = null;
|
||||
_scaleController.reset();
|
||||
return;
|
||||
}
|
||||
final double desiredScale = _scaleAnimation!.value;
|
||||
final double scaleChange =
|
||||
desiredScale / _transformer.value.getMaxScaleOnAxis();
|
||||
final Offset referenceFocalPoint =
|
||||
_transformer.toScene(_scaleAnimationFocalPoint);
|
||||
_transformer.value = _matrixScale(_transformer.value, scaleChange);
|
||||
final Offset focalPointSceneScaled =
|
||||
_transformer.toScene(_scaleAnimationFocalPoint);
|
||||
_transformer.value = _matrixTranslate(
|
||||
_transformer.value,
|
||||
focalPointSceneScaled - referenceFocalPoint,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTransformation() => setState(() {});
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this);
|
||||
_scaleController = AnimationController(vsync: this);
|
||||
_transformer.addListener(_handleTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PenInteractiveViewer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.transformationController != widget.transformationController) {
|
||||
oldWidget.transformationController.removeListener(_handleTransformation);
|
||||
widget.transformationController.addListener(_handleTransformation);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scaleController.dispose();
|
||||
_transformer.removeListener(_handleTransformation);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Transform(
|
||||
transform: _transformer.value,
|
||||
child: KeyedSubtree(key: _childKey, child: widget.child),
|
||||
);
|
||||
child = OverflowBox(
|
||||
alignment: Alignment.topLeft,
|
||||
minWidth: 0.0,
|
||||
minHeight: 0.0,
|
||||
maxWidth: double.infinity,
|
||||
maxHeight: double.infinity,
|
||||
child: child,
|
||||
);
|
||||
child = ClipRect(child: child);
|
||||
|
||||
return Listener(
|
||||
onPointerSignal: _receivedPointerSignal,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
supportedDevices: _kPanZoomDevices,
|
||||
onScaleStart: _onScaleStart,
|
||||
onScaleUpdate: _onScaleUpdate,
|
||||
onScaleEnd: _onScaleEnd,
|
||||
trackpadScrollCausesScale: false,
|
||||
trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double _getFinalTime(double velocity, double drag,
|
||||
{double effectivelyMotionless = 10}) {
|
||||
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
|
||||
}
|
||||
|
||||
Offset _round(Offset offset) {
|
||||
return Offset(
|
||||
double.parse(offset.dx.toStringAsFixed(9)),
|
||||
double.parse(offset.dy.toStringAsFixed(9)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user