button.dart 19.4 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 'theme.dart';
17
import 'theme_data.dart';
18

19 20
/// Creates a button based on [Semantics], [Material], and [InkWell]
/// widgets.
21
///
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
/// ### This class is obsolete.
///
/// Custom button classes can be created by configuring the
/// [ButtonStyle] of a [TextButton], [ElevatedButton] or an
/// [OutlinedButton].
///
/// FlatButton, RaisedButton, and OutlineButton have been replaced by
/// TextButton, ElevatedButton, and OutlinedButton respectively.
/// ButtonTheme has been replaced by TextButtonTheme,
/// ElevatedButtonTheme, and OutlinedButtonTheme. The original classes
/// will be deprecated soon, please migrate code that uses them.
/// There's a detailed migration guide for the new button and button
/// theme classes in
/// [flutter.dev/go/material-button-migration-guide](https://flutter.dev/go/material-button-migration-guide).
///
37 38 39 40
/// 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.
41
@Category(<String>['Material', 'Button'])
42 43 44
class RawMaterialButton extends StatefulWidget {
  /// Create a button based on [Semantics], [Material], and [InkWell] widgets.
  ///
45
  /// The [shape], [elevation], [focusElevation], [hoverElevation],
46 47 48
  /// [highlightElevation], [disabledElevation], [padding], [constraints],
  /// [autofocus], and [clipBehavior] arguments must not be null. Additionally,
  /// [elevation], [focusElevation], [hoverElevation], [highlightElevation], and
49
  /// [disabledElevation] must be non-negative.
50
  const RawMaterialButton({
51 52
    Key? key,
    required this.onPressed,
53
    this.onLongPress,
54
    this.onHighlightChanged,
55
    this.mouseCursor,
56 57
    this.textStyle,
    this.fillColor,
58 59
    this.focusColor,
    this.hoverColor,
60 61
    this.highlightColor,
    this.splashColor,
62
    this.elevation = 2.0,
63 64
    this.focusElevation = 4.0,
    this.hoverElevation = 4.0,
65 66 67
    this.highlightElevation = 8.0,
    this.disabledElevation = 0.0,
    this.padding = EdgeInsets.zero,
68
    this.visualDensity = VisualDensity.standard,
69 70 71
    this.constraints = const BoxConstraints(minWidth: 88.0, minHeight: 36.0),
    this.shape = const RoundedRectangleBorder(),
    this.animationDuration = kThemeChangeDuration,
72
    this.clipBehavior = Clip.none,
73
    this.focusNode,
74
    this.autofocus = false,
75
    MaterialTapTargetSize? materialTapTargetSize,
76
    this.child,
77
    this.enableFeedback = true,
78
  }) : materialTapTargetSize = materialTapTargetSize ?? MaterialTapTargetSize.padded,
79
       assert(shape != null),
80
       assert(elevation != null && elevation >= 0.0),
81 82
       assert(focusElevation != null && focusElevation >= 0.0),
       assert(hoverElevation != null && hoverElevation >= 0.0),
83 84
       assert(highlightElevation != null && highlightElevation >= 0.0),
       assert(disabledElevation != null && disabledElevation >= 0.0),
85 86
       assert(padding != null),
       assert(constraints != null),
87
       assert(animationDuration != null),
88
       assert(clipBehavior != null),
89
       assert(autofocus != null),
90 91 92 93
       super(key: key);

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

101 102 103 104 105 106 107
  /// 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.
108
  final VoidCallback? onLongPress;
109

110 111
  /// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
  /// callback.
112 113 114 115
  ///
  /// 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).
116
  final ValueChanged<bool>? onHighlightChanged;
117

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

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

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

149
  /// The color for the button's [Material] when it has the input focus.
150
  final Color? focusColor;
151 152

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

155
  /// The highlight color for the button's [InkWell].
156
  final Color? highlightColor;
157 158

  /// The splash color for the button's [InkWell].
159
  final Color? splashColor;
160 161 162

  /// The elevation for the button's [Material] when the button
  /// is [enabled] but not pressed.
163
  ///
164
  /// Defaults to 2.0. The value is always non-negative.
165 166 167 168
  ///
  /// See also:
  ///
  ///  * [highlightElevation], the default elevation.
169 170 171
  ///  * [hoverElevation], the elevation when a pointer is hovering over the
  ///    button.
  ///  * [focusElevation], the elevation when the button is focused.
172 173
  ///  * [disabledElevation], the elevation when the button is disabled.
  final double elevation;
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 204 205 206 207 208
  /// 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;

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

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

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

240 241 242 243 244 245 246 247 248 249
  /// 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;

250
  /// Defines the button's size.
251
  ///
252 253 254 255
  /// Typically used to constrain the button's minimum size.
  final BoxConstraints constraints;

