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(),
));
}
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.
Can someone explain why it does not work on ListView ?
Is there any way I can force this to work on ListView
and GestureDetector
?
I need to use ListView
imperatively because I need to implement ReorderableListView.builder
afterward.
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.