| // 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); |
| } |