Skip to content

Commit 6d67fbb

Browse files
authored
Add HitTestBehavior to TapRegion (#113634)
1 parent 94d3a80 commit 6d67fbb

File tree

4 files changed

+131
-12
lines changed

4 files changed

+131
-12
lines changed

packages/flutter/lib/src/rendering/proxy_box.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,12 @@ abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {
173173
RenderBox? child,
174174
}) : super(child);
175175

176-
/// How to behave during hit testing.
176+
/// How to behave during hit testing when deciding how the hit test propagates
177+
/// to children and whether to consider targets behind this one.
178+
///
179+
/// Defaults to [HitTestBehavior.deferToChild].
180+
///
181+
/// See [HitTestBehavior] for the allowed values and their meanings.
177182
HitTestBehavior behavior;
178183

179184
@override

packages/flutter/lib/src/widgets/gesture_detector.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -970,10 +970,14 @@ class GestureDetector extends StatelessWidget {
970970
/// detecting screens.
971971
final GestureForcePressEndCallback? onForcePressEnd;
972972

973-
/// How this gesture detector should behave during hit testing.
973+
/// How this gesture detector should behave during hit testing when deciding
974+
/// how the hit test propagates to children and whether to consider targets
975+
/// behind this one.
974976
///
975977
/// This defaults to [HitTestBehavior.deferToChild] if [child] is not null and
976978
/// [HitTestBehavior.translucent] if child is null.
979+
///
980+
/// See [HitTestBehavior] for the allowed values and their meanings.
977981
final HitTestBehavior? behavior;
978982

979983
/// Whether to exclude these gestures from the semantics tree. For

packages/flutter/lib/src/widgets/tap_region.dart

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ class TapRegion extends SingleChildRenderObjectWidget {
312312
super.key,
313313
required super.child,
314314
this.enabled = true,
315+
this.behavior = HitTestBehavior.deferToChild,
315316
this.onTapOutside,
316317
this.onTapInside,
317318
this.groupId,
@@ -321,6 +322,14 @@ class TapRegion extends SingleChildRenderObjectWidget {
321322
/// Whether or not this [TapRegion] is enabled as part of the composite region.
322323
final bool enabled;
323324

325+
/// How to behave during hit testing when deciding how the hit test propagates
326+
/// to children and whether to consider targets behind this [TapRegion].
327+
///
328+
/// Defaults to [HitTestBehavior.deferToChild].
329+
///
330+
/// See [HitTestBehavior] for the allowed values and their meanings.
331+
final HitTestBehavior behavior;
332+
324333
/// A callback to be invoked when a tap is detected outside of this
325334
/// [TapRegion] and any other region with the same [groupId], if any.
326335
///
@@ -358,6 +367,7 @@ class TapRegion extends SingleChildRenderObjectWidget {
358367
return RenderTapRegion(
359368
registry: TapRegionRegistry.maybeOf(context),
360369
enabled: enabled,
370+
behavior: behavior,
361371
onTapOutside: onTapOutside,
362372
onTapInside: onTapInside,
363373
groupId: groupId,
@@ -367,22 +377,25 @@ class TapRegion extends SingleChildRenderObjectWidget {
367377

368378
@override
369379
void updateRenderObject(BuildContext context, covariant RenderTapRegion renderObject) {
370-
renderObject.registry = TapRegionRegistry.maybeOf(context);
371-
renderObject.enabled = enabled;
372-
renderObject.groupId = groupId;
373-
renderObject.onTapOutside = onTapOutside;
374-
renderObject.onTapInside = onTapInside;
375-
if (kReleaseMode) {
380+
renderObject
381+
..registry = TapRegionRegistry.maybeOf(context)
382+
..enabled = enabled
383+
..behavior = behavior
384+
..groupId = groupId
385+
..onTapOutside = onTapOutside
386+
..onTapInside = onTapInside;
387+
if (!kReleaseMode) {
376388
renderObject.debugLabel = debugLabel;
377389
}
378390
}
379391

380392
@override
381393
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
382394
super.debugFillProperties(properties);
395+
properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true));
396+
properties.add(DiagnosticsProperty<HitTestBehavior>('behavior', behavior, defaultValue: HitTestBehavior.deferToChild));
383397
properties.add(DiagnosticsProperty<Object?>('debugLabel', debugLabel, defaultValue: null));
384398
properties.add(DiagnosticsProperty<Object?>('groupId', groupId, defaultValue: null));
385-
properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true));
386399
}
387400
}
388401

@@ -393,26 +406,33 @@ class TapRegion extends SingleChildRenderObjectWidget {
393406
/// system.
394407
///
395408
/// This render object indicates to the nearest ancestor [TapRegionSurface] that
396-
/// the region occupied by its child will participate in the tap detection for
397-
/// that surface.
409+
/// the region occupied by its child (or itself if [behavior] is
410+
/// [HitTestBehavior.opaque]) will participate in the tap detection for that
411+
/// surface.
398412
///
399413
/// If this region belongs to a group (by virtue of its [groupId]), all the
400414
/// regions in the group will act as one.
401415
///
402416
/// If there is no [RenderTapRegionSurface] ancestor in the render tree,
403417
/// [RenderTapRegion] will do nothing.
404418
///
419+
/// The [behavior] attribute describes how to behave during hit testing when
420+
/// deciding how the hit test propagates to children and whether to consider
421+
/// targets behind the tap region. Defaults to [HitTestBehavior.deferToChild].
422+
/// See [HitTestBehavior] for the allowed values and their meanings.
423+
///
405424
/// See also:
406425
///
407426
/// * [TapRegion], a widget that inserts a [RenderTapRegion] into the render
408427
/// tree.
409-
class RenderTapRegion extends RenderProxyBox {
428+
class RenderTapRegion extends RenderProxyBoxWithHitTestBehavior {
410429
/// Creates a [RenderTapRegion].
411430
RenderTapRegion({
412431
TapRegionRegistry? registry,
413432
bool enabled = true,
414433
this.onTapOutside,
415434
this.onTapInside,
435+
super.behavior = HitTestBehavior.deferToChild,
416436
Object? groupId,
417437
String? debugLabel,
418438
}) : _registry = registry,

packages/flutter/test/widgets/tap_region_test.dart

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:ui';
66

7+
import 'package:flutter/gestures.dart';
78
import 'package:flutter/material.dart';
89
import 'package:flutter_test/flutter_test.dart';
910

@@ -99,6 +100,7 @@ void main() {
99100
await click(find.text('Outside Surface'));
100101
expect(tappedOutside, isEmpty);
101102
});
103+
102104
testWidgets('TapRegionSurface detects inside taps', (WidgetTester tester) async {
103105
final Set<String> tappedInside = <String>{};
104106
await tester.pumpWidget(
@@ -185,6 +187,94 @@ void main() {
185187
await click(find.text('Outside Surface'));
186188
expect(tappedInside, isEmpty);
187189
});
190+
191+
testWidgets('TapRegionSurface detects inside taps correctly with behavior', (WidgetTester tester) async {
192+
final Set<String> tappedInside = <String>{};
193+
const ValueKey<String> noGroupKey = ValueKey<String>('No Group');
194+
const ValueKey<String> group1AKey = ValueKey<String>('Group 1 A');
195+
const ValueKey<String> group1BKey = ValueKey<String>('Group 1 B');
196+
await tester.pumpWidget(
197+
Directionality(
198+
textDirection: TextDirection.ltr,
199+
child: Column(
200+
children: <Widget>[
201+
const Text('Outside Surface'),
202+
TapRegionSurface(
203+
child: Row(
204+
children: <Widget>[
205+
ConstrainedBox(
206+
constraints: const BoxConstraints.tightFor(width: 100, height: 100),
207+
child: TapRegion(
208+
// ignore: avoid_redundant_argument_values
209+
behavior: HitTestBehavior.deferToChild,
210+
onTapInside: (PointerEvent event) {
211+
tappedInside.add(noGroupKey.value);
212+
},
213+
child: Stack(key: noGroupKey),
214+
),
215+
),
216+
ConstrainedBox(
217+
constraints: const BoxConstraints.tightFor(width: 100, height: 100),
218+
child: TapRegion(
219+
groupId: 1,
220+
behavior: HitTestBehavior.opaque,
221+
onTapInside: (PointerEvent event) {
222+
tappedInside.add(group1AKey.value);
223+
},
224+
child: Stack(key: group1AKey),
225+
),
226+
),
227+
ConstrainedBox(
228+
constraints: const BoxConstraints.tightFor(width: 100, height: 100),
229+
child: TapRegion(
230+
groupId: 1,
231+
behavior: HitTestBehavior.translucent,
232+
onTapInside: (PointerEvent event) {
233+
tappedInside.add(group1BKey.value);
234+
},
235+
child: Stack(key: group1BKey),
236+
),
237+
),
238+
],
239+
),
240+
),
241+
],
242+
),
243+
),
244+
);
245+
246+
await tester.pump();
247+
248+
Future<void> click(Finder finder) async {
249+
final TestGesture gesture = await tester.startGesture(
250+
tester.getCenter(finder),
251+
kind: PointerDeviceKind.mouse,
252+
);
253+
await gesture.up();
254+
await gesture.removePointer();
255+
}
256+
257+
expect(tappedInside, isEmpty);
258+
259+
await click(find.byKey(noGroupKey));
260+
expect(tappedInside, isEmpty); // No hittable children, so no hit.
261+
262+
await click(find.byKey(group1AKey));
263+
// No hittable children, but set to opaque, so it hits, triggering the
264+
// group.
265+
expect(tappedInside,
266+
equals(<String>{
267+
'Group 1 A',
268+
'Group 1 B',
269+
}),
270+
);
271+
tappedInside.clear();
272+
273+
await click(find.byKey(group1BKey));
274+
expect(tappedInside, isEmpty); // No hittable children while translucent, so no hit.
275+
tappedInside.clear();
276+
});
277+
188278
testWidgets('Setting the group updates the registration', (WidgetTester tester) async {
189279
final Set<String> tappedOutside = <String>{};
190280
await tester.pumpWidget(

0 commit comments

Comments
 (0)