button.dart 18.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:math' as math;

7
import 'package:flutter/foundation.dart';
8
import 'package:flutter/rendering.dart';
9
import 'package:flutter/widgets.dart';
10

11
import 'button_theme.dart';
12
import 'constants.dart';
13 14
import 'ink_well.dart';
import 'material.dart';
15
import 'material_state.dart';
16
import 'material_state_mixin.dart';
17
import 'theme.dart';
18
import 'theme_data.dart';
19

20 21
/// Creates a button based on [Semantics], [Material], and [InkWell]
/// widgets.
22
///
23 24 25 26
/// This class does not use the current [Theme] or [ButtonTheme] to
/// compute default values for unspecified parameters. It's intended to
/// be used for custom Material buttons that optionally incorporate defaults
/// from the themes or from app-specific sources.
27 28 29 30 31 32 33 34 35 36
///
/// This class is planned to be deprecated in a future release, see
/// [ButtonStyleButton], the base class of [TextButton], [ElevatedButton], and
/// [OutlinedButton].
///
/// See also:
///
///  * [TextButton], a simple flat button without a shadow.
///  * [ElevatedButton], a filled button whose material elevates when pressed.
///  * [OutlinedButton], a [TextButton] with a border outline.
37
@Category(<String>['Material', 'Button'])
38 39 40
class RawMaterialButton extends StatefulWidget {
  /// Create a button based on [Semantics], [Material], and [InkWell] widgets.
  ///
41
  /// The [shape], [elevation], [focusElevation], [hoverElevation],
42 43 44
  /// [highlightElevation], [disabledElevation], [padding], [constraints],
  /// [autofocus], and [clipBehavior] arguments must not be null. Additionally,
  /// [elevation], [focusElevation], [hoverElevation], [highlightElevation], and
45
  /// [disabledElevation] must be non-negative.
46
  const RawMaterialButton({
47
    super.key,
48
    required this.onPressed,
49
    this.onLongPress,
50
    this.onHighlightChanged,
51
    this.mouseCursor,
52 53
    this.textStyle,
    this.fillColor,
54 55
    this.focusColor,
    this.hoverColor,
56 57
    this.highlightColor,
    this.splashColor,
58
    this.elevation = 2.0,
59 60
    this.focusElevation = 4.0,
    this.hoverElevation = 4.0,
61 62 63
    this.highlightElevation = 8.0,
    this.disabledElevation = 0.0,
    this.padding = EdgeInsets.zero,
64
    this.visualDensity = VisualDensity.standard,
65 66 67
    this.constraints = const BoxConstraints(minWidth: 88.0, minHeight: 36.0),
    this.shape = const RoundedRectangleBorder(),
    this.animationDuration = kThemeChangeDuration,
68
    this.clipBehavior = Clip.none,
69
    this.focusNode,
70
    this.autofocus = false,
71
    MaterialTapTargetSize? materialTapTargetSize,
72
    this.child,
73
    this.enableFeedback = true,
74
  }) : materialTapTargetSize = materialTapTargetSize ?? MaterialTapTargetSize.padded,
75
       assert(shape != null),
76
       assert(elevation != null && elevation >= 0.0),
77 78
       assert(focusElevation != null && focusElevation >= 0.0),
       assert(hoverElevation != null && hoverElevation >= 0.0),
79 80
       assert(highlightElevation != null && highlightElevation >= 0.0),
       assert(disabledElevation != null && disabledElevation >= 0.0),
81 82
       assert(padding != null),
       assert(constraints != null),
83
       assert(animationDuration != null),
84
       assert(clipBehavior != null),
85
       assert(autofocus != null);
86 87 88

  /// Called when the button is tapped or otherwise activated.
  ///
89 90 91 92 93
  /// If this callback and [onLongPress] are null, then the button will be disabled.
  ///
  /// See also:
  ///
  ///  * [enabled], which is true if the button is enabled.
94
  final VoidCallback? onPressed;
95

96 97 98 99 100 101 102
  /// 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.
103
  final VoidCallback? onLongPress;
104

105 106
  /// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
  /// callback.
107 108 109 110
  ///
  /// If [onPressed] changes from null to non-null while a gesture is ongoing,
  /// this can fire during the build phase (in which case calling
  /// [State.setState] is not allowed).
111
  final ValueChanged<bool>? onHighlightChanged;
112

113
  /// {@template flutter.material.RawMaterialButton.mouseCursor}
114 115
  /// The cursor for a mouse pointer when it enters or is hovering over the
  /// button.
116
  ///
117 118 119 120 121 122 123
  /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
  /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s:
  ///
  ///  * [MaterialState.pressed].
  ///  * [MaterialState.hovered].
  ///  * [MaterialState.focused].
  ///  * [MaterialState.disabled].
124
  /// {@endtemplate}
125 126
  ///
  /// If this property is null, [MaterialStateMouseCursor.clickable] will be used.
127
  final MouseCursor? mouseCursor;
128

129 130
  /// Defines the default text style, with [Material.textStyle], for the
  /// button's [child].
131
  ///
132
  /// If [TextStyle.color] is a [MaterialStateProperty<Color>], [MaterialStateProperty.resolve]
133 134 135 136 137 138
  /// is used for the following [MaterialState]s:
  ///
  ///  * [MaterialState.pressed].
  ///  * [MaterialState.hovered].
  ///  * [MaterialState.focused].
  ///  * [MaterialState.disabled].
139
  final TextStyle? textStyle;
140 141

