button_style_button.dart 19.4 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math' as math;

import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

import 'button_style.dart';
import 'colors.dart';
import 'constants.dart';
import 'ink_well.dart';
import 'material.dart';
import 'material_state.dart';
17
import 'material_state_mixin.dart';
18 19 20 21 22 23 24 25 26
import 'theme_data.dart';

/// The base [StatefulWidget] class for buttons whose style is defined by a [ButtonStyle] object.
///
/// Concrete subclasses must override [defaultStyleOf] and [themeStyleOf].
///
/// See also:
///
///  * [TextButton], a simple ButtonStyleButton without a shadow.
27
///  * [ElevatedButton], a filled ButtonStyleButton whose material elevates when pressed.
28 29
///  * [OutlinedButton], similar to [TextButton], but with an outline.
abstract class ButtonStyleButton extends StatefulWidget {
30 31
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// const constructors so that they can be used in const expressions.
32
  const ButtonStyleButton({
33
    super.key,
34 35
    required this.onPressed,
    required this.onLongPress,
36 37
    required this.onHover,
    required this.onFocusChange,
38 39 40 41 42
    required this.style,
    required this.focusNode,
    required this.autofocus,
    required this.clipBehavior,
    required this.child,
43
  }) : assert(autofocus != null),
44
       assert(clipBehavior != null);
45 46 47 48 49 50 51 52

  /// Called when the button is tapped or otherwise activated.
  ///
  /// If this callback and [onLongPress] are null, then the button will be disabled.
  ///
  /// See also:
  ///
  ///  * [enabled], which is true if the button is enabled.
53
  final VoidCallback? onPressed;
54 55 56 57 58 59 60 61

  /// Called when the button is long-pressed.
  ///
  /// If this callback and [onPressed] are null, then the button will be disabled.
  ///
  /// See also:
  ///
  ///  * [enabled], which is true if the button is enabled.
62
  final VoidCallback? onLongPress;
63

64 65 66 67 68 69 70 71 72 73 74 75 76
  /// Called when a pointer enters or exits the button response area.
  ///
  /// The value passed to the callback is true if a pointer has entered this
  /// part of the material and false if a pointer has exited this part of the
  /// material.
  final ValueChanged<bool>? onHover;

  /// Handler called when the focus changes.
  ///
  /// Called with true if this widget's node gains focus, and false if it loses
  /// focus.
  final ValueChanged<bool>? onFocusChange;

77 78 79 80 81 82 83 84
  /// Customizes this button's appearance.
  ///
  /// Non-null properties of this style override the corresponding
  /// properties in [themeStyleOf] and [defaultStyleOf]. [MaterialStateProperty]s
  /// that resolve to non-null values will similarly override the corresponding
  /// [MaterialStateProperty]s in [themeStyleOf] and [defaultStyleOf].
  ///
  /// Null by default.
85
  final ButtonStyle? style;
86

87
  /// {@macro flutter.material.Material.clipBehavior}
88 89 90 91 92
  ///
  /// Defaults to [Clip.none], and must not be null.
  final Clip clipBehavior;

  /// {@macro flutter.widgets.Focus.focusNode}
93
  final FocusNode? focusNode;
94 95 96 97 98

  /// {@macro flutter.widgets.Focus.autofocus}
  final bool autofocus;

  /// Typically the button's label.
99
  final Widget? child;
100 101 102 103

  /// Returns a non-null [ButtonStyle] that's based primarily on the [Theme]'s
  /// [ThemeData.textTheme] and [ThemeData.colorScheme].
  ///
104
  /// The returned style can be overridden by the [style] parameter and
105
  /// by the style returned by [themeStyleOf]. For example the default
106
  /// style of the [TextButton] subclass can be overridden with its
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
  /// [TextButton.style] constructor parameter, or with a
  /// [TextButtonTheme].
  ///
  /// Concrete button subclasses should return a ButtonStyle that
  /// has no null properties, and where all of the [MaterialStateProperty]
  /// properties resolve to non-null values.
  ///
  /// See also:
  ///
  ///  * [themeStyleOf], Returns the ButtonStyle of this button's component theme.
  @protected
  ButtonStyle defaultStyleOf(BuildContext context);

