Search code examples
fluttertransformgesturedetector

Flutter: GestureHandler and Transform.scale result in small hit box


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(),
        ],
      ),
    ));
  }
}


Solution

  • 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