  /// The color of the button's [Material].
142
  final Color? fillColor;
143

144
  /// The color for the button's [Material] when it has the input focus.
145
  final Color? focusColor;
146 147

  /// The color for the button's [Material] when a pointer is hovering over it.
148
  final Color? hoverColor;
149

150
  /// The highlight color for the button's [InkWell].
151
  final Color? highlightColor;
152 153

  /// The splash color for the button's [InkWell].
154
  final Color? splashColor;
155 156 157

  /// The elevation for the button's [Material] when the button
  /// is [enabled] but not pressed.
158
  ///
159
  /// Defaults to 2.0. The value is always non-negative.
160 161 162 163
  ///
  /// See also:
  ///
  ///  * [highlightElevation], the default elevation.
164 165 166
  ///  * [hoverElevation], the elevation when a pointer is hovering over the
  ///    button.
  ///  * [focusElevation], the elevation when the button is focused.
167 168
  ///  * [disabledElevation], the elevation when the button is disabled.
  final double elevation;
169

170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203
  /// The elevation for the button's [Material] when the button
  /// is [enabled] and a pointer is hovering over it.
  ///
  /// Defaults to 4.0. The value is always non-negative.
  ///
  /// If the button is [enabled], and being pressed (in the highlighted state),
  /// then the [highlightElevation] take precedence over the [hoverElevation].
  ///
  /// See also:
  ///
  ///  * [elevation], the default elevation.
  ///  * [focusElevation], the elevation when the button is focused.
  ///  * [disabledElevation], the elevation when the button is disabled.
  ///  * [highlightElevation], the elevation when the button is pressed.
  final double hoverElevation;

  /// The elevation for the button's [Material] when the button
  /// is [enabled] and has the input focus.
  ///
  /// Defaults to 4.0. The value is always non-negative.
  ///
  /// If the button is [enabled], and being pressed (in the highlighted state),
  /// or a mouse cursor is hovering over the button, then the [hoverElevation]
  /// and [highlightElevation] take precedence over the [focusElevation].
  ///
  /// See also:
  ///
  ///  * [elevation], the default elevation.
  ///  * [hoverElevation], the elevation when a pointer is hovering over the
  ///    button.
  ///  * [disabledElevation], the elevation when the button is disabled.
  ///  * [highlightElevation], the elevation when the button is pressed.
  final double focusElevation;

204 205
  /// The elevation for the button's [Material] when the button
  /// is [enabled] and pressed.
206
  ///
207
  /// Defaults to 8.0. The value is always non-negative.
208 209 210 211
  ///
  /// See also:
  ///
  ///  * [elevation], the default elevation.
212
  ///  * [hoverElevation], the elevation when a pointer is hovering over the
213
  ///    button.
214
  ///  * [focusElevation], the elevation when the button is focused.
215 216
  ///  * [disabledElevation], the elevation when the button is disabled.
  final double highlightElevation;
217

218 219
  /// The elevation for the button's [Material] when the button
  /// is not [enabled].
220
  ///
221
  /// Defaults to 0.0. The value is always non-negative.
222
  ///
223 224
  /// See also:
  ///
225
  ///  * [elevation], the default elevation.
226
  ///  * [hoverElevation], the elevation when a pointer is hovering over the
227
  ///    button.
228
  ///  * [focusElevation], the elevation when the button is focused.
229 230 231 232
  ///  * [highlightElevation], the elevation when the button is pressed.
  final double disabledElevation;

  /// The internal padding for the button's [child].
233
  final EdgeInsetsGeometry padding;
234

235 236 237 238 239 240 241 242 243 244
  /// Defines how compact the button's layout will be.
  ///
  /// {@macro flutter.material.themedata.visualDensity}
  ///
  /// See also:
  ///
  ///  * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets
  ///    within a [Theme].
  final VisualDensity visualDensity;

245
  /// Defines the button's size.
246
  ///
247 248 249 250
  /// Typically used to constrain the button's minimum size.
  final BoxConstraints constraints;

  /// The shape of the button's [Material].
251
  ///
252 253
  /// The button's highlight and splash are clipped to this shape. If the
  /// button has an elevation, then its drop shadow is defined by this shape.
254 255 256 257 258 259 260 261
  ///
  /// If [shape] is a [MaterialStateProperty<ShapeBorder>], [MaterialStateProperty.resolve]
  /// is used for the following [MaterialState]s:
  ///
  /// * [MaterialState.pressed].
  /// * [MaterialState.hovered].
  /// * [MaterialState.focused].
  /// * [MaterialState.disabled].
262 263
  final ShapeBorder shape;

264 265 266 267 268
  /// Defines the duration of animated changes for [shape] and [elevation].
  ///
  /// The default value is [kThemeChangeDuration].
  final Duration animationDuration;

269
  /// Typically the button's label.
270
  final Widget? child;
271 272 273 274

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

278 279 280 281 282 283
  /// Configures the minimum size of the tap target.
  ///
  /// Defaults to [MaterialTapTargetSize.padded].
  ///
  /// See also:
  ///
284
  ///  * [MaterialTapTargetSize], for a description of how this affects tap targets.
285 286
  final MaterialTapTargetSize materialTapTargetSize;

287
  /// {@macro flutter.widgets.Focus.focusNode}
288
  final FocusNode? focusNode;
289

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

293
  /// {@macro flutter.material.Material.clipBehavior}
294 295
  ///
  /// Defaults to [Clip.none], and must not be null.
296 297
  final Clip clipBehavior;

298 299 300 301 302 303 304 305 306 307
  /// Whether detected gestures should provide acoustic and/or haptic feedback.
  ///
  /// For example, on Android a tap will produce a clicking sound and a
  /// long-press will produce a short vibration, when feedback is enabled.
  ///
  /// See also:
  ///
  ///  * [Feedback] for providing platform-specific feedback to certain actions.
  final bool enableFeedback;

308
  @override
309
  State<RawMaterialButton> createState() => _RawMaterialButtonState();
310 311
}

312
class _RawMaterialButtonState extends State<RawMaterialButton> with MaterialStateMixin {
313 314 315 316

  @override
  void initState() {
    super.initState();
317
    setMaterialState(MaterialState.disabled, !widget.enabled);
318 319
  }

320 321 322
  @override
  void didUpdateWidget(RawMaterialButton oldWidget) {
    super.didUpdateWidget(oldWidget);
323
    setMaterialState(MaterialState.disabled, !widget.enabled);
324 325 326 327
    // 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.
328 329
    if (isDisabled && isPressed) {
      removeMaterialState(MaterialState.pressed);
330 331 332
    }
  }

333 334 335
  double get _effectiveElevation {
    // These conditionals are in order of precedence, so be careful about
    // reorganizing them.
336
    if (isDisabled) {
337
      return widget.disabledElevation;
338
    }
339
    if (isPressed) {
340 341
      return widget.highlightElevation;
    }
342
    if (isHovered) {
343 344
      return widget.hoverElevation;
    }
345
    if (isFocused) {
346 347 348
      return widget.focusElevation;
    }
    return widget.elevation;
349 350
  }

351
  @override
352
  Widget build(BuildContext context) {
353 354
    final Color? effectiveTextColor = MaterialStateProperty.resolveAs<Color?>(widget.textStyle?.color, materialStates);
    final ShapeBorder? effectiveShape =  MaterialStateProperty.resolveAs<ShapeBorder?>(widget.shape, materialStates);
355
    final Offset densityAdjustment = widget.visualDensity.baseSizeAdjustment;
356
    final BoxConstraints effectiveConstraints = widget.visualDensity.effectiveConstraints(widget.constraints);
357
    final MouseCursor? effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor?>(
358
      widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
359
      materialStates,
360
    );
361 362 363 364 365 366 367 368
    final EdgeInsetsGeometry padding = widget.padding.add(
      EdgeInsets.only(
        left: densityAdjustment.dx,
        top: densityAdjustment.dy,
        right: densityAdjustment.dx,
        bottom: densityAdjustment.dy,
      ),
    ).clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity);
369

370

371
    final Widget result = ConstrainedBox(
372
      constraints: effectiveConstraints,
373 374 375 376 377
      child: Material(
        elevation: _effectiveElevation,
        textStyle: widget.textStyle?.copyWith(color: effectiveTextColor),
        shape: effectiveShape,
        color: widget.fillColor,
378 379
        // For compatibility during the M3 migration the default shadow needs to be passed.
        shadowColor: Theme.of(context).useMaterial3 ? Theme.of(context).shadowColor : null,
380 381 382 383 384 385
        type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button,
        animationDuration: widget.animationDuration,
        clipBehavior: widget.clipBehavior,
        child: InkWell(
          focusNode: widget.focusNode,
          canRequestFocus: widget.enabled,
386
          onFocusChange: updateMaterialState(MaterialState.focused),
387
          autofocus: widget.autofocus,
388
          onHighlightChanged: updateMaterialState(MaterialState.pressed, onChanged: widget.onHighlightChanged),
389 390 391 392
          splashColor: widget.splashColor,
          highlightColor: widget.highlightColor,
          focusColor: widget.focusColor,
          hoverColor: widget.hoverColor,
393
          onHover: updateMaterialState(MaterialState.hovered),
394
          onTap: widget.onPressed,
395
          onLongPress: widget.onLongPress,
396
          enableFeedback: widget.enableFeedback,
397
          customBorder: effectiveShape,
398
          mouseCursor: effectiveMouseCursor,
399 400 401
          child: IconTheme.merge(
            data: IconThemeData(color: effectiveTextColor),
            child: Container(
402
              padding: padding,
403 404 405 406
              child: Center(
                widthFactor: 1.0,
                heightFactor: 1.0,
                child: widget.child,
407 408 409 410 411 412
              ),
            ),
          ),
        ),
      ),
    );
413
    final Size minSize;
414 415
    switch (widget.materialTapTargetSize) {
      case MaterialTapTargetSize.padded:
416 417 418 419 420 421
        minSize = Size(
          kMinInteractiveDimension + densityAdjustment.dx,
          kMinInteractiveDimension + densityAdjustment.dy,
        );
        assert(minSize.width >= 0.0);
        assert(minSize.height >= 0.0);
422 423
        break;
      case MaterialTapTargetSize.shrinkWrap:
424
        minSize = Size.zero;
425
        break;
426 427
    }

428
    return Semantics(
429 430 431
      container: true,
      button: true,
      enabled: widget.enabled,
432
      child: _InputPadding(
433 434 435
        minSize: minSize,
        child: result,
      ),
436
    );
437
  }
438 439
}

440
/// A widget to pad the area around a [MaterialButton]'s inner [Material].
441 442 443
///
/// 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
444 445 446
/// "tap target", but not its material or its ink splashes.
class _InputPadding extends SingleChildRenderObjectWidget {
  const _InputPadding({
447
    super.child,
448
    required this.minSize,
449
  });
450

451
  final Size minSize;
452 453 454

  @override
  RenderObject createRenderObject(BuildContext context) {
455
    return _RenderInputPadding(minSize);
456 457 458
  }

  @override
459 460
  void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) {
    renderObject.minSize = minSize;
461 462 463
  }
}

464
class _RenderInputPadding extends RenderShiftedBox {
465
  _RenderInputPadding(this._minSize, [RenderBox? child]) : super(child);
466 467 468 469 470 471 472 473 474 475 476 477 478

  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)
479
      return math.max(child!.getMinIntrinsicWidth(height), minSize.width);
480 481 482 483 484 485
    return 0.0;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    if (child != null)
486
      return math.max(child!.getMinIntrinsicHeight(width), minSize.height);
487 488 489 490 491 492
    return 0.0;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    if (child != null)
493
      return math.max(child!.getMaxIntrinsicWidth(height), minSize.width);
494 495 496 497 498 499
    return 0.0;
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    if (child != null)
500
      return math.max(child!.getMaxIntrinsicHeight(width), minSize.height);
501 502 503
    return 0.0;
  }

504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521
  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,
    );
  }

522 523
  @override
  void performLayout() {
524 525 526 527
    size = _computeSize(
      constraints: constraints,
      layoutChild: ChildLayoutHelper.layoutChild,
    );
528
    if (child != null) {
529
      final BoxParentData childParentData = child!.parentData! as BoxParentData;
530
      childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset);
531 532
    }
  }
533 534

  @override
535
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
536 537 538
    if (super.hitTest(result, position: position)) {
      return true;
    }
539
    final Offset center = child!.size.center(Offset.zero);
540 541 542
    return result.addWithRawTransform(
      transform: MatrixUtils.forceToPoint(center),
      position: center,
543
      hitTest: (BoxHitTestResult result, Offset position) {
544
        assert(position == center);
545
        return child!.hitTest(result, position: center);
546 547
      },
    );
548 549
  }
}