  /// Returns the ButtonStyle that belongs to the button's component theme.
  ///
122
  /// The returned style can be overridden by the [style] parameter.
123 124 125 126 127 128 129 130 131
  ///
  /// Concrete button subclasses should return the ButtonStyle for the
  /// nearest subclass-specific inherited theme, and if no such theme
  /// exists, then the same value from the overall [Theme].
  ///
  /// See also:
  ///
  ///  * [defaultStyleOf], Returns the default [ButtonStyle] for this button.
  @protected
132
  ButtonStyle? themeStyleOf(BuildContext context);
133 134 135 136 137 138 139 140

  /// Whether the button is enabled or disabled.
  ///
  /// Buttons are disabled by default. To enable a button, set its [onPressed]
  /// or [onLongPress] properties to a non-null value.
  bool get enabled => onPressed != null || onLongPress != null;

  @override
141
  State<ButtonStyleButton> createState() => _ButtonStyleState();
142 143 144 145 146 147 148 149 150 151 152 153

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'disabled'));
    properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null));
    properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
  }

  /// Returns null if [value] is null, otherwise `MaterialStateProperty.all<T>(value)`.
  ///
  /// A convenience method for subclasses.
154
  static MaterialStateProperty<T>? allOrNull<T>(T? value) => value == null ? null : MaterialStateProperty.all<T>(value);
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179

  /// Returns an interpolated value based on the [textScaleFactor] parameter:
  ///
  ///  * 0 - 1 [geometry1x]
  ///  * 1 - 2 lerp([geometry1x], [geometry2x], [textScaleFactor] - 1)
  ///  * 2 - 3 lerp([geometry2x], [geometry3x], [textScaleFactor] - 2)
  ///  * otherwise [geometry3x]
  ///
  /// A convenience method for subclasses.
  static EdgeInsetsGeometry scaledPadding(
    EdgeInsetsGeometry geometry1x,
    EdgeInsetsGeometry geometry2x,
    EdgeInsetsGeometry geometry3x,
    double textScaleFactor,
  ) {
    assert(geometry1x != null);
    assert(geometry2x != null);
    assert(geometry3x != null);
    assert(textScaleFactor != null);

    if (textScaleFactor <= 1) {
      return geometry1x;
    } else if (textScaleFactor >= 3) {
      return geometry3x;
    } else if (textScaleFactor <= 2) {
180
      return EdgeInsetsGeometry.lerp(geometry1x, geometry2x, textScaleFactor - 1)!;
181
    }
182
    return EdgeInsetsGeometry.lerp(geometry2x, geometry3x, textScaleFactor - 2)!;
183 184 185 186 187 188 189 190 191
  }
}

/// The base [State] class for buttons whose style is defined by a [ButtonStyle] object.
///
/// See also:
///
///  * [ButtonStyleButton], the [StatefulWidget] subclass for which this class is the [State].
///  * [TextButton], a simple button without a shadow.
192
///  * [ElevatedButton], a filled button whose material elevates when pressed.
193
///  * [OutlinedButton], similar to [TextButton], but with an outline.
194
class _ButtonStyleState extends State<ButtonStyleButton> with MaterialStateMixin, TickerProviderStateMixin {
195 196 197
  AnimationController? _controller;
  double? _elevation;
  Color? _backgroundColor;
198 199 200 201

  @override
  void initState() {
    super.initState();
202
    setMaterialState(MaterialState.disabled, !widget.enabled);
203 204
  }

205 206 207 208 209 210
  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

211 212 213
  @override
  void didUpdateWidget(ButtonStyleButton oldWidget) {
    super.didUpdateWidget(oldWidget);
214
    setMaterialState(MaterialState.disabled, !widget.enabled);
215 216 217 218
    // If the button is disabled while a press gesture is currently ongoing,
    // InkWell makes a call to handleHighlightChanged. This causes an exception
    // because it calls setState in the middle of a build. To preempt this, we
    // manually update pressed to false when this situation occurs.
219 220
    if (isDisabled && isPressed) {
      removeMaterialState(MaterialState.pressed);
221 222 223 224 225
    }
  }

  @override
  Widget build(BuildContext context) {
226 227
    final ButtonStyle? widgetStyle = widget.style;
    final ButtonStyle? themeStyle = widget.themeStyleOf(context);
228 229 230
    final ButtonStyle defaultStyle = widget.defaultStyleOf(context);
    assert(defaultStyle != null);

231 232 233 234
    T? effectiveValue<T>(T? Function(ButtonStyle? style) getProperty) {
      final T? widgetValue  = getProperty(widgetStyle);
      final T? themeValue   = getProperty(themeStyle);
      final T? defaultValue = getProperty(defaultStyle);
235 236 237
      return widgetValue ?? themeValue ?? defaultValue;
    }

238
    T? resolve<T>(MaterialStateProperty<T>? Function(ButtonStyle? style) getProperty) {
239
      return effectiveValue(
240
        (ButtonStyle? style) => getProperty(style)?.resolve(materialStates),
241 242 243
      );
    }

244 245 246 247 248
    final double? resolvedElevation = resolve<double?>((ButtonStyle? style) => style?.elevation);
    final TextStyle? resolvedTextStyle = resolve<TextStyle?>((ButtonStyle? style) => style?.textStyle);
    Color? resolvedBackgroundColor = resolve<Color?>((ButtonStyle? style) => style?.backgroundColor);
    final Color? resolvedForegroundColor = resolve<Color?>((ButtonStyle? style) => style?.foregroundColor);
    final Color? resolvedShadowColor = resolve<Color?>((ButtonStyle? style) => style?.shadowColor);
249
    final Color? resolvedSurfaceTintColor = resolve<Color?>((ButtonStyle? style) => style?.surfaceTintColor);
250 251
    final EdgeInsetsGeometry? resolvedPadding = resolve<EdgeInsetsGeometry?>((ButtonStyle? style) => style?.padding);
    final Size? resolvedMinimumSize = resolve<Size?>((ButtonStyle? style) => style?.minimumSize);
252
    final Size? resolvedFixedSize = resolve<Size?>((ButtonStyle? style) => style?.fixedSize);
253
    final Size? resolvedMaximumSize = resolve<Size?>((ButtonStyle? style) => style?.maximumSize);
254 255
    final BorderSide? resolvedSide = resolve<BorderSide?>((ButtonStyle? style) => style?.side);
    final OutlinedBorder? resolvedShape = resolve<OutlinedBorder?>((ButtonStyle? style) => style?.shape);
256 257

    final MaterialStateMouseCursor resolvedMouseCursor = _MouseCursor(
258
      (Set<MaterialState> states) => effectiveValue((ButtonStyle? style) => style?.mouseCursor?.resolve(states)),
259 260
    );

261
    final MaterialStateProperty<Color?> overlayColor = MaterialStateProperty.resolveWith<Color?>(
262
      (Set<MaterialState> states) => effectiveValue((ButtonStyle? style) => style?.overlayColor?.resolve(states)),
263 264
    );

265 266 267 268
    final VisualDensity? resolvedVisualDensity = effectiveValue((ButtonStyle? style) => style?.visualDensity);
    final MaterialTapTargetSize? resolvedTapTargetSize = effectiveValue((ButtonStyle? style) => style?.tapTargetSize);
    final Duration? resolvedAnimationDuration = effectiveValue((ButtonStyle? style) => style?.animationDuration);
    final bool? resolvedEnableFeedback = effectiveValue((ButtonStyle? style) => style?.enableFeedback);
269
    final AlignmentGeometry? resolvedAlignment = effectiveValue((ButtonStyle? style) => style?.alignment);
270
    final Offset densityAdjustment = resolvedVisualDensity!.baseSizeAdjustment;
271
    final InteractiveInkFeatureFactory? resolvedSplashFactory = effectiveValue((ButtonStyle? style) => style?.splashFactory);
272 273

    BoxConstraints effectiveConstraints = resolvedVisualDensity.effectiveConstraints(
274
      BoxConstraints(
275
        minWidth: resolvedMinimumSize!.width,
276
        minHeight: resolvedMinimumSize.height,
277 278
        maxWidth: resolvedMaximumSize!.width,
        maxHeight: resolvedMaximumSize.height,
279 280
      ),
    );
281 282 283 284 285 286 287 288 289 290 291
    if (resolvedFixedSize != null) {
      final Size size = effectiveConstraints.constrain(resolvedFixedSize);
      if (size.width.isFinite) {
        effectiveConstraints = effectiveConstraints.copyWith(
          minWidth: size.width,
          maxWidth: size.width,
        );
      }
      if (size.height.isFinite) {
        effectiveConstraints = effectiveConstraints.copyWith(
          minHeight: size.height,
292
          maxHeight: size.height,
293 294 295 296
        );
      }
    }

297 298 299 300 301 302 303 304 305
    // Per the Material Design team: don't allow the VisualDensity
    // adjustment to reduce the width of the left/right padding. If we
    // did, VisualDensity.compact, the default for desktop/web, would
    // reduce the horizontal padding to zero.
    final double dy = densityAdjustment.dy;
    final double dx = math.max(0, densityAdjustment.dx);
    final EdgeInsetsGeometry padding = resolvedPadding!
      .add(EdgeInsets.fromLTRB(dx, dy, dx, dy))
      .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity);
306

307 308 309 310
    // If an opaque button's background is becoming translucent while its
    // elevation is changing, change the elevation first. Material implicitly
    // animates its elevation but not its color. SKIA renders non-zero
    // elevations as a shadow colored fill behind the Material's background.
311
    if (resolvedAnimationDuration! > Duration.zero
312 313 314
        && _elevation != null
        && _backgroundColor != null
        && _elevation != resolvedElevation
315 316
        && _backgroundColor!.value != resolvedBackgroundColor!.value
        && _backgroundColor!.opacity == 1
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331
        && resolvedBackgroundColor.opacity < 1
        && resolvedElevation == 0) {
      if (_controller?.duration != resolvedAnimationDuration) {
        _controller?.dispose();
        _controller = AnimationController(
          duration: resolvedAnimationDuration,
          vsync: this,
        )
        ..addStatusListener((AnimationStatus status) {
          if (status == AnimationStatus.completed) {
            setState(() { }); // Rebuild with the final background color.
          }
        });
      }
      resolvedBackgroundColor = _backgroundColor; // Defer changing the background color.
332 333
      _controller!.value = 0;
      _controller!.forward();
334 335 336 337
    }
    _elevation = resolvedElevation;
    _backgroundColor = resolvedBackgroundColor;

338 339 340
    final Widget result = ConstrainedBox(
      constraints: effectiveConstraints,
      child: Material(
341
        elevation: resolvedElevation!,
342
        textStyle: resolvedTextStyle?.copyWith(color: resolvedForegroundColor),
343
        shape: resolvedShape!.copyWith(side: resolvedSide),
344 345
        color: resolvedBackgroundColor,
        shadowColor: resolvedShadowColor,
346
        surfaceTintColor: resolvedSurfaceTintColor,
347 348 349 350 351 352
        type: resolvedBackgroundColor == null ? MaterialType.transparency : MaterialType.button,
        animationDuration: resolvedAnimationDuration,
        clipBehavior: widget.clipBehavior,
        child: InkWell(
          onTap: widget.onPressed,
          onLongPress: widget.onLongPress,
353
          onHighlightChanged: updateMaterialState(MaterialState.pressed),
354 355 356 357
          onHover: updateMaterialState(
            MaterialState.hovered,
            onChanged: widget.onHover,
          ),
358 359 360 361
          mouseCursor: resolvedMouseCursor,
          enableFeedback: resolvedEnableFeedback,
          focusNode: widget.focusNode,
          canRequestFocus: widget.enabled,
362 363 364 365
          onFocusChange: updateMaterialState(
            MaterialState.focused,
            onChanged: widget.onFocusChange,
          ),
366
          autofocus: widget.autofocus,
367
          splashFactory: resolvedSplashFactory,
368 369 370 371 372 373 374
          overlayColor: overlayColor,
          highlightColor: Colors.transparent,
          customBorder: resolvedShape,
          child: IconTheme.merge(
            data: IconThemeData(color: resolvedForegroundColor),
            child: Padding(
              padding: padding,
375 376
              child: Align(
                alignment: resolvedAlignment!,
377 378 379 380 381 382 383 384 385 386
                widthFactor: 1.0,
                heightFactor: 1.0,
                child: widget.child,
              ),
            ),
          ),
        ),
      ),
    );

387
    final Size minSize;
388
    switch (resolvedTapTargetSize!) {
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416
      case MaterialTapTargetSize.padded:
        minSize = Size(
          kMinInteractiveDimension + densityAdjustment.dx,
          kMinInteractiveDimension + densityAdjustment.dy,
        );
        assert(minSize.width >= 0.0);
        assert(minSize.height >= 0.0);
        break;
      case MaterialTapTargetSize.shrinkWrap:
        minSize = Size.zero;
        break;
    }

