1

Background

Trying to setup a simple image editor, allowing users to scale and move images around with gestures.

Scaling and moving works well via GestureDetector, Transform.scale and Transform.rotate.

Problem

Upon scaling, the user can still scale the already scaled images.

But: The GestureDetector does not change the area for performing hit tests.

Problem: The user can use the original hitbox only for manipulating images. It is not possible to scale the image by using the two-finger pinching gesture on the extended, outer shape.

Images

The first image demonstrates the basic setup.

The second image demonstrates the the result of using a gesture. It shows the small, unchanged inner hitbox. As well as the the resulting scaled and rotated shape.

The filled box is the hitbox. The outer rectangle shows the scaled image.

enter image description here enter image description hereenter image description here

Desired Behavior

Using the two-finger pinching gesture on the scaled, outer shape should allow further manipulation of the object.

Instead, the inner hit box can be used alone. But a user expects to use the scaled, outer shape for further scaling and moving the object.

Code

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: GestureTest(),
    );
  }
}

class DrawContainer {
  Color color;
  Offset offset;
  double scale;
  double angle;
  late double baseScaleFactor;

  DrawContainer(this.color, this.offset, this.scale, this.angle) {
    baseScaleFactor = scale;
  }

  onScaleStart() => baseScaleFactor = scale;

  onScaleUpdate(double scaleNew) =>
      scale = (baseScaleFactor * scaleNew).clamp(0.5, 5);
}

class GestureTest extends StatefulWidget {
  const GestureTest({Key? key}) : super(key: key);

  @override
  // ignore: library_private_types_in_public_api
  _GestureTestState createState() => _GestureTestState();
}

class _GestureTestState extends State<GestureTest> {
  bool doRedraw = false;

  final List<DrawContainer> containers = [
    DrawContainer(Colors.red, const Offset(50, 50), 1.0, 0.0),
    DrawContainer(Colors.yellow, const Offset(100, 100), 1.0, 0.0),
    DrawContainer(Colors.green, const Offset(150, 150), 1.0, 0.0),
  ];

  void onGestureStart(DrawContainer e) => e.onScaleStart();

  onGestureUpdate(DrawContainer e, ScaleUpdateDetails d) {
    e.offset = e.offset + d.focalPointDelta;
    if (d.rotation != 0.0) e.angle = d.rotation;
    if (d.scale != 1.0) e.onScaleUpdate(d.scale);
    setState(() => doRedraw = !doRedraw); // redraw
  }

  void rebuildAllChildren(BuildContext context) {
    void rebuild(Element el) {
      el.markNeedsBuild();
      el.visitChildren(rebuild);
    }

    (context as Element).visitChildren(rebuild);
  }

  @override
  Widget build(BuildContext context) {
    rebuildAllChildren(context);
    return SafeArea(
        child: Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          doRedraw ? const SizedBox.shrink() : const SizedBox.shrink(),
          ...containers.map((e) {
            return Positioned(
                top: e.offset.dy,
                left: e.offset.dx,
                child: Container(
                  color: e.color,
                  child: GestureDetector(
                      onScaleStart: (details) {
                        if (details.pointerCount == 2) {
                          onGestureStart(e);
                        }
                      },
                      onScaleUpdate: (details) => onGestureUpdate(e, details),
                      child: Transform.rotate(
                          angle: e.angle,
                          child: Transform.scale(
                            scale: e.scale,
                            child: Container(
                                decoration: BoxDecoration(
                                    border: Border.all(color: e.color)),
                                width: 100,
                                height: 100),
                            // Text(e.label, style: const TextStyle(fontSize: 40)),
                          ))),
                  // ),
                ));
          }).toList(),
        ],
      ),
    ));
  }
}

Dabbel
  • 2,468
  • 1
  • 8
  • 25

1 Answers1

5

Below is a generic, working example with the hitbox' size matching the scaled widget's size.

The basic structure is as follows:

SizedBox (infinite size) # may not be needed
- Stack
  - GestureDetector for each Widget
    - Stack 
      - Positioned, Transform 
        - Widget
