Skip to content

Instantly share code, notes, and snippets.

@venkatd
Last active August 26, 2021 00:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save venkatd/d259568dd49d3ea44519281cc0b65d49 to your computer and use it in GitHub Desktop.
Save venkatd/d259568dd49d3ea44519281cc0b65d49 to your computer and use it in GitHub Desktop.
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
typedef TapOutsideCallback = void Function(BoxHitTestResult hitTestResult);
/// Allows widgets to detect when tapping outside of the bounds of the widget.
///
/// A common use case is allowing [TextField]s and other widgets to give up
/// their focus when someone taps outside of them.
///
/// See also:
///
/// * [TapOutsideSurface], the widget you must put close to the root of your
/// application in order to detect taps outside.
class TapOutsideDetector extends SingleChildRenderObjectWidget {
/// Create a new [FocusTrapArea] that expands the area of the provided [focusNode].
TapOutsideDetector({
Key? key,
required Widget child,
this.onTapOutside,
this.enabled = true,
}) : super(key: key, child: child);
/// Called when [enabled] is true and the nearest [TapOutsideSurface]
/// receives a in an area outside the bounds of the [child] widget
final TapOutsideCallback? onTapOutside;
/// Whether [onTapOutside] should be called
final bool enabled;
_TapOutsideSurfaceRenderObject? _lastRegisteredSurface;
@override
_TapOutsideDetectorRenderObject createRenderObject(BuildContext context) {
final renderObject = _TapOutsideDetectorRenderObject(
onTapOutside: onTapOutside,
enabled: enabled,
);
final surfaceRenderObject = context
.findAncestorRenderObjectOfType<_TapOutsideSurfaceRenderObject>();
if (surfaceRenderObject == null) {
throw TapOutsideSurfaceNotFoundError._(renderObject);
}
if (onTapOutside != null && enabled) {
surfaceRenderObject.register(renderObject);
_lastRegisteredSurface = surfaceRenderObject;
}
return renderObject;
}
@override
void updateRenderObject(
BuildContext context, _TapOutsideDetectorRenderObject renderObject) {
bool wasRegistered = renderObject.shouldBeRegistered;
renderObject.onTapOutside = onTapOutside;
renderObject.enabled = enabled;
bool shouldBeRegistered = renderObject.shouldBeRegistered;
if (shouldBeRegistered == wasRegistered) {
return;
}
final surfaceRenderObject = context
.findAncestorRenderObjectOfType<_TapOutsideSurfaceRenderObject>();
if (surfaceRenderObject == null) {
throw TapOutsideSurfaceNotFoundError._(renderObject);
}
if (shouldBeRegistered && !wasRegistered) {
surfaceRenderObject.register(renderObject);
_lastRegisteredSurface = surfaceRenderObject;
} else if (!shouldBeRegistered && wasRegistered) {
surfaceRenderObject.unregister(renderObject);
_lastRegisteredSurface = null;
}
}
@override
void didUnmountRenderObject(_TapOutsideDetectorRenderObject renderObject) {
_lastRegisteredSurface?.unregister(renderObject);
_lastRegisteredSurface = null;
}
}
class _TapOutsideDetectorRenderObject extends RenderProxyBox {
_TapOutsideDetectorRenderObject({
required this.onTapOutside,
required this.enabled,
});
TapOutsideCallback? onTapOutside;
bool enabled;
bool get shouldBeRegistered => onTapOutside != null && enabled;
}
/// In order to use [TapOutsideDetector], you must wrap the entire application
/// in a [TapOutsideSurface] which can detect tap outside events on behalf of
/// [TapOutsideDetector]s.
///
/// For performance reasons, widgets in Flutter can't respond hit tests
/// outside of their bounds.
class TapOutsideSurface extends SingleChildRenderObjectWidget {
const TapOutsideSurface({
required Widget child,
Key? key,
}) : super(child: child, key: key);
@override
RenderObject createRenderObject(BuildContext context) {
return _TapOutsideSurfaceRenderObject();
}
@override
void updateRenderObject(
BuildContext context, _TapOutsideSurfaceRenderObject renderObject) {}
}
class _TapOutsideSurfaceRenderObject extends RenderProxyBoxWithHitTestBehavior {
final cachedResults = Expando<BoxHitTestResult>();
final _registeredDetectors = Set<_TapOutsideDetectorRenderObject>();
void register(_TapOutsideDetectorRenderObject detector) {
_registeredDetectors.add(detector);
}
void unregister(_TapOutsideDetectorRenderObject detector) {
_registeredDetectors.remove(detector);
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) {
return false;
}
final hitTarget =
hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget) {
final entry = BoxHitTestEntry(this, position);
cachedResults[entry] = result;
result.add(entry);
}
return hitTarget;
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (_registeredDetectors.isEmpty) {
return;
}
assert(debugHandleEvent(event, entry));
if (event is! PointerDownEvent ||
event.buttons != kPrimaryButton ||
event.kind != PointerDeviceKind.mouse) {
return;
}
final BoxHitTestResult? result = cachedResults[entry];
if (result == null) return;
final matchingDetectors =
_matchingDetectors(_registeredDetectors, result.path);
for (final detector in matchingDetectors) {
assert(detector.enabled);
detector.onTapOutside!.call(result);
}
}
Iterable<_TapOutsideDetectorRenderObject> _matchingDetectors(
Set<_TapOutsideDetectorRenderObject> detectors,
Iterable<HitTestEntry> hitTestPath) {
final hitDetectors = Set<HitTestTarget>();
for (final entry in hitTestPath) {
final target = entry.target;
if (_registeredDetectors.contains(target)) {
hitDetectors.add(target);
}
}
return detectors.difference(hitDetectors);
}
}
/// The error that will be thrown if [TapOutsideDetector] fails to find a [TapOutsideSurface].
class TapOutsideSurfaceNotFoundError<_TapOutsideSurfaceRenderObject>
extends Error {
TapOutsideSurfaceNotFoundError._(this._detector);
final _TapOutsideSurfaceRenderObject _detector;
@override
String toString() {
return '''
Error: Could not find a TapOutsideSurface ancestor above $_detector
''';
}
}
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'tap_outside_detector.dart';
/// Unfocuses the widget with the primary focus when a tap occurs outside the
/// bounds of [child]. Unfocus is only triggered if [child] is an ancestor of
/// the widget with primary focus.
class TapOutsideUnfocuser extends StatefulWidget {
const TapOutsideUnfocuser({required this.child});
final Widget child;
@override
State<TapOutsideUnfocuser> createState() => _TapOutsideUnfocuserState();
}
class _TapOutsideUnfocuserState extends State<TapOutsideUnfocuser> {
late final FocusNode focusNode;
bool focusNodeHasFocus = false;
@override
initState() {
super.initState();
focusNode = FocusNode(
debugLabel: 'TapOutsideUnfocuser',
canRequestFocus: false,
skipTraversal: true,
);
focusNodeHasFocus = focusNode.hasFocus;
focusNode.addListener(_onFocusMaybeChanged);
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: focusNode,
child: TapOutsideDetector(
enabled: focusNodeHasFocus,
onTapOutside: (BoxHitTestResult result) {
if (_shouldUnfocus(result)) {
FocusManager.instance.primaryFocus?.unfocus();
}
},
child: widget.child,
),
);
}
void _onFocusMaybeChanged() {
if (focusNodeHasFocus != focusNode.hasFocus) {
setState(() {
focusNodeHasFocus = focusNode.hasFocus;
});
}
}
@override
void dispose() {
super.dispose();
focusNode.dispose();
}
}
bool _shouldUnfocus(BoxHitTestResult result) {
for (final e in result.path) {
if (e.target is RenderEditable || e.target is _IgnoreUnfocuserRenderBox) {
return false;
}
}
return true;
}
/// Widgets besides TextField that you don't want to trigger an unfocus for
/// can be wrapped with [IgnoreUnfocuser]
class IgnoreUnfocuser extends SingleChildRenderObjectWidget {
const IgnoreUnfocuser({required this.child}) : super(child: child);
final Widget child;
@override
_IgnoreUnfocuserRenderBox createRenderObject(BuildContext context) =>
_IgnoreUnfocuserRenderBox();
}
class _IgnoreUnfocuserRenderBox extends RenderPointerListener {}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment