From 4620a6b9a31e47fca1825a6e63d906e73ad06f4e Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Wed, 11 Jun 2025 23:29:18 +0300 Subject: [PATCH] feat: tap outside handler + component fixes --- CHANGELOG.md | 6 +- example/lib/main.dart | 55 ++++++++++- example/pubspec.lock | 2 +- lib/buttons/circle_button_component.dart | 11 ++- lib/buttons/rectangle_button_component.dart | 11 +-- lib/helpers/tap_outside_handler.dart | 102 ++++++++++++++++++++ lib/inputs/text_field_component.dart | 26 ++++- lib/inputs/toggle_component.dart | 53 +++++----- pubspec.yaml | 2 +- 9 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 lib/helpers/tap_outside_handler.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c4b40..397d33b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ -## 0.0.5 +## 0.1.0 +- Add `CircleButtonComponent` +- Add `ToggleComponent` +- Add `TapOutsideCallbacks` & `TapOutsideHandler` - `ModalComponent`: Rename `closeButton` to `trailing` - `ModalComponent`: Add `leading` property -- Add `CircleButtonComponent` - Rename `RectButtonComponent` to `RectangleButtonComponent` - Fix `TextFieldComponent` position and offset diff --git a/example/lib/main.dart b/example/lib/main.dart index 876e629..1bba4ec 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,7 @@ // ignore_for_file: avoid_print import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:flame/input.dart'; @@ -74,21 +75,52 @@ class FlameUIExample extends FlameGame // GridComponent final grid = GridComponent( children: List.generate(4, (i) { - return RectangleButtonComponent( + return CircleButtonComponent( label: 'G$i', - size: Vector2.all(36), + radius: 18, position: Vector2.zero(), - color: Colors.red, + color: Colors.blue, onPressed: () => print('Grid $i'), ); }), childSize: Vector2.all(36), spacing: Vector2.all(4), size: Vector2(100, 100), - position: Vector2(20, 260), + position: Vector2(40, 260), ); rootContainer.add(grid); + final visual = RectangleComponent( + size: Vector2(80, 32), + paint: Paint()..color = Colors.green, + children: [_ToggleIndicator(radius: 16, position: Vector2(40, 16))], + ); + + late ToggleComponent toggle; + toggle = ToggleComponent( + position: Vector2(20, 340), + value: false, + onChanged: (val) { + print('Toggled to $val'); + toggle.value = val; + }, + valueOn: visual, + valueOff: visual, + onAnimate: (visual, newValue) { + // Move the icon upward briefly when toggled + final indicator = visual.children.query<_ToggleIndicator>().first; + final amount = 24.0; + indicator.add( + MoveEffect.by( + Vector2(newValue ? amount : -amount, 0), + EffectController(duration: 0.3, curve: Curves.easeInOut), + ), + ); + }, + ); + + rootContainer.add(toggle); + // Wrap everything in scrollable area final scrollable = ScrollableAreaComponent( content: rootContainer, @@ -131,3 +163,18 @@ class FlameUIExample extends FlameGame add(modal); } } + +class _ToggleIndicator extends PositionComponent { + double radius; + _ToggleIndicator({super.position, required this.radius}); + + @override + void render(Canvas canvas) { + super.render(canvas); + canvas.drawCircle( + Offset(position.x - radius, position.y - radius), + radius, + Paint()..color = Colors.red, + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 763fe1c..d1a5c11 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -31,7 +31,7 @@ packages: path: ".." relative: true source: path - version: "0.0.4" + version: "0.1.0" flutter: dependency: "direct main" description: flutter diff --git a/lib/buttons/circle_button_component.dart b/lib/buttons/circle_button_component.dart index 6742bfd..e6b4bea 100644 --- a/lib/buttons/circle_button_component.dart +++ b/lib/buttons/circle_button_component.dart @@ -70,9 +70,6 @@ class CircleButtonComponent extends PositionComponent /// The color of the label text. Color textColor; - /// The current background color of the button. - late Color currentColor; - TextComponent? _text; /// The paint used for the button's background. @@ -88,7 +85,8 @@ class CircleButtonComponent extends PositionComponent /// /// [onPressed] is the callback triggered when the button is pressed. /// [label] is the text displayed on the button. - /// [size] and [position] define the size and position of the button. + /// [sprite] is the sprite displayed on the button. + /// [radius] and [position] define the size and position of the button. /// [color] is the default background color. /// [pressedColor] is the background color when pressed. Defaults to [color]. /// [textColor] is the color of the label text. @@ -110,7 +108,6 @@ class CircleButtonComponent extends PositionComponent _label = label, _sprite = sprite, pressedColor = pressedColor ?? color, - currentColor = color, paint = Paint() ..color = color @@ -158,4 +155,8 @@ class CircleButtonComponent extends PositionComponent super.render(canvas); canvas.drawCircle(Offset.zero, radius, paint); } + + @override + bool containsLocalPoint(Vector2 point) => + point.distanceTo(Vector2.zero()) <= radius; } diff --git a/lib/buttons/rectangle_button_component.dart b/lib/buttons/rectangle_button_component.dart index b4f31dd..a990cd9 100644 --- a/lib/buttons/rectangle_button_component.dart +++ b/lib/buttons/rectangle_button_component.dart @@ -23,9 +23,6 @@ class RectangleButtonComponent extends PositionComponent /// The color of the label text. Color textColor; - /// The current background color of the button. - late Color currentColor; - /// The background rectangle of the button. late RectangleComponent background; @@ -48,16 +45,12 @@ class RectangleButtonComponent extends PositionComponent required this.color, Color? pressedColor, this.textColor = Colors.white, - }) : pressedColor = pressedColor ?? color, - currentColor = color; + }) : pressedColor = pressedColor ?? color; /// Loads the button's components (background and text). @override Future onLoad() async { - background = RectangleComponent( - size: size, - paint: Paint()..color = currentColor, - ); + background = RectangleComponent(size: size, paint: Paint()..color = color); text = TextComponent( text: label, diff --git a/lib/helpers/tap_outside_handler.dart b/lib/helpers/tap_outside_handler.dart new file mode 100644 index 0000000..2b501fc --- /dev/null +++ b/lib/helpers/tap_outside_handler.dart @@ -0,0 +1,102 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; + +/// A component that catches global tap events and notifies registered fields +/// when a tap occurs outside their bounds. +/// +/// Use [register] with any [TapOutsideCallbacks] to receive outside tap notifications. +/// This is useful for dismissing overlays, popups, or input fields when +/// tapping elsewhere in the game. Use [unregister] when the component is removed or no longer +/// needs to receive notifications. +/// +/// To use this, ensure that the game has a [TapOutsideHandler] added to it, you can +/// use the `ensureGlobalTapCatcher` method on the game instance. +/// Run it before adding any components that need to handle outside taps, such as the game init. +/// If you want to load it inside [onLoad] of a component, make sure not to await it directly, +/// as it will block the component from loading while waiting for the game lifecycle to process. +/// Instead, use an unawaited method, or use `then` to wait without blocking. +/// +/// Example usage: +/// +/// ```dart +/// @override +/// Future onLoad() async { +/// super.onLoad(); +/// game.ensureGlobalTapCatcher().then((catcher) => catcher.register(this)); +/// } +/// ``` +class TapOutsideHandler extends Component with TapCallbacks, HasGameReference { + final Set _registeredFields = {}; + + Future attach(FlameGame game) { + return game.ensureGlobalTapCatcher(); + } + + /// Registers a [TapOutsideCallbacks] to receive outside tap notifications. + void register(TapOutsideCallbacks field) => _registeredFields.add(field); + + /// Unregisters a [TapOutsideCallbacks] from receiving outside tap notifications. + void unregister(TapOutsideCallbacks field) => _registeredFields.remove(field); + + @override + void onTapDown(TapDownEvent event) { + for (final field in _registeredFields) { + final rect = field.toAbsoluteRect(); + final tapPos = event.canvasPosition.toOffset(); + + // Notify the field if the tap was outside its bounds. + if (!rect.contains(tapPos)) { + field.onTapDownOutside(event); + } + } + event.continuePropagation = true; + } + + @override + void onTapUp(TapUpEvent event) { + for (final field in _registeredFields) { + final rect = field.toAbsoluteRect(); + final tapPos = event.canvasPosition.toOffset(); + + // Notify the field if the tap was outside its bounds. + if (!rect.contains(tapPos)) { + field.onTapUpOutside(event); + } + } + event.continuePropagation = true; + } + + @override + /// Always returns true to catch all tap events globally. + bool containsLocalPoint(Vector2 point) => true; +} + +/// Mixin for components that want to be notified when a tap occurs outside +/// their bounds via [TapOutsideHandler]. +/// +/// To use this mixin, a component must implement the `onTapDownOutside` or +/// `onTapUpOutside` methods to handle the tap events that occur outside. +mixin TapOutsideCallbacks on PositionComponent { + /// Called when a tap down event occurs outside this component's bounds. + void onTapDownOutside(TapDownEvent event) {} + + /// Called when a tap up event occurs outside this component's bounds. + void onTapUpOutside(TapUpEvent event) {} +} + +extension GlobalTapCatcherMixin on FlameGame { + /// Adds a [TapOutsideHandler] to the game if it doesn't already exist. + Future ensureGlobalTapCatcher() async { + if (children.query().isEmpty) { + await add(TapOutsideHandler()); + } + return lifecycleEventsProcessed.then((_) { + return children.query().first; + }); + } + + TapOutsideHandler get globalTapCatcher => + children.query().firstOrNull ?? + (TapOutsideHandler()..attach(this)); +} diff --git a/lib/inputs/text_field_component.dart b/lib/inputs/text_field_component.dart index b92cb24..58fba55 100644 --- a/lib/inputs/text_field_component.dart +++ b/lib/inputs/text_field_component.dart @@ -1,14 +1,18 @@ +import 'dart:async'; + import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flutter/material.dart'; +import '../helpers/tap_outside_handler.dart'; + /// A callback type for handling text changes. typedef OnTextChanged = void Function(String); /// A custom text field component for Flame games, supporting focus, rendering, /// and interaction with a virtual keyboard. class TextFieldComponent extends PositionComponent - with TapCallbacks, HasGameReference { + with TapCallbacks, HasGameReference, TapOutsideCallbacks { /// Generic background component when not focused. final PositionComponent? background; @@ -69,6 +73,12 @@ class TextFieldComponent extends PositionComponent ); } + @override + Future onLoad() async { + super.onLoad(); + game.ensureGlobalTapCatcher().then((catcher) => catcher.register(this)); + } + @override void render(Canvas canvas) { super.render(canvas); @@ -98,16 +108,21 @@ class TextFieldComponent extends PositionComponent @override void onTapDown(TapDownEvent event) { - _focus(); + focus(); } - void _focus() { + @override + void onTapDownOutside(TapDownEvent event) { + unfocus(); + } + + void focus() { if (isFocused) return; isFocused = true; _showKeyboardOverlay(); } - void _unfocus() { + void unfocus() { if (!isFocused) return; isFocused = false; _overlayEntry?.remove(); @@ -166,7 +181,8 @@ class TextFieldComponent extends PositionComponent @override void onRemove() { - _unfocus(); + unfocus(); + game.children.query().firstOrNull?.unregister(this); super.onRemove(); } } diff --git a/lib/inputs/toggle_component.dart b/lib/inputs/toggle_component.dart index 6cfa1f0..9d2cde4 100644 --- a/lib/inputs/toggle_component.dart +++ b/lib/inputs/toggle_component.dart @@ -8,32 +8,37 @@ import 'package:flutter/widgets.dart'; /// and toggles between them when tapped. It supports custom animations during the toggle, /// provides a callback for value changes, and can be marked non-interactive via [disabled]. /// -/// ### Example: Animate a child icon when toggled +/// ### Example: Animate a child indicator when toggled /// /// ```dart -/// final icon = SpriteComponent(sprite: yourIcon, size: Vector2.all(12)) -/// ..position = Vector2(10, 10); -/// -/// final onVisual = RectangleComponent(size: Vector2.all(32), children: [icon]); -/// final offVisual = RectangleComponent(size: Vector2.all(32)); -/// -/// final toggle = ToggleComponent( -/// value: false, -/// onChanged: (val) => print('Toggled to $val'), -/// valueOn: onVisual, -/// valueOff: offVisual, -/// onAnimate: (visual, newValue) { -/// // Move the icon upward briefly when toggled -/// final icon = visual.children.query().first; -/// icon.add( -/// MoveEffect.by( -/// Vector2(0, -4), -/// EffectController(duration: 0.1, reverseDuration: 0.1, alternate: true), -/// ), -/// ); -/// }, -/// ); -/// ``` +// final visual = RectangleComponent( +// size: Vector2(80, 32), +// paint: Paint()..color = Colors.green, +// children: [_ToggleIndicator(radius: 16, position: Vector2(40, 16))], +// ); +// +// late ToggleComponent toggle; +// toggle = ToggleComponent( +// position: Vector2(20, 340), +// value: false, +// onChanged: (val) { +// print('Toggled to $val'); +// toggle.value = val; +// }, +// valueOn: visual, +// valueOff: visual, +// onAnimate: (visual, newValue) { +// // Move the icon upward briefly when toggled +// final indicator = visual.children.query<_ToggleIndicator>().first; +// final amount = 24.0; +// indicator.add( +// MoveEffect.by( +// Vector2(newValue ? amount : -amount, 0), +// EffectController(duration: 0.3, curve: Curves.easeInOut), +// ), +// ); +// }, +// ); class ToggleComponent extends PositionComponent with TapCallbacks { /// The current value of the toggle. `true` for enabled, `false` for disabled. bool _value; diff --git a/pubspec.yaml b/pubspec.yaml index 81c8fed..8978bfc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flame_ui description: A reusable UI component library for Flame games, including buttons, text fields, modals, lists, and layout helpers. -version: 0.0.4 +version: 0.1.0 homepage: https://github.com/chenasraf/flame_ui repository: https://github.com/chenasraf/flame_ui