    return Semantics(
      container: true,
      button: true,
      enabled: widget.enabled,
      child: _InputPadding(
        minSize: minSize,
        child: result,
      ),
    );
  }
}

class _MouseCursor extends MaterialStateMouseCursor {
  const _MouseCursor(this.resolveCallback);

417
  final MaterialPropertyResolver<MouseCursor?> resolveCallback;
418 419

  @override
420
  MouseCursor resolve(Set<MaterialState> states) => resolveCallback(states)!;
421 422 423 424 425

  @override
  String get debugDescription => 'ButtonStyleButton_MouseCursor';
}

426
/// A widget to pad the area around a [ButtonStyleButton]'s inner [Material].
427 428 429 430 431 432
///
/// Redirect taps that occur in the padded area around the child to the center
/// of the child. This increases the size of the button and the button's
/// "tap target", but not its material or its ink splashes.
class _InputPadding extends SingleChildRenderObjectWidget {
  const _InputPadding({
433
    super.child,
434
    required this.minSize,
435
  });
436 437 438 439 440 441 442 443 444 445 446 447 448 449 450

  final Size minSize;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderInputPadding(minSize);
  }

  @override
  void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) {
    renderObject.minSize = minSize;
  }
}

class _RenderInputPadding extends RenderShiftedBox {
451
  _RenderInputPadding(this._minSize, [RenderBox? child]) : super(child);
452 453 454 455 456 457 458 459 460 461 462 463 464

