• Hans Muller's avatar
    Added ButtonStyle.foregroundBuilder and ButtonStyle.backgroundBuilder (#141818) · ff6c8f5d
    Hans Muller authored
    Fixes https://github.com/flutter/flutter/issues/139456, https://github.com/flutter/flutter/issues/130335, https://github.com/flutter/flutter/issues/89563.
    
    Two new properties have been added to ButtonStyle to make it possible to insert arbitrary state-dependent widgets in a button's background or foreground. These properties can be specified for an individual button, using the style parameter, or for all buttons using a button theme's style parameter.
    
    The new ButtonStyle properties are `backgroundBuilder` and `foregroundBuilder` and their (function) types are:
    
    ```dart
    typedef ButtonLayerBuilder = Widget Function(
      BuildContext context,
      Set<MaterialState> states,
      Widget? child
    );
    ```
    
    The new builder functions are called whenever the button is built and the `states` parameter communicates the pressed/hovered/etc state fo the button.
    
    ## `backgroundBuilder`
    
    Creates a widget that becomes the child of the button's Material and whose child is the rest of the button, including the button's `child` parameter.  By default the returned widget is clipped to the Material's ButtonStyle.shape.
    
    The `backgroundBuilder` can be used to add a gradient to the button's background. Here's an example that creates a yellow/orange gradient background:
    
    ![opaque-gradient-bg](https://github.com/flutter/flutter/assets/1377460/80df8368-e7cf-49ef-aee7-2776a573644c)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        backgroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          return DecoratedBox(
            decoration: BoxDecoration(
              gradient: LinearGradient(colors: [Colors.orange, Colors.yellow]),
            ),
            child: child,
          );
        },
      ),
      child: Text('Text Button'),
    )
    ```
    
    Because the background widget becomes the child of the button's Material, if it's opaque (as it is in this case) then it obscures the overlay highlights which are painted on the button's Material. To ensure that the highlights show through one can decorate the background with an `Ink` widget.  This version also overrides the overlay color to be (shades of) red, because that makes the highlights look a little nicer with the yellow/orange background.
    
    ![ink-gradient-bg](https://github.com/flutter/flutter/assets/1377460/68a49733-f30e-44a1-a948-dc8cc95e1716)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        overlayColor: Colors.red,
        backgroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          return Ink(
            decoration: BoxDecoration(
              gradient: LinearGradient(colors: [Colors.orange, Colors.yellow]),
            ),
            child: child,
          );
        },
      ),
      child: Text('Text Button'),
    )
    ```
    
    Now the button's overlay highlights are painted on the Ink widget. An Ink widget isn't needed if the background is sufficiently translucent. This version of the example creates a translucent backround widget. 
    
    ![translucent-graident-bg](https://github.com/flutter/flutter/assets/1377460/3b016e1f-200a-4d07-8111-e20d29f18014)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        overlayColor: Colors.red,
        backgroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          return DecoratedBox(
            decoration: BoxDecoration(
              gradient: LinearGradient(colors: [
                Colors.orange.withOpacity(0.5),
                Colors.yellow.withOpacity(0.5),
              ]),
            ),
            child: child,
          );
        },
      ),
      child: Text('Text Button'),
    )
    ```
    
    One can also decorate the background with an image. In this example, the button's background is an burlap texture image. The foreground color has been changed to black to make the button's text a little clearer relative to the mottled brown backround.
    
    ![burlap-bg](https://github.com/flutter/flutter/assets/1377460/f2f61ab1-10d9-43a4-bd63-beecdce33b45)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        foregroundColor: Colors.black,
        backgroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          return Ink(
            decoration: BoxDecoration(
              image: DecorationImage(
                image: NetworkImage(burlapUrl),
                fit: BoxFit.cover,
              ),
            ),
            child: child,
          );
        },
      ),
      child: Text('Text Button'),
    )
    ```
    
    The background widget can depend on the `states` parameter. In this example the blue/orange gradient flips horizontally when the button is hovered/pressed.
    
    ![gradient-flip](https://github.com/flutter/flutter/assets/1377460/c6c6fe26-ae47-445b-b82d-4605d9583bd8)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        backgroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          final Color color1 = Colors.blue.withOpacity(0.5);
          final Color color2 = Colors.orange.withOpacity(0.5);
          return DecoratedBox(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                colors: switch (states.contains(MaterialState.hovered)) {
                  true => <Color>[color1, color2],
                  false => <Color>[color2, color1],
                },
              ),
            ),
            child: child,
          );
        },
      ),
      child: Text('Text Button'),
    )
    ```
    
    The preceeding examples have not included a BoxDecoration border because ButtonStyle already supports `ButtonStyle.shape` and `ButtonStyle.side` parameters that can be uesd to define state-dependent borders. Borders defined with the ButtonStyle side parameter match the button's shape. To add a border that changes color when the button is hovered or pressed, one must specify the side property using `copyWith`, since there's no `styleFrom` shorthand for this case.
    
    ![border-gradient-bg](https://github.com/flutter/flutter/assets/1377460/63cffcd3-0dcf-4eb1-aed5-d14adf1e57f6)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        foregroundColor: Colors.indigo,
        backgroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          final Color color1 = Colors.blue.withOpacity(0.5);
          final Color color2 = Colors.orange.withOpacity(0.5);
          return DecoratedBox(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                colors: switch (states.contains(MaterialState.hovered)) {
                  true => <Color>[color1, color2],
                  false => <Color>[color2, color1],
                },
              ),
            ),
            child: child,
          );
        },
      ).copyWith(
        side: MaterialStateProperty.resolveWith<BorderSide?>((Set<MaterialState> states) {
          if (states.contains(MaterialState.hovered)) {
            return BorderSide(width: 3, color: Colors.yellow);
          }
          return null; // defer to the default
        }),
      ),
      child: Text('Text Button'),
    )
    ```
    
    Although all of the examples have created a ButtonStyle locally and only applied it to one button, they could have configured the `ThemeData.textButtonTheme` instead and applied the style to all TextButtons. And, of course, all of this works for all of the ButtonStyleButton classes, not just TextButton.
    
    ## `foregroundBuilder`
    
    Creates a Widget that contains the button's child parameter. The returned widget is clipped by the button's [ButtonStyle.shape] inset by the button's [ButtonStyle.padding] and aligned by the button's [ButtonStyle.alignment].
    
    The `foregroundBuilder` can be used to wrap the button's child, e.g. with a border or a `ShaderMask` or as a state-dependent substitute for the child.
    
    This example adds a border that's just applied to the child. The border only appears when the button is hovered/pressed.
    
    ![border-fg](https://github.com/flutter/flutter/assets/1377460/687a3245-fe68-4983-a04e-5fcc77f8aa21)
    
    ```dart
    ElevatedButton(
      onPressed: () {},
      style: ElevatedButton.styleFrom(
        foregroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          final ColorScheme colorScheme = Theme.of(context).colorScheme;
          return DecoratedBox(
            decoration: BoxDecoration(
              border: states.contains(MaterialState.hovered)
                ? Border(bottom: BorderSide(color: colorScheme.primary))
                : Border(), // essentially "no border"
            ),
            child: child,
          );
        },
      ),
      child: Text('Text Button'),
    )
    ```
    
    The foregroundBuilder can be used with `ShaderMask` to change the way the button's child is rendered. In this example the ShaderMask's gradient causes the button's child to fade out on top.
    
    ![shader_mask_fg](https://github.com/flutter/flutter/assets/1377460/54010f24-e65d-4551-ae58-712135df3d8d)
    
    ```dart
    ElevatedButton(
      onPressed: () { },
      style: ElevatedButton.styleFrom(
        foregroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          final ColorScheme colorScheme = Theme.of(context).colorScheme;
          return ShaderMask(
            shaderCallback: (Rect bounds) {
              return LinearGradient(
                begin: Alignment.bottomCenter,
                end: Alignment.topCenter,
                colors: <Color>[
                  colorScheme.primary,
                  colorScheme.primaryContainer,
                ],
              ).createShader(bounds);
            },
            blendMode: BlendMode.srcATop,
            child: child,
          );
        },
      ),
      child:  const Text('Elevated Button'),
    )
    ```
    
    A commonly requested configuration for butttons has the developer provide images, one for pressed/hovered/normal state. You can use the foregroundBuilder to create a button that fades between a normal image and another image when the button is pressed. In this case the foregroundBuilder doesn't use the child it's passed, even though we've provided the required TextButton child parameter.
    
    ![image-button](https://github.com/flutter/flutter/assets/1377460/f5b1a22f-43ce-4be3-8e70-06de4c958380)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        foregroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          final String url = states.contains(MaterialState.pressed) ? smiley2Url : smiley1Url;
          return AnimatedContainer(
            width: 100,
            height: 100,
            duration: Duration(milliseconds: 300),
            decoration: BoxDecoration(
              image: DecorationImage(
                image: NetworkImage(url),
                fit: BoxFit.contain,
              ),
            ),
          );
        },
      ),
      child: Text('No Child'),
    )
    ```
    
    In this example the button's default overlay appears when the button is hovered and pressed. Another image can be used to indicate the hovered state and the default overlay can be defeated by specifying `Colors.transparent` for the `overlayColor`:
    
    ![image-per-state](https://github.com/flutter/flutter/assets/1377460/7ab9da2f-f661-4374-b395-c2e0c7c4cf13)
    
    ```dart
    TextButton(
      onPressed: () {},
      style: TextButton.styleFrom(
        overlayColor: Colors.transparent,
        foregroundBuilder: (BuildContext context, Set<MaterialState> states, Widget? child) {
          String url = states.contains(MaterialState.hovered) ? smiley3Url : smiley1Url;
          if (states.contains(MaterialState.pressed)) {
            url = smiley2Url;
          }
          return AnimatedContainer(
            width: 100,
            height: 100,
            duration: Duration(milliseconds: 300),
            decoration: BoxDecoration(
              image: DecorationImage(
                image: NetworkImage(url),
                fit: BoxFit.contain,
              ),
            ),
          );
        },
      ),
      child: Text('No Child'),
    )
    ```
    ff6c8f5d
button_style.dart 25.1 KB