Marcelo Glasberg
Marcelo Glasberg

Reputation: 30889

In Flutter, how can a positioned Widget feel taps outside of its parent Stack area?

A Stack contains MyWidget inside of a Positioned.

Stack(
  overflow: Overflow.visible,
  children: [
    Positioned(
    top: 0.0,
    left: 0.0,
    child: MyWidget(),
  )],
);

Since overflow is Overflow.visible and MyWidget is larger than the Stack, it displays outside of the Stack, which is what I want.

However, I can't tap in the area of MyWidget which is outside of the Stack area. It simply ignores the tap there.

How can I make sure MyWidget accepts gestures there?

Upvotes: 41

Views: 25573

Answers (8)

Ryan Sneyd
Ryan Sneyd

Reputation: 189

SizedBox(
    height: double.infinity,
    child: Stack(
         clipBehavior: Clip.none,
         children: [
              Widget1(),
              Positioned(
                   top: 85,
                   left: 16,
                   right: 16,
                        child: Widget2()
              )
         ],
    ),
),

I solved this by wrapping the Stack in a fixed height with a sized box. The problem is that the render box is too small for the second element so when you try to make the second widget overhang it cannot detect it. So by creating a fixed sized you constrain the size of the lowest z index widget allowing the higher z index widget to fit.

Upvotes: 0

无夜之星辰
无夜之星辰

Reputation: 6158

Sometimes you can use Column to build a special Stack:

result

key point:

  1. verticalDirection is up.
  2. transform down the top widget.

Below is my code, you can copy and test:

Column(
  verticalDirection: VerticalDirection.up,
  children: [
    Container(
      width: 200,
      height: 100,
      color: Colors.red,
    ),
    Transform.translate(
      offset: const Offset(0, 30),
      child: GestureDetector(
        onTap: () {
          print('tap orange view');
        },
        child: Container(
          width: 60,
          height: 60,
          color: Colors.orange,
        ),
      ),
    ),
  ],
),

Upvotes: 7

Lanistor
Lanistor

Reputation: 303

I write a container to resolve this problem, which not implements beautifully, but can be used and did code encapsulation for easily to use.

Here is the implement:

import 'package:flutter/widgets.dart';

/// Creates a widget that can check its' overflow children's hitTest
///
/// [overflowKeys] is must, and there should be used on overflow widget's outermost widget those' sizes cover the overflow child, because it will [hitTest] its' children, but not [hitTest] its' parents. And i cannot found a way to check RenderBox's parent in flutter.
///
/// The [OverflowWithHitTest]'s size must contains the overflow widgets, so you can use it as outer as possible.
///
/// This will not reduce rendering performance, because it only overcheck the given widgets marked by [overflowKeys].
///
/// Demo:
///
/// class MyPage extends State<UserCenterPage> {
///
///   var overflowKeys = <GlobalKey>[GlobalKey()];
///
///   Widget build(BuildContext context) {
///     return Scaffold(
///       body: OverflowWithHitTest(
///
///         overflowKeys: overflowKeys,
///
///         child: Container(
///           height: 50,
///           child: UnconstrainedBox(
///             child: Container(
///               width: 200,
///               height: 50,
///               color: Colors.red,
///               child: OverflowBox(
///                 alignment: Alignment.topLeft,
///                 minWidth: 100,
///                 maxWidth: 200,
///                 minHeight: 100,
///                 maxHeight: 200,
///                 child: GestureDetector(
///                   key: overflowKeys[0],
///                   behavior: HitTestBehavior.translucent,
///                   onTap: () {
///                     print('==== onTap;');
///                   },
///                   child: Container(
///                     color: Colors.blue,
///                     height: 200,
///                     child: Text('aaaa'),
///                   ),
///                 ),
///               ),
///             ),
///           ),
///         ),
///       ),
///     );
///   }
/// }
///
///
class OverflowWithHitTest extends SingleChildRenderObjectWidget {
  const OverflowWithHitTest({
    required this.overflowKeys,
    Widget? child,
    Key? key,
  }) : super(key: key, child: child);

  final List<GlobalKey> overflowKeys;

  @override
  _OverflowWithHitTestBox createRenderObject(BuildContext context) {
    return _OverflowWithHitTestBox(overflowKeys: overflowKeys);
  }

  @override
  void updateRenderObject(
      BuildContext context, _OverflowWithHitTestBox renderObject) {
    renderObject.overflowKeys = overflowKeys;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(
        DiagnosticsProperty<List<GlobalKey>>('overflowKeys', overflowKeys));
  }
}

class _OverflowWithHitTestBox extends RenderProxyBoxWithHitTestBehavior {
  _OverflowWithHitTestBox({required List<GlobalKey> overflowKeys})
      : _overflowKeys = overflowKeys,
        super(behavior: HitTestBehavior.translucent);

  /// Global keys of overflow children
  List<GlobalKey> get overflowKeys => _overflowKeys;
  List<GlobalKey> _overflowKeys;

  set overflowKeys(List<GlobalKey> value) {
    var changed = false;

    if (value.length != _overflowKeys.length) {
      changed = true;
    } else {
      for (var ind = 0; ind < value.length; ind++) {
        if (value[ind] != _overflowKeys[ind]) {
          changed = true;
        }
      }
    }
    if (!changed) {
      return;
    }
    _overflowKeys = value;
    markNeedsPaint();
  }

  @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    if (hitTestOverflowChildren(result, position: position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget =
          hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

  bool hitTestOverflowChildren(BoxHitTestResult result,
      {required Offset position}) {
    if (overflowKeys.length == 0) {
      return false;
    }
    var hitGlobalPosition = this.localToGlobal(position);
    for (var child in overflowKeys) {
      if (child.currentContext == null) {
        continue;
      }
      var renderObj = child.currentContext!.findRenderObject();
      if (renderObj == null || renderObj is! RenderBox) {
        continue;
      }

      var localPosition = renderObj.globalToLocal(hitGlobalPosition);
      if (renderObj.hitTest(result, position: localPosition)) {
        return true;
      }
    }
    return false;
  }
}

Upvotes: -1

Benno
Benno

Reputation: 318

This limitation can be worked around by using an OverlayEntry widget as the Stack's parent (since OverlayEntry fills up the entire screen all children are also hit tested). Here is a proof of concept solution on DartPad.

Create a custom widget that returns a Future:

    Widget build(BuildContext context) {
    Future(showOverlay);
    return Container();
  }

This future should then remove any previous instance of OverlayEntry and insert the Stack with your custom widgets:

  void showOverlay() {
    hideOverlay();
    RenderBox? renderBox = context.findAncestorRenderObjectOfType<RenderBox>();

    var parentSize = renderBox!.size;
    var parentPosition = renderBox.localToGlobal(Offset.zero);

    overlay = _overlayEntryBuilder(parentPosition, parentSize);
    Overlay.of(context)!.insert(overlay!);
  }

  void hideOverlay() {
    overlay?.remove();
  }

Use a builder function to generate the Stack:

 OverlayEntry _overlayEntryBuilder(Offset parentPosition, Size parentSize) {  
    return OverlayEntry(
      maintainState: false,
      builder: (context) {
        return Stack(
          clipBehavior: Clip.none,
          children: [
            Positioned(
              left: parentPosition.dx + parentSize.width,
              top: parentPosition.dy + parentSize.height,
              child: Material(
                color: Colors.transparent,
                child: InkWell(
                  onTap: () {},
                  child: Container(),
                ),
              ),
            ),
          ],
        );
      },
    );
  }

Upvotes: 5

zengxiangxi
zengxiangxi

Reputation: 89

You can consider using inheritance to copy the hitTest method to break the hit rule, example

class Stack2 extends Stack {
  Stack2({
    Key key,
    AlignmentGeometry alignment = AlignmentDirectional.topStart,
    TextDirection textDirection,
    StackFit fit = StackFit.loose,
    Overflow overflow = Overflow.clip,
    List<Widget> children = const <Widget>[],
  }) : super(
          key: key,
          alignment: alignment,
          textDirection: textDirection,
          fit: fit,
          overflow: overflow,
          children: children,
        );

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

class RenderStack2 extends RenderStack {
  RenderStack2({
    List<RenderBox> children,
    AlignmentGeometry alignment = AlignmentDirectional.topStart,
    TextDirection textDirection,
    StackFit fit = StackFit.loose,
    Overflow overflow = Overflow.clip,
  }) : super(
          children: children,
          alignment: alignment,
          textDirection: textDirection,
          fit: fit,
          overflow: overflow,
        );

  @override
  bool hitTest(BoxHitTestResult result, {Offset position}) {
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
    return false;
  }
}

Upvotes: 8

George Chailazopoulos
George Chailazopoulos

Reputation: 470

I had a similar issue. Basically since the stack's children don't use the fully overflown box size for their hit testing, i used a nested stack and an arbitrary big height so that i can capture the clicks of the nested stack's overflown boxes. Not sure if it can work for you but here goes nothing :)

So in your example maybe you could try something like that

Stack(
  clipBehavior: Clip.none,
  children: [
    Positioned(
    top: 0.0,
    left: 0.0,
    height : 500.0 // biggest possible child size or just very big 
    child: Stack(
      children: [MyWidget()]
    ),
  )],
);

Upvotes: 14

Norbert
Norbert

Reputation: 1027

This behavior occurs because the stack checks whether the pointer is inside its bounds before checking whether a child got hit:

Class: RenderBox (which RenderStack extends)

bool hitTest(BoxHitTestResult result, { @required Offset position }) {

    ...

    if (_size.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
}

My workaround is deleting the

if (_size.contains(position))

check. Unfortunately, this is not possible without copying code from the framework.

Here is what I did:

  • Copied the Stack class and named it Stack2
  • Copied RenderStack and named it RenderStack2
  • Made Stack2 reference RenderStack2
  • Added the hitTest method from above without the _size.contains check
  • Copied Positioned and named it Positioned2 and made it reference Stack2 as its generic parameter
  • Used Stack2 and Positioned2 in my code

This solution is by no means optimal, but it achieves the desired behavior.

Upvotes: 18

diegoveloper
diegoveloper

Reputation: 103451

Ok, I did a workaround about this, basically I added a GestureDetector on the parent and implemented the onTapDown. Also you have to keep track your Widget using GlobalKey to get the current position.

When the Tap at the parent level is detected check if the tap position is inside your widget.

The code below:

final GlobalKey key = new GlobalKey();

      void onTapDown(BuildContext context, TapDownDetails details) {
        final RenderBox box = context.findRenderObject();
        final Offset localOffset = box.globalToLocal(details.globalPosition);
        final RenderBox containerBox = key.currentContext.findRenderObject();
        final Offset containerOffset = containerBox.localToGlobal(localOffset);
        final onTap = containerBox.paintBounds.contains(containerOffset);
        if (onTap){
          print("DO YOUR STUFF...");
        }
      }

      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTapDown: (TapDownDetails details) => onTapDown(context, details),
          child: Container(
            color: Colors.red,
            width: MediaQuery.of(context).size.width,
            height: MediaQuery.of(context).size.height,
            child: Align(
              alignment: Alignment.topLeft,
                      child: SizedBox(
                width: 200.0,
                height: 400.0,
                child: Container(
                  color: Colors.black,
                    child: Stack(
                      overflow: Overflow.visible,
                      children: [
                        Positioned(
                          top: 0.0, left: 0.0,
                                          child: Container(
                            key: key,
                            width: 500.0,
                            height: 200.0,
                            color: Colors.blue,
                          ),
                        ),
                      ],
                    ),

                ),
              ),
            ),
          ),
        );
      } 

Upvotes: 7

Related Questions