28

I'm trying to make a GestureDetector work inside a Stack with a Container on top of it but the onTap callback is never called.

As you can see, it doesn't work even with HitTestBehavior.translucent

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: <Widget>[
            GestureDetector(
              behavior: HitTestBehavior.translucent,
              onTap: () {
                print('tap');
              },
              child: Container(color: Colors.blue),
            ),
            Container(color: Colors.white),
          ],
        ),
      ),
    );
  }

I know that it can be strange that I would want to capture tap event below another Widget but in my real case, the widget on top is transparent and sometimes has a gradient.

Vardiak
  • 801
  • 1
  • 7
  • 13

5 Answers5

31

Ok guys, I think I found a solution myself. I hope that there exist a simpler solution but it works for my use. The problem I had was that the Stack widget doesn't pass the hit test to all children but only the first one that is hit. What I did is that I rewrote the hit detection algorithm used by the Stack's RenderBox. I really didn't intended to go this far and I'm still waiting for a better answer. Here is my code, use it at your own risk :

class CustomStack extends Stack {
  CustomStack({children}) : super(children: children);

  @override
  CustomRenderStack createRenderObject(BuildContext context) {
    return CustomRenderStack(
      alignment: alignment,
      textDirection: textDirection ?? Directionality.of(context),
      fit: fit,
      overflow: overflow,
    );
  }
}

class CustomRenderStack extends RenderStack {
  CustomRenderStack({alignment, textDirection, fit, overflow})
      : super(
            alignment: alignment,
            textDirection: textDirection,
            fit: fit,
            overflow: overflow);

  @override
  bool hitTestChildren(BoxHitTestResult result, {Offset position}) {
    var stackHit = false;

    final children = getChildrenAsList();

    for (var child in children) {
      final StackParentData childParentData = child.parentData;

      final childHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset transformed) {
          assert(transformed == position - childParentData.offset);
          return child.hitTest(result, position: transformed);
        },
      );

      if (childHit) stackHit = true;
    }

    return stackHit;
  }
}

Vardiak
  • 801
  • 1
  • 7
  • 13
  • You're a genius! Thanks man. I wanted to create a transparent AppBar above a fullscreen container with a Stack, but since the AppBar was also rendered fullscreen, it captured the click and the underlying elements did not respond. Your CustomStack made it work! – Florian Gl Feb 12 '20 at 21:10
  • Your welcome! It's really a shame that we have to go through this to make it work, I really think that the hit detection method used by Flutter should be reworked, it's so much simpler to do those things in CSS... – Vardiak Feb 13 '20 at 22:27
  • Same use case here, and without this solution I can't even guess how many hours I would have dumped into this. I'd upvote ten times if I could. Thanks for this. – Jon Halliday Apr 12 '20 at 11:45
  • That works perfectly! Thank you! What a mission to have a button under a listview still clickable. – Dustin Silk Jun 14 '20 at 07:59
  • Thanks! Do you think allowing GestureDetector not absorbing hit tests will also make sense? I.e. `onTap() { print('tap'); return true; } // continue hitTests` – Andrey Chaschev Jul 07 '20 at 04:07
  • Brilliant! Thank you. – mertcanb Nov 21 '20 at 06:38
  • For whoever wants to copy-and-paste the code: At least need to add `updateRenderObject`, otherwise this code is incomplete (btw good job!) – ch271828n Aug 24 '22 at 03:59
  • 1
    2022 UPDATE: Please take a look at @ch271828n answer – Paul Oct 25 '22 at 03:22
26

This can be solved with IgnorePointer.

You could wrap your top widget (in this case the white Container) with an IgnorePointer.

Stack(
  children:[
    (...your down widget which should handle the tap...)

    IgnorePointer(
      child: (... your top widget which must be transparent to any tap ...)
    )
  ]
)

Then your top widget becomes transparent to gesture events. Events will be captured by the top most dedector on the Stack, if any.

For example you might make little change to your code to achieve your desired result , like this :

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: <Widget>[
            GestureDetector(
              behavior: HitTestBehavior.translucent,
              onTap: () {
                print('tap');
              },
              child: Container(color: Colors.blue),
            ),
            IgnorePointer(ignoring:true,child:Container(color: Colors.white)),
          ],
        ),
      ),
    );
  }
easeccy
  • 4,248
  • 1
  • 21
  • 37
Hossein Hadi
  • 1,229
  • 15
  • 20
7

October 2021 update for flutter > 2.5

Inspired from @Vardiak's answer, with null safety and framework changes

Replace your Stack by this CustomStack widget:

class CustomStack extends Stack {
  CustomStack({children}) : super(children: children);

