When building a custom UI control in Flutter it’s tempting to just use a GestureDetector
and call it a day, but to do so would be a mistake! Especially if you’re planning on supporting users with alternate inputs like mouse or keyboard.
It turns out that making a UI control that behaves “properly” for a variety of inputs is quite a bit more complicated than just detecting taps. Generally speaking, each control you create needs the following features:
- a hover state
- a focus state (for tab-key or arrow-key traversal)
- a region that changes the mouse-cursor
- key handlers for [Space] and [Enter] keys (or maybe others)
Traditionally, you might create that by composing a big block of widgets including Focus
, Actions
, Shortcuts
and MouseRegion
. This works, but is a lot of boilerplate, and indentation. Luckily Flutter provides a dedicated widget for this purpose: FocusableActionDetector
.
FocusableActionDetector
combines Focus
, Actions
, Shortcuts
and MouseRegion
into one, and is used pretty heavily inside of the Flutter SDK, including the Material DatePicker
, Switch
, Checkbox
, Radio
and Slider
controls.
Using it yourself is fairly straightforward. Lets look at how we’d build a completely custom button using this widget.
Note: To follow along with the code, check out the gist here: https://gist.github.com/esDotDev/04a6301a3858769d4baf5ab1230f7fa2
MyCustomButton
First, create a StatefulWidget
that can hold your _isFocused
and _isHovered
state. We’ll also create a FocusNode
that we can use to request focus on press, which is a common behavior for buttons. Also create some typical onPressed
and label
fields to configure the button.
class MyCustomButton extends StatefulWidget { @override State<MyCustomButton> createState() => _MyCustomButtonState(); } class _MyCustomButtonState extends State<MyCustomButton> { bool _isHovered = false; bool _isFocused = false; FocusNode _focusNode = FocusNode(); @override Widget build(BuildContext context) { // TODO: } void _handlePressed() { _focusNode.requestFocus(); widget.onPressed(); } }
Because we’re making a button, we’ll add some basic configuration options:
const MyCustomButton({Key? key, required this.onPressed, required this.label}) : super(key: key); final VoidCallback onPressed; final String label;
All that’s left now, is to fill out the build()
method.
Widget build(BuildContext context) { // Change visuals based on focus/hover state Color outlineColor = _isFocused ? Colors.black : Colors.transparent; Color bgColor = _isHovered ? Colors.blue.shade100 : Colors.white; // Use `GestureDetector` to handle taps return GestureDetector( onTap: _handlePressed, // Focus Actionable Detector child: FocusableActionDetector( focusNode: _focusNode, // Set mouse cursor mouseCursor: SystemMouseCursors.click, // Rebuild with hover/focus changes onShowFocusHighlight: (v) => setState(() => _isFocused = v), onShowHoverHighlight: (v) => setState(() => _isHovered = v), // Button Content child: Container( padding: const EdgeInsets.all(8), child: Text(widget.label), decoration: BoxDecoration( color: bgColor, border: Border.all(color: outlineColor, width: 1), ), ), ), );
In the above code, note that you still do need to wrap a GestureDetector
to detect taps. Additionally, notice how you can change the appearance of your ui, according to hover/focus state.
Keyboard Actions
The final step for any control is Keyboard bindings. By default most OS controls support [Space] and [Enter] to “submit”. You can do this by wiring up the built-in ActivateIntent
in the .actions
field:
FocusableActionDetector( // Hook up the built-in `ActivateIntent` to submit on [Enter] and [Space] actions: { ActivateIntent: CallbackAction<Intent>(onInvoke: (_) => _handlePressed()), }, child: ... ),
Note: You can create your own custom Actions and Intents but most of the time the default ActivateIntent
is enough.
You can also add additional key bindings using the .shortcuts
parameter. Here we’ll bind yet [Ctrl + X] to the same ActivateIntent
. Now our control will submit on [Enter], [Space] and [Ctrl + X]!
FocusableActionDetector( // Add 'Ctrl + X' key to the default [Enter] and [Space] shortcuts: { SingleActivator(LogicalKeyboardKey.keyX, control: true): ActivateIntent(), }, child: ... ),
With that, you have a finished control that works exactly as you’d expect on desktop, web and mobile!
And here is a look at the complete code for your new custom button:
class MyCustomButton extends StatefulWidget { const MyCustomButton({Key? key, required this.onPressed, required this.label}) : super(key: key); final VoidCallback onPressed; final String label; @override State<MyCustomButton> createState() => _MyCustomButtonState(); } class _MyCustomButtonState extends State<MyCustomButton> { bool _isHovered = false; bool _isFocused = false; FocusNode _focusNode = FocusNode(); @override Widget build(BuildContext context) { // Change visuals based on focus/hover state Color outlineColor = _isFocused ? Colors.black : Colors.transparent; Color bgColor = _isHovered ? Colors.blue.shade100 : Colors.white; return GestureDetector( onTap: _handlePressed, child: FocusableActionDetector( actions: { ActivateIntent: CallbackAction<Intent>(onInvoke: (_) => _handlePressed()), }, shortcuts: { SingleActivator(LogicalKeyboardKey.keyX, control: true): ActivateIntent(), }, child: Container( padding: const EdgeInsets.all(8), child: Text(widget.label), decoration: BoxDecoration( color: bgColor, border: Border.all(color: outlineColor, width: 1), ), ), ), ); } void _handlePressed() { _focusNode.requestFocus(); widget.onPressed(); } }
To view a running example of this, as well as a custom checkbox example, check out here: https://gist.github.com/esDotDev/04a6301a3858769d4baf5ab1230f7fa2
Going further…
While the FocusableActionDetector
takes care of some boilerplate, it still leaves quite a bit behind for common use cases like buttons, checkboxes, switches etc. Specifically:
- adding the
GestureDetector
- adding the
ActivateIntent
mapping - maintaining the
isHovered
andisFocused
state - managing the focus request on submit
It seemed to us that all of this common behavior could be handled with some sort of builder. So we went ahead and made one!
It’s called FocusableControlBuilder
and is available as a package here: https://pub.dev/packages/focusable_control_builder
FocusableControlBuilder
bakes in support for GestureDetector
, manages the FocusNode
, adds an ActivateIntent
for keyboard support and provides a builder
method which gives access to control.isFocused
and control.isHovered
for use in the construction of the view. Phew!
For most use cases, this will eliminate the majority of boilerplate. Buttons, checkboxes, toggles, sliders etc could all base themselves off of this builder augmenting it with different gestures or state.
If we convert the previous example it looks something like:
class MyCustomButton extends StatelessWidget { const MyCustomButton(this.label, {Key? key, required this.onPressed}) : super(key: key); final String label; final VoidCallback onPressed; @override Widget build(BuildContext context) { return FocusableControlBuilder( onPressed: onPressed, builder: (_, FocusableControlState control) { Color outlineColor = control.isFocused ? Colors.black : Colors.transparent; Color bgColor = control.isHovered ? Colors.blue.shade100 : Colors.grey.shade200; return Container( padding: const EdgeInsets.all(8), child: Text(label), decoration: BoxDecoration( color: bgColor, border: Border.all(color: outlineColor, width: 1), ), ); }); } }
Not bad! We hope you find this package useful, if you do, please let us know below!
You literally saved me so much time, thanks for sharing!
Excellent Contant, Thanks for sharing