
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
ActivateIntentmapping - maintaining the
isHoveredandisFocusedstate - 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