skip to content

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?

level: middleimportance: should knowfreq 42%

answer

  1. scale is a superset of pan
  2. one pointer: scale 1.0, rotation 0
  3. snapshot in onScaleStart
  4. scale and rotation are cumulative
  5. detector inside the Transform

basics

~20 s

Use 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 s

I 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 lines
dart
import '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

for a junior

Remember that the scale callbacks alone handle dragging, pinching and rotating, and that pan and scale must not be combined on one detector.

for a middle

Explain why scale and rotation are relative to the gesture start, the snapshot pattern in onScaleStart, and what happens when finger count changes.

for a senior

Handle transformed hit areas and coordinate spaces, clamp scale, and plan for conflicts when the canvas sits inside a scrollable or zoomable parent.

for a principal

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.