  /// The shape of the button's [Material].
256
  ///
257 258
  /// 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.
259 260 261 262 263 264 265 266
  ///
  /// If [shape] is a [MaterialStateProperty<ShapeBorder>], [MaterialStateProperty.resolve]
  /// is used for the following [MaterialState]s:
  ///
  /// * [MaterialState.pressed].
  /// * [MaterialState.hovered].
  /// * [MaterialState.focused].
  /// * [MaterialState.disabled].
267 268
  final ShapeBorder shape;

269 270 271 272 273
  /// Defines the duration of animated changes for [shape] and [elevation].
  ///
  /// The default value is [kThemeChangeDuration].
  final Duration animationDuration;

274
  /// Typically the button's label.
275
  final Widget? child;
276 277 278 279

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

283 284 285 286 287 288
  /// Configures the minimum size of the tap target.
  ///
  /// Defaults to [MaterialTapTargetSize.padded].
  ///
  /// See also:
  ///
289
  ///  * [MaterialTapTargetSize], for a description of how this affects tap targets.
290 291
  final MaterialTapTargetSize materialTapTargetSize;

292
  /// {@macro flutter.widgets.Focus.focusNode}
293
  final FocusNode? focusNode;
294

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

298
  /// {@macro flutter.material.Material.clipBehavior}
299 300
  ///
  /// Defaults to [Clip.none], and must not be null.
301 302
  final Clip clipBehavior;

303 304 305 306 307 308 309 310 311 312
  /// 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;

313
  @override
314
  _RawMaterialButtonState createState() => _RawMaterialButtonState();
315 316 317
}

class _RawMaterialButtonState extends State<RawMaterialButton> {
318 319 320 321 322 323 324 325 326 327
  final Set<MaterialState> _states = <MaterialState>{};

  bool get _hovered => _states.contains(MaterialState.hovered);
  bool get _focused => _states.contains(MaterialState.focused);
  bool get _pressed => _states.contains(MaterialState.pressed);
  bool get _disabled => _states.contains(MaterialState.disabled);

  void _updateState(MaterialState state, bool value) {
    value ? _states.add(state) : _states.remove(state);
  }
328

329
  void _handleHighlightChanged(bool value) {
330
    if (_pressed != value) {
331
      setState(() {
332
        _updateState(MaterialState.pressed, value);
333
        widget.onHighlightChanged?.call(value);
334 335 336 337
      });
    }
  }

338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359
  void _handleHoveredChanged(bool value) {
    if (_hovered != value) {
      setState(() {
        _updateState(MaterialState.hovered, value);
      });
    }
  }

  void _handleFocusedChanged(bool value) {
    if (_focused != value) {
      setState(() {
        _updateState(MaterialState.focused, value);
      });
    }
  }

