Flutter: Building custom controls with `FocusableActionDetector`

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!

Tab, Enter, Space, Taps all work as expected!

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 and isFocused 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!

shawn.blais

Shawn has worked as programmer and product designer for over 20 years, shipping several games on Steam and Playstation and dozens of apps on mobile. He has extensive programming experience with Dart, C#, ActionScript, SQL, PHP and Javascript and a deep proficiency with motion graphics and UX design. Shawn is currently the Technical Director for gskinner.

@tree_fortress

One Comment

  1. You literally saved me so much time, thanks for sharing!

Leave a Reply

Your email address will not be published. Required fields are marked *