How to disable a button

It sounds like a trivial task: you want to disable a button based on condition. For example: there is a login screen, in which the user needs to enter username and password. As long as one of the textfields is empty, the user should be signaled that it’s pointless to tap the login button by showing it in a disabled state.

So basically we want something like this:

How a disabled button could work

Intuitively, you would probably search for a constructor argument named activated, deactivated, enabled, disabled or something like that. Actually, there is none. However, there is a required argument, the Flutter team decided to give two purposes:

Buttons are disabled by default. To enable a button, set its onPressed or onLongPress properties to a non-null value.

This is from the documentation of the enabled property of MaterialButtons. So the callback functions have the purpose of providing a callback when the respective action happens but also disable the button if the given argument is null.

The easy but not-so-intuitive way

So we need to do something likes this:

FlatButton(
  onPressed: _bothTextFieldsContainText() ? null : () => _performLogin,
  child: Text('Login')
);

Where _bothTextFieldsContainText() returns true if login and password contain at least one character and _performLogin executes the login procedure.

In my opinion, the caller needs to take care of something that should be an internal logic of the button. To me, the button being in disabled state when the callback is null, is a side-effect and disregards the SRP. One responsibility is to provide the callback, the other one is the UI-specific change that occurs in the absence of that property.
I also try to avoid the ternary operator whenever I can because it takes a little more time in my brain to process than a separate function with a classic if statement.

The not-so-easy but more intuitive way (for the caller)

Let’s provide a widget in which you can inject a button that expects a property isDeactivated. So it should be usable like this:

DeactivatableButton(
  isDeactivated: !_bothTextFieldsContainText(),
  child: RaisedButton(
    ...
  ),
);

That’s cool because we can inject any type of MaterialButton. If we were to extend an existing button type like FlatButton, RaisedButton or OutlineButton, it would only work for these kinds.

What do we have to do? We have to create a new class that extends StatelessWidget (as the state of the deactivated button is controlled by the caller). We then return a MaterialButton that returns null for onPressed and onLongPressed as long as the isDeactivated property is set:

class DeactivatableButton extends StatelessWidget {
  DeactivatableButton({
    Key key,
    @required this.child,
    @required this.isDeactivated,
  }) : super(key: key);

  final MaterialButton child;
  final bool isDeactivated;

  @override
  Widget build(BuildContext context) {
    return MaterialButton(
      onPressed: isDeactivated ? null : child.onPressed,
      onLongPress: isDeactivated ? null : child.onLongPress,
      onHighlightChanged: child.onHighlightChanged,
      textTheme: child.textTheme,
      textColor: child.textColor,
      disabledTextColor: child.disabledTextColor,
      color: child.color,
      disabledColor: child.disabledColor,
      focusColor: child.focusColor,
      hoverColor: child.hoverColor,
      highlightColor: child.highlightColor,
      splashColor: child.splashColor,
      colorBrightness: child.colorBrightness,
      elevation: child.elevation,
      focusElevation: child.focusElevation,
      hoverElevation: child.hoverElevation,
      highlightElevation: child.highlightElevation,
      disabledElevation: child.disabledElevation,
      padding: child.padding,
      visualDensity: child.visualDensity,
      shape: child.shape,
      clipBehavior: child.clipBehavior,
      focusNode: child.focusNode,
      autofocus: child.autofocus,
      materialTapTargetSize: child.materialTapTargetSize,
      animationDuration: child.animationDuration,
      minWidth: child.minWidth,
      height: child.height,
      enableFeedback: child.enableFeedback,
      child: child.child,
    );
  }
}

Wow, that’s a lot of code for such a simple functionality. That’s because MaterialButton’s constructor has lots of optional arguments. We want every argument to be like the child ones except for onPressed and onLongPressed as they control the disabled state.

Before typing

The button is disabled

After started typing

The button is enabled

The styling of the disabled state can be altered using disabledColor, disabledTextColor and disabledElevation. So if for some reason we would want the disabled state to be with blue background color and white text color, we could do it like this:

DeactivatableButton(
  isDeactivated: !_isButtonActivated,
  child: RaisedButton(
    textColor: Colors.white,
    color: Colors.red,
    disabledColor: Colors.blue,
    disabledTextColor: Colors.white,
    onPressed: () {
      ...
    },
    child: Text('OK'),
  ),
);

Extending the functionality by giving feedback