  @override
  void initState() {
    super.initState();
    _updateState(MaterialState.disabled, !widget.enabled);
  }

360 361 362
  @override
  void didUpdateWidget(RawMaterialButton oldWidget) {
    super.didUpdateWidget(oldWidget);
363 364 365 366 367 368 369
    _updateState(MaterialState.disabled, !widget.enabled);
    // 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.
    if (_disabled && _pressed) {
      _handleHighlightChanged(false);
370 371 372
    }
  }

373 374 375 376
  double get _effectiveElevation {
    // These conditionals are in order of precedence, so be careful about
    // reorganizing them.
    if (_disabled) {
377
      return widget.disabledElevation;
378
    }
379 380 381 382 383 384 385 386 387 388
    if (_pressed) {
      return widget.highlightElevation;
    }
    if (_hovered) {
      return widget.hoverElevation;
    }
    if (_focused) {
      return widget.focusElevation;
    }
    return widget.elevation;
389 390
  }

391
  @override
392
  Widget build(BuildContext context) {
393 394
    final Color? effectiveTextColor = MaterialStateProperty.resolveAs<Color?>(widget.textStyle?.color, _states);
    final ShapeBorder? effectiveShape =  MaterialStateProperty.resolveAs<ShapeBorder?>(widget.shape, _states);
395
    final Offset densityAdjustment = widget.visualDensity.baseSizeAdjustment;
396
    final BoxConstraints effectiveConstraints = widget.visualDensity.effectiveConstraints(widget.constraints);
397
    final MouseCursor? effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor?>(
398 399 400
      widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
      _states,
    );
401 402 403 404 405 406 407 408
    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);
409

410

411
    final Widget result = ConstrainedBox(
412
      constraints: effectiveConstraints,
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432
      child: Material(
        elevation: _effectiveElevation,
        textStyle: widget.textStyle?.copyWith(color: effectiveTextColor),
        shape: effectiveShape,
        color: widget.fillColor,
        type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button,
        animationDuration: widget.animationDuration,
        clipBehavior: widget.clipBehavior,
        child: InkWell(
          focusNode: widget.focusNode,
          canRequestFocus: widget.enabled,
          onFocusChange: _handleFocusedChanged,
          autofocus: widget.autofocus,
          onHighlightChanged: _handleHighlightChanged,
          splashColor: widget.splashColor,
          highlightColor: widget.highlightColor,
          focusColor: widget.focusColor,
          hoverColor: widget.hoverColor,
          onHover: _handleHoveredChanged,
          onTap: widget.onPressed,
433
          onLongPress: widget.onLongPress,
434
          enableFeedback: widget.enableFeedback,
435
          customBorder: effectiveShape,
436
          mouseCursor: effectiveMouseCursor,
437 438 439
          child: IconTheme.merge(
            data: IconThemeData(color: effectiveTextColor),
            child: Container(
440
              padding: padding,
441 442 443 444
              child: Center(
                widthFactor: 1.0,
                heightFactor: 1.0,
                child: widget.child,
445 446 447 448 449 450
              ),
            ),
          ),
        ),
      ),
    );
451
    final Size minSize;
452 453
    switch (widget.materialTapTargetSize) {
      case MaterialTapTargetSize.padded:
454 455 456 457 458 459
        minSize = Size(
          kMinInteractiveDimension + densityAdjustment.dx,
          kMinInteractiveDimension + densityAdjustment.dy,
        );
        assert(minSize.width >= 0.0);
        assert(minSize.height >= 0.0);
460 461
        break;
      case MaterialTapTargetSize.shrinkWrap:
462
        minSize = Size.zero;
463
        break;
464 465
    }

466
    return Semantics(
467 468 469
      container: true,
      button: true,
      enabled: widget.enabled,
470
      child: _InputPadding(
471 472 473
        minSize: minSize,
        child: result,
      ),
474
    );
475
  }
476 477
}

478
/// A widget to pad the area around a [MaterialButton]'s inner [Material].
479 480 481
///
/// 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
482 483 484
/// "tap target", but not its material or its ink splashes.
class _InputPadding extends SingleChildRenderObjectWidget {
  const _InputPadding({
485 486 487
    Key? key,
    Widget? child,
    required this.minSize,
488 489
  }) : super(key: key, child: child);

490
  final Size minSize;
491 492 493

  @override
  RenderObject createRenderObject(BuildContext context) {
494
    return _RenderInputPadding(minSize);
495 496 497
  }

  @override
498 499
  void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) {
    renderObject.minSize = minSize;
500 501 502
  }
}

503
class _RenderInputPadding extends RenderShiftedBox {
504
  _RenderInputPadding(this._minSize, [RenderBox? child]) : super(child);
505 506 507 508 509 510 511 512 513 514 515 516 517

  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)
518
      return math.max(child!.getMinIntrinsicWidth(height), minSize.width);
519 520 521 522 523 524
    return 0.0;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    if (child != null)
525
      return math.max(child!.getMinIntrinsicHeight(width), minSize.height);
526 527 528 529 530 531
    return 0.0;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    if (child != null)
532
      return math.max(child!.getMaxIntrinsicWidth(height), minSize.width);
533 534 535 536 537 538
    return 0.0;
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    if (child != null)
539
      return math.max(child!.getMaxIntrinsicHeight(width), minSize.height);
540 541 542
    return 0.0;
  }

543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560
  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,
    );
  }

561 562
  @override
  void performLayout() {
563 564 565 566
    size = _computeSize(
      constraints: constraints,
      layoutChild: ChildLayoutHelper.layoutChild,
    );
567
    if (child != null) {
568
      final BoxParentData childParentData = child!.parentData! as BoxParentData;
569
      childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset);
570 571
    }
  }
572 573

  @override
574
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
575 576 577
    if (super.hitTest(result, position: position)) {
      return true;
    }
578
    final Offset center = child!.size.center(Offset.zero);
579 580 581
    return result.addWithRawTransform(
      transform: MatrixUtils.forceToPoint(center),
      position: center,
582
      hitTest: (BoxHitTestResult result, Offset? position) {
583
        assert(position == center);
584
        return child!.hitTest(result, position: center);
585 586
      },
    );
587 588
  }
}