  @override
  CustomRenderStack createRenderObject(BuildContext context) {
    return CustomRenderStack(
      alignment: alignment,
      textDirection: textDirection ?? Directionality.of(context),
      fit: fit,
    );
  }
}

class CustomRenderStack extends RenderStack {
  CustomRenderStack({alignment, textDirection, fit, overflow})
      : super(alignment: alignment, textDirection: textDirection, fit: fit);

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    var stackHit = false;

    final children = getChildrenAsList();

    for (var child in children) {
      final StackParentData childParentData =
          child.parentData as StackParentData;

      final childHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset transformed) {
          assert(transformed == position - childParentData.offset);
          return child.hitTest(result, position: transformed);
        },
      );

      if (childHit) stackHit = true;
    }

    return stackHit;
  }
}
Hugo H
  • 6,029
  • 5
  • 37
  • 57
  • My CustomStack still works but I really feel like you can do the same thing with IgnorePointer. I personally refactored my code to use it. – Vardiak Oct 06 '21 at 20:19
  • For whoever wants to copy-and-paste the code: At least need to add `updateRenderObject`, otherwise this code is incomplete – ch271828n Aug 24 '22 at 03:59
  • @ch271828n Flutter API might have changed since my answer in 2021, don't mind updating my sample code with 2022 working code ;) – Hugo H Aug 25 '22 at 11:54
  • Awesome! Thanks a lot, man. If it could always be that simple just to copy and paste... – Paul Aug 29 '22 at 04:42
5

Inspired by @Vardiak and @HugoH, with bug fixed (i.e. updateRenderObject added - otherwise the code is buggy)

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class MultiHitStack extends Stack {
  MultiHitStack({
    super.key,
    super.alignment = AlignmentDirectional.topStart,
    super.textDirection,
    super.fit = StackFit.loose,
    super.clipBehavior = Clip.hardEdge,
    super.children = const <Widget>[],
  });

  @override
  RenderMultiHitStack createRenderObject(BuildContext context) {
    return RenderMultiHitStack(
      alignment: alignment,
      textDirection: textDirection ?? Directionality.maybeOf(context),
      fit: fit,
      clipBehavior: clipBehavior,
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderMultiHitStack renderObject) {
    renderObject
      ..alignment = alignment
      ..textDirection = textDirection ?? Directionality.maybeOf(context)
      ..fit = fit
      ..clipBehavior = clipBehavior;
  }
}

class RenderMultiHitStack extends RenderStack {
  RenderMultiHitStack({
    super.children,
    super.alignment = AlignmentDirectional.topStart,
    super.textDirection,
    super.fit = StackFit.loose,
    super.clipBehavior = Clip.hardEdge,
  });

  // NOTE MODIFIED FROM [RenderStack.hitTestChildren], i.e. [defaultHitTestChildren]
  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    // NOTE MODIFIED
    var childHit = false;

    RenderBox? child = lastChild;
    while (child != null) {
      // The x, y parameters have the top left of the node's box as the origin.
      final StackParentData childParentData = child.parentData! as StackParentData;
      final bool isHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset transformed) {
          assert(transformed == position - childParentData.offset);
          return child!.hitTest(result, position: transformed);
        },
      );

      // NOTE MODIFIED
      // if (isHit) return true;
      childHit |= isHit;

      child = childParentData.previousSibling;
    }

    // NOTE MODIFIED
    return childHit;
    // return false;
  }
}
ch271828n
  • 15,854
  • 5
  • 53
  • 88
  • You are a gem! Awesome thanks! Had some scrolling problems before, as the stack would think I am hitting the child instead of scrolling. – Paul Oct 25 '22 at 03:20
0

You made a container (white color) covering by complete the blue color container, so when you touch it, you're giving it up on it (white container) if you want to manage the touch on it (white container) Put a gesture detector on it too.

an easy way to test the problem with which you are reversing the position of the white container with the blue one, and you will see the "tap" happens

this works perfectly well:

Scaffold(
  body: Container(
    child: Stack(
      children: <Widget>[
         GestureDetector(
          behavior: HitTestBehavior.translucent,
          onTap: () {
            print('tap on white ');
          },
          child:
        Container(color: Colors.white)),

        GestureDetector(
          behavior: HitTestBehavior.translucent,
          onTap: () {
            print('tap on blue');
          },
          child: 
          SizedBox(
            height: 500,
            child: Container(color: Colors.blue),
          )
        ),
      ],
    ),
  ),
);
rcorbellini
  • 1,307
  • 1
  • 21
  • 42
  • As I said, this is just a simplification of a complex problem. I can't put the gesture detector on the upper layer because there is a lot of complex gestures there. What I want is detecting the tap on the second layer. – Vardiak Aug 12 '19 at 21:53