Now, what if we have the above situation and we would want the user to see a snackbar with an error message if he were to tap the disabled button?
Let’s extend our newly created widget by an argument called onTapWhenDeactivated. This argument expects a callback function that is executed when the user taps the disabled button.

  DeactivatableButton({
    Key key,
    @required this.child,
    @required this.isDeactivated,
    this.onTapWhenDeactivated =  _returnNull,
  }) : super(key: key);

  final MaterialButton child;
  final bool isDeactivated;
  final Function onTapWhenDeactivated;

  static _returnNull() => null;

  @override
  Widget build(BuildContext context) {
    return MaterialButton(
      onPressed: isDeactivated ? onTapWhenDeactivated : child.onPressed,
      onLongPress: isDeactivated ? onTapWhenDeactivated : child.onLongPress,
      ...
  );
}

Okay now there’s a new parameter which defaults to _returnNull which is a static function. Why have I done it this way and not just written this.onTapWhenDeactivated = const (() => null); ?
It’s because the Dart keyword const is used for object instantiation for values that are already being set at compile time. This is only supported for the following values:

  • Primitive types (int, bool, …)
  • Literal values (e.g. string literal: ‘this is a string literal’)
  • Constant constructors

So no mentioning of functions there. Although there is a respective github issue that’s been open since 2008. That’s why we need to use static functions because they don’t change at runtime.

If we try the above mentioned code we realize: yes, the onTapWhenDeactivated callback is called when the user taps the disabled button, but the disabled button does not look disabled. That’s because like I said above, MaterialButton only declares its state as disabled when both of the callbacks (onPressed, onLongPress) are set to null. So we need to mimic that behavior by setting the corresponding values to the disabled values when isDeactivated is true.
This affects the following attributes:

  • textColor
  • color
  • splashColor
  • highlightColor
  • highlightElevation
  • elevation

So our new widget version looks like this:

  @override
  Widget build(BuildContext context) {
    return MaterialButton(
      onPressed: isDeactivated ? onTapWhenDeactivated : child.onPressed,
      onLongPress: isDeactivated ? onTapWhenDeactivated : child.onLongPress,
      onHighlightChanged: child.onHighlightChanged,
      textTheme: child.textTheme,
      textColor: _getTextColor(),
      disabledTextColor: child.disabledTextColor,
      color: _getColor(),
      disabledColor: child.disabledColor,
      focusColor: child.focusColor,
      hoverColor: child.hoverColor,
      highlightColor: _getHighlightColor(),
      splashColor: _getSplashColor(),
      colorBrightness: child.colorBrightness,
      elevation: _getElevation(),
      focusElevation: child.focusElevation,
      hoverElevation: child.hoverElevation,
      highlightElevation: _getHighlightElevation(),
      disabledElevation: child.disabledElevation,
      padding: child.padding,
      visualDensity: child.visualDensity,
      shape: child.shape,
      clipBehavior: child.clipBehavior,
      focusNode: child.focusNode,
      autofocus: child.autofocus,
      materialTapTargetSize: child.materialTapTargetSize,
      animationDuration: child.animationDuration,
      minWidth: child.minWidth,
      height: child.height,
      enableFeedback: child.enableFeedback,
      child: child.child,
    );
  }

  double _getElevation() {
    return isDeactivated ? child.disabledElevation : child.elevation;
  }

  double _getHighlightElevation() {
    return isDeactivated ? child.disabledElevation : child.highlightElevation;
  }

  Color _getHighlightColor() {
    return isDeactivated ? Colors.transparent : child.highlightColor;
  }

  Color _getSplashColor() {
    return isDeactivated ? Colors.transparent : child.splashColor;
  }

  Color _getTextColor() {
    if (isDeactivated) {
      return child.disabledTextColor;
    }
    return child.textColor;
  }

  Color _getColor() {
    if (isDeactivated) {
      return child.disabledColor;
    }
    return child.color;
  }

Result

And this is what the final result looks like (after tapping the deactivated button):

A snackbar indicating that the button can not be tapped

Caveat

You have probably noticed that we had to touch many arguments of the MaterialButton constructor. This is a little bit flaky. When the API of that class changes in a new version of Flutter, it could be possible that our widget does not work anymore. At least not the way it used to work. Also, Flutter seemingly wants to handle the disabled state by using the nullability of the callbacks. Everything that goes in a different direction might be a problem in the future.

Conclusion

We have discussed two ways of implementing a button that can be disabled. The first sets the callbacks to null when the state is supposed to be disabled. The second one is creating a widget hat expects a button, an isDisabled argument and the behavior that is executed when somebody taps the disabled button.

Depending on how you want to embed the button, which parts of the behavior you want to control and how near you want to stay to the MaterialButton implementation, you can choose between the two approaches.

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

3 thoughts on “How to disable a button”

Leave a Comment