  Size get minSize => _minSize;
  Size _minSize;
  set minSize(Size value) {
    if (_minSize == value)
      return;
    _minSize = value;
    markNeedsLayout();
  }

  @override
  double computeMinIntrinsicWidth(double height) {
    if (child != null)
465
      return math.max(child!.getMinIntrinsicWidth(height), minSize.width);
466 467 468 469 470 471
    return 0.0;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    if (child != null)
472
      return math.max(child!.getMinIntrinsicHeight(width), minSize.height);
473 474 475 476 477 478
    return 0.0;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    if (child != null)
479
      return math.max(child!.getMaxIntrinsicWidth(height), minSize.width);
480 481 482 483 484 485
    return 0.0;
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    if (child != null)
486
      return math.max(child!.getMaxIntrinsicHeight(width), minSize.height);
487 488 489
    return 0.0;
  }

490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507
  Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) {
    if (child != null) {
      final Size childSize = layoutChild(child!, constraints);
      final double height = math.max(childSize.width, minSize.width);
      final double width = math.max(childSize.height, minSize.height);
      return constraints.constrain(Size(height, width));
    }
    return Size.zero;
  }

  @override
  Size computeDryLayout(BoxConstraints constraints) {
    return _computeSize(
      constraints: constraints,
      layoutChild: ChildLayoutHelper.dryLayoutChild,
    );
  }

508 509
  @override
  void performLayout() {
510 511 512 513
    size = _computeSize(
      constraints: constraints,
      layoutChild: ChildLayoutHelper.layoutChild,
    );
514
    if (child != null) {
515
      final BoxParentData childParentData = child!.parentData! as BoxParentData;
516
      childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset);
517 518 519 520
    }
  }

  @override
521
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
522 523 524
    if (super.hitTest(result, position: position)) {
      return true;
    }
525
    final Offset center = child!.size.center(Offset.zero);
526 527 528
    return result.addWithRawTransform(
      transform: MatrixUtils.forceToPoint(center),
      position: center,
529
      hitTest: (BoxHitTestResult result, Offset position) {
530
        assert(position == center);
531
        return child!.hitTest(result, position: center);
532 533 534 535
      },
    );
  }
}