How to make a Stack of Gesture Detectable Custom Painted Shapes

Issue

I managed to make these 3 weird shapes using CustomPainter the slide button shape, pan button shape and tilt button shape respectively… i want to make them detectable so i added GestureDetector but it doesnt work properly.

enter image description here

This is how i Stacked my Custom Painted shapes, I added GestureDetector to each CustomPaint but the area that they are gesture detectable are off completely i want the shapes to be detectable only within the boundaries of the shape.

class _MotionControl3State extends State<MotionControl3> {
  @override
  Widget build(BuildContext context) {
    final PageController controller = PageController();
    return Scaffold(
      backgroundColor: const Color(0xff2e2e2e),
      body: SafeArea(
        child: Column(
          children: [
            Container(
              color: Colors.red,
              height: 250,
              width: MediaQuery.of(context).size.width,
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 5.0),
                child: Stack(
                  alignment: Alignment.center,
                  children: [
                    Positioned(
                      top: 25,
                      right: 72,
                      child: GestureDetector(
                        onTap: (){
                          print('pan');
                       },
                      child: CustomPaint(
                      painter: RPSCustomPainter3(),size: Size(250, 200),
                    ),
                  ),
                    ),
                    Positioned(
                      top: 46,
                      child: GestureDetector(
                        onTap: (){
                          print('pan');
                        },
                        child: CustomPaint(
                          painter: RPSCustomPainter3(),size: Size(250, 200),
                        ),
                      ),
                    ),
                    Positioned(
                      top: 106,
                      right: 35,
                      child: GestureDetector(
                        onTap: (){
                          print('tilt');
                        },
                        child: CustomPaint(
                          painter: RPSCustomPainter2(),size: Size(300, 150),
                        ),
                      ),
                    ),
                    Positioned(
                      right:210,
                      child: Joystick(),
                    ),
                    Positioned(
                      left:210,
                      child: Joystick(),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

These are my Custom Painted shapes

class SlideButtonShape extends CustomPainter{

  @override
  void paint(Canvas canvas, Size size) {


    
    Paint fillPaint = Paint();
    fillPaint.color = const Color(0xFF830B2C);
    fillPaint.style = PaintingStyle.fill;

    Path path0 = Path();
    path0.moveTo(size.width*0.3750000,size.height*0.2000000);
    path0.cubicTo(size.width*0.7906500,size.height*0.1983400,size.width*0.7990625,size.height*0.2000000,size.width*0.9375000,size.height*0.1980000);
    path0.quadraticBezierTo(size.width*0.7800750,size.height*0.2997800,size.width*0.7625000,size.height*0.5000000);
    path0.lineTo(size.width*0.5500000,size.height*0.5000000);
    path0.quadraticBezierTo(size.width*0.5319500,size.height*0.3022000,size.width*0.3750000,size.height*0.2000000);
    path0.close();

    canvas.drawPath(path0, fillPaint);

    TextPainter textPainter = TextPainter(
        text: TextSpan(
          text: 'Slide',
          style: TextStyle(
            color: Colors.white,
            fontSize: 15,
          ),
        ),
        textDirection: TextDirection.ltr,
        textAlign: TextAlign.center
    );
    textPainter.layout();
    final offset = Offset(size.width / 2 +  32, size.height / 2 - 30);
    textPainter.paint(canvas,offset);

  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }

}

class TiltButtonShape extends CustomPainter{

  @override
  void paint(Canvas canvas, Size size) {

    Paint fillPaint = Paint();
    fillPaint.color = const Color(0xFF830B2C);
    fillPaint.style = PaintingStyle.fill;

    Path path0 = Path();
    path0.moveTo(size.width*0.2500000,size.height*0.6000000);
    path0.quadraticBezierTo(size.width*0.4066750,size.height*0.4998400,size.width*0.4250000,size.height*0.3000000);
    path0.lineTo(size.width*0.6375000,size.height*0.3000000);
    path0.quadraticBezierTo(size.width*0.6564000,size.height*0.4992800,size.width*0.8125000,size.height*0.6000000);
    path0.cubicTo(size.width*0.6718750,size.height*0.6000000,size.width*0.6718750,size.height*0.6000000,size.width*0.2500000,size.height*0.6000000);
    path0.close();

    canvas.drawPath(path0, fillPaint);

    TextPainter textPainter = TextPainter(
        text: TextSpan(
          text: 'Tilt',
          style: TextStyle(
            color: Colors.white,
            fontSize: 15,
          ),
        ),
        textDirection: TextDirection.ltr,
        textAlign: TextAlign.center
    );
    textPainter.layout();
    final offset = Offset(size.width / 2 - 3, size.height / 2 - 15);
    textPainter.paint(canvas,offset);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }

}


class PanButtonShape extends CustomPainter{

  @override
  void paint(Canvas canvas, Size size) {

    Paint fillPaint = Paint();
    fillPaint.color = const Color(0xFF830B2C);
    fillPaint.style = PaintingStyle.fill;

    Path path0 = Path();
    path0.moveTo(size.width*0.3750000,size.height*0.3000000);
    path0.cubicTo(size.width*0.5625000,size.height*0.3000000,size.width*0.5625000,size.height*0.3000000,size.width*0.6250000,size.height*0.3000000);
    path0.quadraticBezierTo(size.width*0.6124375,size.height*0.4002400,size.width*0.6250000,size.height*0.5000000);
    path0.lineTo(size.width*0.3750000,size.height*0.5000000);
    path0.quadraticBezierTo(size.width*0.3879500,size.height*0.4000000,size.width*0.3750000,size.height*0.3000000);
    path0.close();

    canvas.drawPath(path0, fillPaint);

    TextPainter textPainter = TextPainter(
        text: TextSpan(
          text: 'Pan',
          style: TextStyle(
            color: Colors.white,
            fontSize: 15,
          ),
        ),
        textDirection: TextDirection.ltr,
        textAlign: TextAlign.center
    );
    textPainter.layout();
    final offset = Offset(size.width / 2 - 12, size.height / 2 - 30);
    textPainter.paint(canvas,offset);

  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }

}

Solution

instead of CustomPainter you should extend ShapeBorder, something like PathBorder class below:

class MotionControl extends StatelessWidget {
  const MotionControl({Key? key, required this.shapes}) : super(key: key);

  final List<Shape> shapes;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: LayoutBuilder(
        builder: (context, constraints) {
          for (final shape in shapes) {
            shape.computeShapeBorder(const Size(36, 14), Offset.zero & constraints.biggest);
          }
          return Stack(
            children: [
              for (final shape in shapes)
                Positioned.fromRect(
                  rect: shape.bounds,
                  child: Material(
                    shape: shape.shapeBorder,
                    color: shape.color,
                    textStyle: const TextStyle(color: Colors.white70, fontWeight: FontWeight.bold),
                    elevation: 2,
                    clipBehavior: Clip.antiAlias,
                    child: InkWell(
                      highlightColor: Colors.orange,
                      splashColor: Colors.deepPurple,
                      onTap: shape.onTap ?? () {},
                      child: GestureDetector(
                        behavior: HitTestBehavior.translucent,
                        onPanUpdate: shape.onPanUpdate,
                        child: shape.child,
                      ),
                    ),
                  ),
                ),
            ],
          );
        }
      ),
    );
  }
}

class Shape {
  final String pathData;
  final Color color;
  final Widget child;
  final GestureTapCallback? onTap;
  final GestureDragUpdateCallback? onPanUpdate;
  late PathBorder shapeBorder;
  late Rect bounds;

  Shape({required this.pathData, required this.color, required this.child, this.onTap, this.onPanUpdate});

  void computeShapeBorder(Size inputSize, Rect rect) {
    final fs = applyBoxFit(BoxFit.contain, inputSize, rect.size);
    final r = Alignment.center.inscribe(fs.destination, rect);
    final matrix = Matrix4.translationValues(r.left, r.top, 0)
      ..scale(fs.destination.width / fs.source.width);
    // parseSvgPathData() needs: import 'package:path_drawing/path_drawing.dart';
    final path = parseSvgPathData(pathData).transform(matrix.storage);
    bounds = path.getBounds();
    shapeBorder = PathBorder(path.shift(-bounds.topLeft));
  }
}

class PathBorder extends ShapeBorder {
  final Path path;

  const PathBorder(this.path);

  @override
  EdgeInsetsGeometry get dimensions => EdgeInsets.zero;

  @override
  ui.Path getInnerPath(ui.Rect rect, {ui.TextDirection? textDirection}) => path;

  @override
  ui.Path getOuterPath(ui.Rect rect, {ui.TextDirection? textDirection}) {
    return path.shift(rect.topLeft);
  }

  @override
  void paint(ui.Canvas canvas, ui.Rect rect, {ui.TextDirection? textDirection}) {}

  @override
  ShapeBorder scale(double t) => this;
}

now you could setup a list of your Shapes like this:

middleSlide() => print('middle slide clicked');
middlePan() => print('middle pan clicked');
middleTilt() => print('middle tilt clicked');
innerLeft() => print('inner left clicked');
outerLeft(d) => print('outer left pan update $d');
innerRight() => print('inner right clicked');
outerRight(d) => print('outer right pan update $d');

final shapes = [
  Shape(
    pathData: 'm 11.646486,0.5 c 1.235074,0.880852 2.194241,2.094714 2.765625,3.5 h 7.177735 c 0.571917,-1.405532 1.531783,-2.61942 2.767578,-3.5 z',
    color: const Color(0xff71112d),
    child: const Center(child: Text('slide')),
    onTap: middleSlide,
  ),
  Shape(
    pathData: 'm 14.589846,4.5 a 8,8 0 0 1 0.410156,2.5 8,8 0 0 1 -0.40625,2.5 h 6.816406 A 8,8 0 0 1 21,7 8,8 0 0 1 21.406252,4.5 Z',
    color: const Color(0xff71112d),
    child: const Center(child: Text('pan')),
    onTap: middlePan,
  ),
  Shape(
    pathData: 'm 14.410158,10 c -0.571917,1.405532 -1.531783,2.61942 -2.767578,3.5 H 24.353518 C 23.118444,12.619148 22.159277,11.405286 21.587893,10 Z',
    color: const Color(0xff71112d),
    child: const Center(child: Text('tilt')),
    onTap: middleTilt,
  ),
  Shape(
    pathData: 'M 14,7 A 7,7 0 0 1 7,14 7,7 0 0 1 0,7 7,7 0 0 1 7,0 7,7 0 0 1 14,7 Z',
    color: const Color(0xff424242),
    child: Stack(
      children: const [
        Align(alignment: Alignment.centerLeft, child: Icon(Icons.arrow_left)),
        Align(alignment: Alignment.centerRight, child: Icon(Icons.arrow_right)),
      ],
    ),
    onPanUpdate: outerLeft,
  ),
  Shape(
    pathData: 'm 10,7 a 3,3 0 0 1 -3,3 3,3 0 0 1 -3,-3 3,3 0 0 1 3,-3 3,3 0 0 1 3,3 z',
    color: const Color(0xff71112d),
    child: const Center(child: Text('slide')),
    onTap: innerLeft,
  ),
  Shape(
    pathData: 'm 36,7 a 7,7 0 0 1 -7,7 7,7 0 0 1 -7,-7 7,7 0 0 1 7,-7 7,7 0 0 1 7,7 z',
    color: const Color(0xff424242),
    child: Stack(
      children: const [
        Align(alignment: Alignment.centerLeft, child: Icon(Icons.arrow_left)),
        Align(alignment: Alignment.topCenter, child: Icon(Icons.arrow_drop_up)),
        Align(alignment: Alignment.centerRight, child: Icon(Icons.arrow_right)),
        Align(alignment: Alignment.bottomCenter, child: Icon(Icons.arrow_drop_down)),
      ],
    ),
    onPanUpdate: outerRight,
  ),
  Shape(
    pathData: 'm 32,7 a 3,3 0 0 1 -3,3 3,3 0 0 1 -3,-3 3,3 0 0 1 3,-3 3,3 0 0 1 3,3 z',
    color: const Color(0xff71112d),
    child: const Center(child: Text('pan\ntilt', textAlign: TextAlign.center,)),
    onTap: innerRight,
  ),
];

and finally use MotionControl like:

MotionControl(shapes: shapes)

this is what you should get:

enter image description here

you may wonder what are those "magic" strings like m 32,7 a 3,3 0 0 1 -3,3 3,3 0 0 1 -3,-3 3,3 0 0 1 3,-3 3,3 0 0 1 3,3 z passed to Shape constructor?

this is your shape path definition taken directly from svg file (<path d=...?/>) – you can edit this file with free inkscape drawing tool

notice that you need to install path_drawing package to parse those strings into Path objects

the path definitions were taken from this svg file – you dont need to copy this anywhere to your project (however you can save it somewhere if you want to change the shapes)

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->

<svg
   xmlns:dc="http://purl.org/dc/elements/1.1/"
   xmlns:cc="http://creativecommons.org/ns#"
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
   xmlns:svg="http://www.w3.org/2000/svg"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
   width="36"
   height="14"
   viewBox="0 0 36 14"
   version="1.1"
   id="SVGRoot"
   inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
   sodipodi:docname="shapes.svg">
  <defs
     id="defs10" />
  <sodipodi:namedview
     id="base"
     pagecolor="#ffffff"
     bordercolor="#666666"
     borderopacity="1.0"
     inkscape:pageopacity="0.0"
     inkscape:pageshadow="2"
     inkscape:zoom="32.222222"
     inkscape:cx="17.952609"
     inkscape:cy="7"
     inkscape:document-units="px"
     inkscape:current-layer="SVGRoot"
     showgrid="true"
     inkscape:window-width="1366"
     inkscape:window-height="744"
     inkscape:window-x="0"
     inkscape:window-y="0"
     inkscape:window-maximized="1"
     inkscape:grid-bbox="true"
     objecttolerance="10000"
     guidetolerance="10000"
     fit-margin-top="0"
     fit-margin-left="0"
     fit-margin-right="0"
     fit-margin-bottom="0">
    <inkscape:grid
       type="xygrid"
       id="grid23"
       originx="-0.99999809"
       originy="-1" />
  </sodipodi:namedview>
  <metadata
     id="metadata13">
    <rdf:RDF>
      <cc:Work
         rdf:about="">
        <dc:format>image/svg+xml</dc:format>
        <dc:type
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
        <dc:title />
      </cc:Work>
    </rdf:RDF>
  </metadata>
  <path
     style="opacity:1;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.48323965;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
     d="m 14.589846,4.5 a 8,8 0 0 1 0.410156,2.5 8,8 0 0 1 -0.40625,2.5 h 6.816406 A 8,8 0 0 1 21,7 8,8 0 0 1 21.406252,4.5 Z"
     id="path26"
     inkscape:connector-curvature="0" />
  <path
     style="opacity:1;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.48323965;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
     d="m 14.410158,10 c -0.571917,1.405532 -1.531783,2.61942 -2.767578,3.5 H 24.353518 C 23.118444,12.619148 22.159277,11.405286 21.587893,10 Z"
     id="path28"
     inkscape:connector-curvature="0"
     sodipodi:nodetypes="ccccc" />
  <path
     style="opacity:1;fill:#800000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.93218362;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
     d="M 14,7 A 7,7 0 0 1 7,14 7,7 0 0 1 0,7 7,7 0 0 1 7,0 7,7 0 0 1 14,7 Z"
     id="circle30" />
  <path
     style="opacity:1;fill:#800000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.93218362;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
     d="m 36,7 a 7,7 0 0 1 -7,7 7,7 0 0 1 -7,-7 7,7 0 0 1 7,-7 7,7 0 0 1 7,7 z"
     id="circle32" />
  <path
     style="opacity:1;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.48323965;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
     d="m 11.646486,0.5 c 1.235074,0.880852 2.194241,2.094714 2.765625,3.5 h 7.177735 c 0.571917,-1.405532 1.531783,-2.61942 2.767578,-3.5 z"
     id="path34"
     inkscape:connector-curvature="0"
     sodipodi:nodetypes="ccccc" />
  <path
     style="opacity:1;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.71428573;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
     d="m 10,7 a 3,3 0 0 1 -3,3 3,3 0 0 1 -3,-3 3,3 0 0 1 3,-3 3,3 0 0 1 3,3 z"
     id="circle36" />
  <path
     style="opacity:1;fill:#008000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.71428573;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
     d="m 32,7 a 3,3 0 0 1 -3,3 3,3 0 0 1 -3,-3 3,3 0 0 1 3,-3 3,3 0 0 1 3,3 z"
     id="circle38" />
</svg>

Answered By – pskink

Answer Checked By – Terry (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *