flutterdartuser-interface

Tap Widget outside Stack in Flutter


There is currently a known Flutter limitation that makes widgets overflowing beyond Stack to not receive gesture events.

However, there is a hack that seems to work by extending the Stack class.

Here is my updated implementation of the hack.

class StackTappableOutside extends Stack {
  const StackTappableOutside({
    super.key,
    super.alignment,
    super.textDirection,
    super.fit,
    super.clipBehavior,
    super.children,
  });

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

class RenderStackTappableOutside extends RenderStack {
  RenderStackTappableOutside({
    super.alignment,
    super.textDirection,
    super.fit,
    super.clipBehavior,
    super.children,
  });

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

I tested it and it works well in Column and Row

(Reproducible, just copy and paste)

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

class StackTappableOutside extends Stack {
  const StackTappableOutside({
    super.key,
    super.alignment,
    super.textDirection,
    super.fit,
    super.clipBehavior,
    super.children,
  });

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

class RenderStackTappableOutside extends RenderStack {
  RenderStackTappableOutside({
    super.alignment,
    super.textDirection,
    super.fit,
    super.clipBehavior,
    super.children,
  });

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

class TapOutsideStackDemo extends StatefulWidget {
  const TapOutsideStackDemo({super.key});

  @override
  State<TapOutsideStackDemo> createState() => _TapOutsideStackDemoState();
}

class _TapOutsideStackDemoState extends State<TapOutsideStackDemo> {
  List<int> items = [0, 1, 2, 3, 4];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Tap Outside Stack Demo'),
      ),
      body: _body(),
    );
  }

  Widget _itemBuilder(int index, int element) {
    final showAddButton = index > 0;
    return StackTappableOutside(
      clipBehavior: Clip.none,
      children: [
        Container(
          child: ListTile(
            title: Text('Todo List Item $element'),
            subtitle: Text('Add a new item after'),
          ),
          decoration: BoxDecoration(
            color: Colors.deepOrange,
            border: Border.all(color: Colors.yellow),
          ),
        ),
        if (showAddButton)
          Positioned(
            top: -24,
            right: 8,
            child: Container(
              clipBehavior: Clip.antiAlias,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: Colors.green,
              ),
              child: IconButton(
                icon: Icon(Icons.add),
                onPressed: () {
                  print('add after');
                },
              ),
            ),
          ),
      ],
    );
  }

  Widget _body() {
    return Column(
      children: items.mapIndexed(_itemBuilder).toList(),
    );
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        final element = items[index];
        return _itemBuilder(index, element);
      },
    );
  }
}

void main() {
  runApp(MaterialApp(
    home: TapOutsideStackDemo(),
  ));
}

enter image description here

With the above code, when you tap the green button from anywhere, it prints:

add after
add after
add after

However, I have a requirement to make it a ListView instead of Column. So I changed from

  Widget _body() {
    return Column(
      children: items.mapIndexed(_itemBuilder).toList(),
    );
  }

to:

  Widget _body() {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        final element = items[index];
        return _itemBuilder(index, element);
      },
    );
  }

Suddenly, only half of the green button works. Only the bottom part works.

I also noticed that if we wrap the List Item with GestureDetector, the hack stops working too, even with the simple Column that worked earlier.


Solution

  • It seems pretty weird that there seems to be no easy out of the box way to do this. But I found a nice pub package which seems to do the job for me: https://pub.dev/packages/defer_pointer

    For your example it worked by wrapping the whole ListView.builder inside the DeferredPointerHandler and wrapping the IconButton inside DeferPointer.

    Here is your modified code sample:

    class _TapOutsideStackDemoState extends State<TapOutsideStackDemo> {
      List<int> items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Tap Outside Stack Demo'),
          ),
          body: _body(),
        );
      }
    
      Widget _itemBuilder(int index, int element) {
        final showAddButton = index > 0;
        return Stack(
          clipBehavior: Clip.none,
          children: [
            Container(
              child: ListTile(
                title: Text('Todo List Item $element'),
                subtitle: Text('Add a new item after'),
              ),
              decoration: BoxDecoration(
                color: Colors.deepOrange,
                border: Border.all(color: Colors.yellow),
              ),
            ),
            if (showAddButton)
              Positioned(
                top: -24,
                right: 8,
                child: Container(
                  clipBehavior: Clip.antiAlias,
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                    color: Colors.green,
                  ),
                  child: DeferPointer(
                    child: IconButton(
                      icon: Icon(Icons.add),
                      onPressed: () {
                        print('add before $element');
                      },
                    ),
                  ),
                ),
              ),
          ],
        );
      }
    
      Widget _body() {
        // return Column(
        //   children: items.mapIndexed(_itemBuilder).toList(),
        // );
        return DeferredPointerHandler(
          child: ListView.builder(
            itemCount: items.length,
            hitTestBehavior: HitTestBehavior.translucent,
            itemBuilder: (context, index) {
              final element = items[index];
              return _itemBuilder(index, element);
            },
          ),
        );
      }
    }
    
    

    Also note that this no longer requires the StackTappableOutside workaround.