import 'package:flutter/material.dart';

// -------------------------------------------------------------------
// THE ITEM TO BE DRAWN
// -------------------------------------------------------------------

class DrawContainer {
  Color color;
  Offset offset;
  double width;
  double height;
  double scale;
  double angle;
  late double _baseScaleFactor;
  late double _baseAngleFactor;

  DrawContainer(this.color, this.offset, this.width, this.height, this.scale,
      this.angle) {
    onScaleStart();
  }

  onScaleStart() {
    _baseScaleFactor = scale;
    _baseAngleFactor = angle;
  }

  onScaleUpdate(double scaleNew) =>
      scale = (_baseScaleFactor * scaleNew).clamp(0.5, 5);

  onRotateUpdate(double angleNew) => angle = _baseAngleFactor + angleNew;
}

// -------------------------------------------------------------------
// APP
// -------------------------------------------------------------------

void main() {
  runApp(const MaterialApp(home: GestureTest()));
}

class GestureTest extends StatefulWidget {
  const GestureTest({Key? key}) : super(key: key);

  @override
  // ignore: library_private_types_in_public_api
  _GestureTestState createState() => _GestureTestState();
}

// -------------------------------------------------------------------
// APP STATE
// -------------------------------------------------------------------

class _GestureTestState extends State<GestureTest> {
  final List<DrawContainer> containers = [
    DrawContainer(Colors.red, const Offset(50, 50), 100, 100, 1.0, 0.0),
    DrawContainer(Colors.yellow, const Offset(100, 100), 200, 100, 1.0, 0.0),
    DrawContainer(Colors.green, const Offset(150, 150), 50, 100, 1.0, 0.0),
  ];

  void onGestureStart(DrawContainer e) => e.onScaleStart();

  onGestureUpdate(DrawContainer e, ScaleUpdateDetails d) {
    e.offset = e.offset + d.focalPointDelta;
    if (d.rotation != 0.0) e.onRotateUpdate(d.rotation);
    if (d.scale != 1.0) e.onScaleUpdate(d.scale);
    setState(() {}); // redraw
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
        child: Scaffold(
      body: SizedBox(
        height: double.infinity,
        width: double.infinity,
        child: Stack(
          children: [
            ...containers.map((e) {
              return GestureDetector(
                  onScaleStart: (details) {
                    // detect two fingers to reset internal factors
                    if (details.pointerCount == 2) {
                      onGestureStart(e);
                    }
                  },
                  onScaleUpdate: (details) => onGestureUpdate(e, details),
                  child: DrawWidget(e));
            }).toList(),
          ],
        ),
      ),
    ));
  }
}

// -------------------------------------------------------------------
// POSITION, ROTATE AND SCALE THE WIDGET
// -------------------------------------------------------------------

class DrawWidget extends StatelessWidget {
  final DrawContainer e;
  const DrawWidget(this.e, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned(
          left: e.offset.dx,
          top: e.offset.dy,
          child: Transform.rotate(
            angle: e.angle,
            child: Transform.scale(
              scale: e.scale,
              child: Container(
                height: e.width,
                width: e.height,
                color: e.color,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

This test case has been helpful: https://stackoverflow.com/a/68360447/12098106

Dabbel
  • 2,468
  • 1
  • 8
  • 25
  • Thank you!!! The Stack child of the GestureDetector did the trick! I spent some hours searching for this. – testerino Mar 02 '23 at 10:42
  • @testerino You are welcome. Took some time to find the solution, too. – Dabbel Mar 03 '23 at 14:05
  • I have to say that this solution only works if you have only one item. In my case I could not us this. But finally I discovered that using Matrix4 and Transform to scale the Widget makes the GestureDetector hitbox fit the Matri4 transformation. – testerino Mar 06 '23 at 07:05
  • @testerino Mh, it works for more than one item for me. I use it in production. Good, that you found a solution. – Dabbel Mar 07 '23 at 09:06