In a Flutter sticker editor, how do you let users drag, pinch-resize and rotate a sticker with GestureDetector, and why not combine onPan and onScale?
answer
- scale is a superset of pan
- one pointer: scale 1.0, rotation 0
- snapshot in onScaleStart
- scale and rotation are cumulative
- detector inside the Transform
basics
~20 sUse only the scale callbacks: onScaleUpdate reports focal point movement, a cumulative scale and a rotation, so one recognizer handles drag, pinch and rotate. Passing pan and scale together fails a debug assertion, because scale is a superset of pan.
solid answer
~40 sI give the sticker's `GestureDetector` `onScaleStart` and `onScaleUpdate` only, plus `onTap` for selection. With one finger the scale recognizer reports movement with `scale` 1.0, so it covers dragging; with two it adds `scale` and `rotation`. Both are relative to the gesture's start, so in `onScaleStart` I snapshot position, scale, rotation and the global `focalPoint`, and in `onScaleUpdate` I apply `startScale * details.scale`, `startRotation + details.rotation` and the focal-point difference. Combining pan and scale callbacks on one detector trips a debug assertion saying scale is a superset of pan. I place the detector inside the `Transform` so the touch area follows the enlarged sticker.
code
dart · 60 linesimport 'package:flutter/material.dart';
class Sticker extends StatefulWidget {
const Sticker({super.key, required this.asset, required this.onSelected});
final String asset;
final VoidCallback onSelected;
@override
State<Sticker> createState() => _StickerState();
}
class _StickerState extends State<Sticker> {
Offset _position = const Offset(80, 120);
double _scale = 1;
double _rotation = 0;
// Snapshot taken when a scale gesture (re)starts.
Offset _startPosition = Offset.zero;
Offset _startFocal = Offset.zero;
double _startScale = 1;
double _startRotation = 0;
void _onScaleStart(ScaleStartDetails d) {
_startPosition = _position;
_startFocal = d.focalPoint; // global coordinates
_startScale = _scale;
_startRotation = _rotation;
}
void _onScaleUpdate(ScaleUpdateDetails d) {
setState(() {
_position = _startPosition + (d.focalPoint - _startFocal);
_scale = (_startScale * d.scale).clamp(0.3, 4.0);
_rotation = _startRotation + d.rotation;
});
}
@override
Widget build(BuildContext context) {
return Positioned(
left: _position.dx,
top: _position.dy,
child: Transform.rotate(
angle: _rotation,
child: Transform.scale(
scale: _scale,
// Inside the transforms, so the touch area follows what is painted.
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onSelected,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: Image.asset(widget.asset, width: 120),
),
),
),
);
}
}go deeper
Remember that the scale callbacks alone handle dragging, pinching and rotating, and that pan and scale must not be combined on one detector.
Explain why scale and rotation are relative to the gesture start, the snapshot pattern in onScaleStart, and what happens when finger count changes.
Handle transformed hit areas and coordinate spaces, clamp scale, and plan for conflicts when the canvas sits inside a scrollable or zoomable parent.
Decide whether a gesture-heavy editor builds on GestureDetector or on a dedicated transform widget, weighing control, testability and maintenance.
## Scale is a superset of pan `GestureDetector` offers a **pan** family (`onPanStart`, `onPanUpdate`, `onPanEnd`) for one-finger dragging in any direction, and a **scale** family (`onScaleStart`, `onScaleUpdate`, `onScaleEnd`) for multi-finger transforms. The scale recognizer also reports single-finger movement: with one pointer, `scale` stays 1.0, `rotation` stays 0 and the focal point simply follows the finger. That is why passing both families to one detector is a mistake. In debug builds the constructor fails with *"Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan."* and the hint *"Just use the scale gesture recognizer."* For a sticker that must move, resize and rotate, use **only the scale callbacks** — and keep `onTap` for selection, which is a separate tap recognizer. ## What the callbacks give you | Details class | Field | Meaning | |---|---|---| | `ScaleStartDetails` | `focalPoint`, `localFocalPoint` | centre of the touching pointers, global and local | | `ScaleStartDetails` | `pointerCount` | how many pointers are down | | `ScaleUpdateDetails` | `scale` | current span divided by the span when the gesture (re)started | | `ScaleUpdateDetails` | `horizontalScale`, `verticalScale` | the same ratio per axis | | `ScaleUpdateDetails` | `rotation` | angle in radians of the first two pointers, relative to their starting line | | `ScaleUpdateDetails` | `focalPoint`, `focalPointDelta` | where the centre is now, and how far it moved since the last update | | `ScaleEndDetails` | `velocity`, `scaleVelocity` | for a fling after release | ## The baseline pattern `scale` and `rotation` are **relative to the start of the gesture**, not to the previous update. The correct pattern therefore: 1. In `onScaleStart`, snapshot the sticker's current position, scale and rotation, and the global focal point. 2. In `onScaleUpdate`, compute `startScale * details.scale`, `startRotation + details.rotation`, and `startPosition + (details.focalPoint - startFocal)`. 3. Clamp the scale to sane limits so a sticker cannot vanish or fill the screen. Multiplying the stored scale by `details.scale` on every update compounds the ratio and makes the sticker explode in size — the most common bug in this code. When a finger is added or lifted mid-gesture, the recognizer ends the current gesture and starts a new one: `onScaleEnd` fires, then `onScaleStart` again once the pointers move, with `scale` back at 1.0. Taking the snapshot in `onScaleStart` handles that transparently. ## Hit area and coordinate spaces - **`Transform` does not change layout size.** A detector placed *outside* a `Transform.scale` keeps the untransformed box as its touch area, so an enlarged sticker only responds in its original rectangle. Place the detector *inside* the transforms; `Transform` inverts its matrix during hit testing, so touches map onto the painted sticker. - Once inside, `focalPointDelta` is reported in the receiver's local, transformed space; rotated or scaled deltas would move the sticker the wrong way. Using the difference of global `focalPoint` values, as above, avoids that. - Set `behavior: HitTestBehavior.opaque` so transparent margins of the sticker image's box also respond. ## Related knobs - `trackpadScrollCausesScale` (default `false`) lets trackpad scrolling drive scale on desktop. - `dragStartBehavior` (default `DragStartBehavior.start`) decides whether the first update is measured from where the finger went down or from where the drag was accepted. - If the canvas is inside a scroll view, drags compete with scrolling in the gesture arena — a separate topic.
- What happens to details.scale when a second finger lands during a one-finger drag?The recognizer reconfigures: it fires `onScaleEnd` for the current gesture and, once the pointers move, `onScaleStart` again, with the span of the new pointer set as the baseline. `details.scale` restarts at 1.0. Because the snapshot is taken in `onScaleStart`, the sticker continues smoothly from its current size.
- Why does an enlarged sticker ignore taps near its new edges when the GestureDetector wraps the Transform?`Transform` paints its child transformed but does not change its layout size, so a detector outside it keeps the original rectangle as its hit area. Putting the detector inside the `Transform` lets the framework map touches through the inverse matrix, so the whole painted sticker responds.
saying these in an interview costs you the question
- Use onPanUpdate for moving and onScaleUpdate for pinching on one detector.
- details.scale is the change since the previous update.
- Multiply the stored scale by details.scale on every update.
- Wrapping a Transform with GestureDetector makes the scaled area tappable.
- Rotation needs a separate rotation recognizer in GestureDetector.