blob: 6d6371d8bdbe2dc87634d0fcf321e384a3f62542 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'editable_text.dart';
import 'framework.dart';
// Enable if you want verbose logging about tap region changes.
const bool _kDebugTapRegion = false;
bool _tapRegionDebug(String message, [Iterable<String>? details]) {
if (_kDebugTapRegion) {
debugPrint('TAP REGION: $message');
if (details != null && details.isNotEmpty) {
for (final String detail in details) {
debugPrint(' $detail');
}
}
}
// Return true so that it can be easily used inside of an assert.
return true;
}
/// The type of callback that [TapRegion.onTapOutside] and
/// [TapRegion.onTapInside] take.
///
/// The event is the pointer event that caused the callback to be called.
typedef TapRegionCallback = void Function(PointerDownEvent event);
/// An interface for registering and unregistering a [RenderTapRegion]
/// (typically created with a [TapRegion] widget) with a
/// [RenderTapRegionSurface] (typically created with a [TapRegionSurface]
/// widget).
abstract class TapRegionRegistry {
/// Register the given [RenderTapRegion] with the registry.
void registerTapRegion(RenderTapRegion region);
/// Unregister the given [RenderTapRegion] with the registry.
void unregisterTapRegion(RenderTapRegion region);
/// Allows finding of the nearest [TapRegionRegistry], such as a
/// [RenderTapRegionSurface].
///
/// Will throw if a [TapRegionRegistry] isn't found.
static TapRegionRegistry of(BuildContext context) {
final TapRegionRegistry? registry = maybeOf(context);
assert(() {
if (registry == null) {
throw FlutterError(
'TapRegionRegistry.of() was called with a context that does not contain a TapRegionSurface widget.\n'
'No TapRegionSurface widget ancestor could be found starting from the context that was passed to '
'TapRegionRegistry.of().\n'
'The context used was:\n'
' $context',
);
}
return true;
}());
return registry!;
}
/// Allows finding of the nearest [TapRegionRegistry], such as a
/// [RenderTapRegionSurface].
static TapRegionRegistry? maybeOf(BuildContext context) {
return context.findAncestorRenderObjectOfType<RenderTapRegionSurface>();
}
}
/// A widget that provides notification of a tap inside or outside of a set of
/// registered regions, without participating in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// The regions are defined by adding [TapRegion] widgets to the widget tree
/// around the regions of interest, and they will register with this
/// [TapRegionSurface]. Each of the tap regions can optionally belong to a group
/// by assigning a [TapRegion.groupId], where all the regions with the same
/// groupId act as if they were all one region.
///
/// When a tap outside of a registered region or region group is detected, its
/// [TapRegion.onTapOutside] callback is called. If the tap is outside one
/// member of a group, but inside another, no notification is made.
///
/// When a tap inside of a registered region or region group is detected, its
/// [TapRegion.onTapInside] callback is called. If the tap is inside one member
/// of a group, all members are notified.
///
/// The [TapRegionSurface] should be defined at the highest level needed to
/// encompass the entire area where taps should be monitored. This is typically
/// around the entire app. If the entire app isn't covered, then taps outside of
/// the [TapRegionSurface] will be ignored and no [TapRegion.onTapOutside] calls
/// will be made for those events. The [WidgetsApp], [MaterialApp] and
/// [CupertinoApp] automatically include a [TapRegionSurface] around their
/// entire app.
///
/// [TapRegionSurface] does not participate in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system, so if multiple [TapRegionSurface]s are active at the same time, they
/// will all fire, and so will any other gestures recognized by a
/// [GestureDetector] or other pointer event handlers.
///
/// [TapRegion]s register only with the nearest ancestor [TapRegionSurface].
///
/// See also:
///
/// * [RenderTapRegionSurface], the render object that is inserted into the
/// render tree by this widget.
/// * <https://flutter.dev/gestures/#gesture-disambiguation> for more
/// information about the gesture system and how it disambiguates inputs.
class TapRegionSurface extends SingleChildRenderObjectWidget {
/// Creates a const [RenderTapRegionSurface].
///
/// The [child] attribute is required.
const TapRegionSurface({
super.key,
required Widget super.child,
});
@override
RenderObject createRenderObject(BuildContext context) {
return RenderTapRegionSurface();
}
@override
void updateRenderObject(
BuildContext context,
RenderProxyBoxWithHitTestBehavior renderObject,
) {}
}
/// A render object that provides notification of a tap inside or outside of a
/// set of registered regions, without participating in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// The regions are defined by adding [RenderTapRegion] render objects in the
/// render tree around the regions of interest, and they will register with this
/// [RenderTapRegionSurface]. Each of the tap regions can optionally belong to a
/// group by assigning a [RenderTapRegion.groupId], where all the regions with
/// the same groupId act as if they were all one region.
///
/// When a tap outside of a registered region or region group is detected, its
/// [TapRegion.onTapOutside] callback is called. If the tap is outside one
/// member of a group, but inside another, no notification is made.
///
/// When a tap inside of a registered region or region group is detected, its
/// [TapRegion.onTapInside] callback is called. If the tap is inside one member
/// of a group, all members are notified.
///
/// The [RenderTapRegionSurface] should be defined at the highest level needed
/// to encompass the entire area where taps should be monitored. This is
/// typically around the entire app. If the entire app isn't covered, then taps
/// outside of the [RenderTapRegionSurface] will be ignored and no
/// [RenderTapRegion.onTapOutside] calls will be made for those events. The
/// [WidgetsApp], [MaterialApp] and [CupertinoApp] automatically include a
/// [RenderTapRegionSurface] around the entire app.
///
/// [RenderTapRegionSurface] does not participate in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system, so if multiple [RenderTapRegionSurface]s are active at the same
/// time, they will all fire, and so will any other gestures recognized by a
/// [GestureDetector] or other pointer event handlers.
///
/// [RenderTapRegion]s register only with the nearest ancestor
/// [RenderTapRegionSurface].
///
/// See also:
///
/// * [TapRegionSurface], a widget that inserts a [RenderTapRegionSurface] into
/// the render tree.
/// * [TapRegionRegistry.of], which can find the nearest ancestor
/// [RenderTapRegionSurface], which is a [TapRegionRegistry].
class RenderTapRegionSurface extends RenderProxyBoxWithHitTestBehavior with TapRegionRegistry {
final Expando<BoxHitTestResult> _cachedResults = Expando<BoxHitTestResult>();
final Set<RenderTapRegion> _registeredRegions = <RenderTapRegion>{};
final Map<Object?, Set<RenderTapRegion>> _groupIdToRegions = <Object?, Set<RenderTapRegion>>{};
@override
void registerTapRegion(RenderTapRegion region) {
assert(_tapRegionDebug('Region $region registered.'));
assert(!_registeredRegions.contains(region));
_registeredRegions.add(region);
if (region.groupId != null) {
_groupIdToRegions[region.groupId] ??= <RenderTapRegion>{};
_groupIdToRegions[region.groupId]!.add(region);
}
}
@override
void unregisterTapRegion(RenderTapRegion region) {
assert(_tapRegionDebug('Region $region unregistered.'));
assert(_registeredRegions.contains(region));
_registeredRegions.remove(region);
if (region.groupId != null) {
assert(_groupIdToRegions.containsKey(region.groupId));
_groupIdToRegions[region.groupId]!.remove(region);
if (_groupIdToRegions[region.groupId]!.isEmpty) {
_groupIdToRegions.remove(region.groupId);
}
}
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) {
return false;
}
final bool hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget) {
final BoxHitTestEntry entry = BoxHitTestEntry(this, position);
_cachedResults[entry] = result;
result.add(entry);
}
return hitTarget;
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
assert(() {
for (final RenderTapRegion region in _registeredRegions) {
if (!region.enabled) {
return false;
}
}
return true;
}(), 'A RenderTapRegion was registered when it was disabled.');
if (event is! PointerDownEvent || event.buttons != kPrimaryButton) {
return;
}
if (_registeredRegions.isEmpty) {
assert(_tapRegionDebug('Ignored tap event because no regions are registered.'));
return;
}
final BoxHitTestResult? result = _cachedResults[entry];
if (result == null) {
assert(_tapRegionDebug('Ignored tap event because no surface descendants were hit.'));
return;
}
// A child was hit, so we need to call onTapOutside for those regions or
// groups of regions that were not hit.
final Set<RenderTapRegion> hitRegions =
_getRegionsHit(_registeredRegions, result.path).cast<RenderTapRegion>().toSet();
final Set<RenderTapRegion> insideRegions = <RenderTapRegion>{};
assert(_tapRegionDebug('Tap event hit ${hitRegions.length} descendants.'));
for (final RenderTapRegion region in hitRegions) {
if (region.groupId == null) {
insideRegions.add(region);
continue;
}
// Add all grouped regions to the insideRegions so that groups act as a
// single region.
insideRegions.addAll(_groupIdToRegions[region.groupId]!);
}
// If they're not inside, then they're outside.
final Set<RenderTapRegion> outsideRegions = _registeredRegions.difference(insideRegions);
for (final RenderTapRegion region in outsideRegions) {
assert(_tapRegionDebug('Calling onTapOutside for $region'));
region.onTapOutside?.call(event);
}
for (final RenderTapRegion region in insideRegions) {
assert(_tapRegionDebug('Calling onTapInside for $region'));
region.onTapInside?.call(event);
}
}
// Returns the registered regions that are in the hit path.
Iterable<HitTestTarget> _getRegionsHit(Set<RenderTapRegion> detectors, Iterable<HitTestEntry> hitTestPath) {
final Set<HitTestTarget> hitRegions = <HitTestTarget>{};
for (final HitTestEntry<HitTestTarget> entry in hitTestPath) {
final HitTestTarget target = entry.target;
if (_registeredRegions.contains(target)) {
hitRegions.add(target);
}
}
return hitRegions;
}
}
/// A widget that defines a region that can detect taps inside or outside of
/// itself and any group of regions it belongs to, without participating in the
/// [gesture disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// This widget indicates to the nearest ancestor [TapRegionSurface] that the
/// region occupied by its child will participate in the tap detection for that
/// surface.
///
/// If this region belongs to a group (by virtue of its [groupId]), all the
/// regions in the group will act as one.
///
/// If there is no [TapRegionSurface] ancestor, [TapRegion] will do nothing.
class TapRegion extends SingleChildRenderObjectWidget {
/// Creates a const [TapRegion].
///
/// The [child] argument is required.
const TapRegion({
super.key,
required super.child,
this.enabled = true,
this.onTapOutside,
this.onTapInside,
this.groupId,
String? debugLabel,
}) : debugLabel = kReleaseMode ? null : debugLabel;
/// Whether or not this [TapRegion] is enabled as part of the composite region.
final bool enabled;
/// A callback to be invoked when a tap is detected outside of this
/// [TapRegion] and any other region with the same [groupId], if any.
///
/// The [PointerDownEvent] passed to the function is the event that caused the
/// notification. If this region is part of a group (i.e. [groupId] is set),
/// then it's possible that the event may be outside of this immediate region,
/// although it will be within the region of one of the group members.
final TapRegionCallback? onTapOutside;
/// A callback to be invoked when a tap is detected inside of this
/// [TapRegion], or any other tap region with the same [groupId], if any.
///
/// The [PointerDownEvent] passed to the function is the event that caused the
/// notification. If this region is part of a group (i.e. [groupId] is set),
/// then it's possible that the event may be outside of this immediate region,
/// although it will be within the region of one of the group members.
final TapRegionCallback? onTapInside;
/// An optional group ID that groups [TapRegion]s together so that they
/// operate as one region. If any member of a group is hit by a particular
/// tap, then the [onTapOutside] will not be called for any members of the
/// group. If any member of the group is hit, then all members will have their
/// [onTapInside] called.
///
/// If the group id is null, then only this region is hit tested.
final Object? groupId;
/// An optional debug label to help with debugging in debug mode.
///
/// Will be null in release mode.
final String? debugLabel;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderTapRegion(
registry: TapRegionRegistry.maybeOf(context),
enabled: enabled,
onTapOutside: onTapOutside,
onTapInside: onTapInside,
groupId: groupId,
debugLabel: debugLabel,
);
}
@override
void updateRenderObject(BuildContext context, covariant RenderTapRegion renderObject) {
renderObject.registry = TapRegionRegistry.maybeOf(context);
renderObject.enabled = enabled;
renderObject.groupId = groupId;
renderObject.onTapOutside = onTapOutside;
renderObject.onTapInside = onTapInside;
if (kReleaseMode) {
renderObject.debugLabel = debugLabel;
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Object?>('debugLabel', debugLabel, defaultValue: null));
properties.add(DiagnosticsProperty<Object?>('groupId', groupId, defaultValue: null));
properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true));
}
}
/// A render object that defines a region that can detect taps inside or outside
/// of itself and any group of regions it belongs to, without participating in
/// the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// This render object indicates to the nearest ancestor [TapRegionSurface] that
/// the region occupied by its child will participate in the tap detection for
/// that surface.
///
/// If this region belongs to a group (by virtue of its [groupId]), all the
/// regions in the group will act as one.
///
/// If there is no [RenderTapRegionSurface] ancestor in the render tree,
/// [RenderTapRegion] will do nothing.
///
/// See also:
///
/// * [TapRegion], a widget that inserts a [RenderTapRegion] into the render
/// tree.
class RenderTapRegion extends RenderProxyBox {
/// Creates a [RenderTapRegion].
RenderTapRegion({
TapRegionRegistry? registry,
bool enabled = true,
this.onTapOutside,
this.onTapInside,
Object? groupId,
String? debugLabel,
}) : _registry = registry,
_enabled = enabled,
_groupId = groupId,
debugLabel = kReleaseMode ? null : debugLabel;
bool _isRegistered = false;
/// A callback to be invoked when a tap is detected outside of this
/// [RenderTapRegion] and any other region with the same [groupId], if any.
///
/// The [PointerDownEvent] passed to the function is the event that caused the
/// notification. If this region is part of a group (i.e. [groupId] is set),
/// then it's possible that the event may be outside of this immediate region,
/// although it will be within the region of one of the group members.
TapRegionCallback? onTapOutside;
/// A callback to be invoked when a tap is detected inside of this
/// [RenderTapRegion], or any other tap region with the same [groupId], if any.
///
/// The [PointerDownEvent] passed to the function is the event that caused the
/// notification. If this region is part of a group (i.e. [groupId] is set),
/// then it's possible that the event may be outside of this immediate region,
/// although it will be within the region of one of the group members.
TapRegionCallback? onTapInside;
/// A label used in debug builds. Will be null in release builds.
String? debugLabel;
/// Whether or not this region should participate in the composite region.
bool get enabled => _enabled;
bool _enabled;
set enabled(bool value) {
if (_enabled != value) {
_enabled = value;
markNeedsLayout();
}
}
/// An optional group ID that groups [RenderTapRegion]s together so that they
/// operate as one region. If any member of a group is hit by a particular
/// tap, then the [onTapOutside] will not be called for any members of the
/// group. If any member of the group is hit, then all members will have their
/// [onTapInside] called.
///
/// If the group id is null, then only this region is hit tested.
Object? get groupId => _groupId;
Object? _groupId;
set groupId(Object? value) {
if (_groupId != value) {
// If the group changes, we need to unregister and re-register under the
// new group. The re-registration happens automatically in layout().
if (_isRegistered) {
_registry!.unregisterTapRegion(this);
_isRegistered = false;
}
_groupId = value;
markNeedsLayout();
}
}
/// The registry that this [RenderTapRegion] should register with.
///
/// If the [registry] is null, then this region will not be registered
/// anywhere, and will not do any tap detection.
///
/// A [RenderTapRegionSurface] is a [TapRegionRegistry].
TapRegionRegistry? get registry => _registry;
TapRegionRegistry? _registry;
set registry(TapRegionRegistry? value) {
if (_registry != value) {
if (_isRegistered) {
_registry!.unregisterTapRegion(this);
_isRegistered = false;
}
_registry = value;
markNeedsLayout();
}
}
@override
void layout(Constraints constraints, {bool parentUsesSize = false}) {
super.layout(constraints, parentUsesSize: parentUsesSize);
if (_registry == null) {
return;
}
if (_isRegistered) {
_registry!.unregisterTapRegion(this);
}
final bool shouldBeRegistered = _enabled && _registry != null;
if (shouldBeRegistered) {
_registry!.registerTapRegion(this);
}
_isRegistered = shouldBeRegistered;
}
@override
void dispose() {
if (_isRegistered) {
_registry!.unregisterTapRegion(this);
}
super.dispose();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<String?>('debugLabel', debugLabel, defaultValue: null));
properties.add(DiagnosticsProperty<Object?>('groupId', groupId, defaultValue: null));
properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true));
}
}
/// A [TapRegion] that adds its children to the tap region group for widgets
/// based on the [EditableText] text editing widget, such as [TextField] and
/// [CupertinoTextField].
///
/// Widgets that are wrapped with a [TextFieldTapRegion] are considered to be
/// part of a text field for purposes of unfocus behavior. So, when the user
/// taps on them, the currently focused text field won't be unfocused by
/// default. This allows controls like spinners, copy buttons, and formatting
/// buttons to be associated with a text field without causing the text field to
/// lose focus when they are interacted with.
///
/// {@tool dartpad}
/// This example shows how to use a [TextFieldTapRegion] to wrap a set of
/// "spinner" buttons that increment and decrement a value in the text field
/// without causing the text field to lose keyboard focus.
///
/// This example includes a generic `SpinnerField<T>` class that you can copy/paste
/// into your own project and customize.
///
/// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [TapRegion], the widget that this widget uses to add widgets to the group
/// of text fields.
class TextFieldTapRegion extends TapRegion {
/// Creates a const [TextFieldTapRegion].
///
/// The [child] field is required.
const TextFieldTapRegion({
super.key,
required super.child,
super.enabled,
super.onTapOutside,
super.onTapInside,
super.debugLabel,
}) : super(groupId: EditableText);
}