button.dart 19.1 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/gestures.dart';
9
import 'package:flutter/rendering.dart';
10
import 'package:flutter/widgets.dart';
11

12
import 'button_theme.dart';
13
import 'constants.dart';
14 15
import 'ink_well.dart';
import 'material.dart';
16
import 'material_state.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 27 28 29 30 31 32 33 34 35 36 37
/// ### 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).
///
38 39 40 41
/// 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.
42
@Category(<String>['Material', 'Button'])
43 44 45
class RawMaterialButton extends StatefulWidget {
  /// Create a button based on [Semantics], [Material], and [InkWell] widgets.
  ///
46
  /// The [shape], [elevation], [focusElevation], [hoverElevation],
47 48 49
  /// [highlightElevation], [disabledElevation], [padding], [constraints],
  /// [autofocus], and [clipBehavior] arguments must not be null. Additionally,
  /// [elevation], [focusElevation], [hoverElevation], [highlightElevation], and
50
  /// [disabledElevation] must be non-negative.
51
  const RawMaterialButton({
52 53
    Key? key,
    required this.onPressed,
54
    this.onLongPress,
55
    this.onHighlightChanged,
56
    this.mouseCursor,
57 58
    this.textStyle,
    this.fillColor,
59 60
    this.focusColor,
    this.hoverColor,
61 62
    this.highlightColor,
    this.splashColor,
63
    this.elevation = 2.0,
64 65
    this.focusElevation = 4.0,
    this.hoverElevation = 4.0,
66 67 68
    this.highlightElevation = 8.0,
    this.disabledElevation = 0.0,
    this.padding = EdgeInsets.zero,
69
    this.visualDensity = const VisualDensity(),
70 71 72
    this.constraints = const BoxConstraints(minWidth: 88.0, minHeight: 36.0),
    this.shape = const RoundedRectangleBorder(),
    this.animationDuration = kThemeChangeDuration,
73
    this.clipBehavior = Clip.none,
74
    this.focusNode,
75
    this.autofocus = false,
76
    MaterialTapTargetSize? materialTapTargetSize,
77
    this.child,
78
    this.enableFeedback = true,
79
  }) : materialTapTargetSize = materialTapTargetSize ?? MaterialTapTargetSize.padded,
80
       assert(shape != null),
81
       assert(elevation != null && elevation >= 0.0),
82 83
       assert(focusElevation != null && focusElevation >= 0.0),
       assert(hoverElevation != null && hoverElevation >= 0.0),
84 85
       assert(highlightElevation != null && highlightElevation >= 0.0),
       assert(disabledElevation != null && disabledElevation >= 0.0),
86 87
       assert(padding != null),
       assert(constraints != null),
88
       assert(animationDuration != null),
89
       assert(clipBehavior != null),
90
       assert(autofocus != null),
91 92 93 94
       super(key: key);

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

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

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

119 120 121
  /// {@template flutter.material.button.mouseCursor}
  /// The cursor for a mouse pointer when it enters or is hovering over the
  /// button.
122
  ///
123 124 125 126 127 128 129 130 131
  /// 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.
132
  /// {@endtemplate}
133
  final MouseCursor? mouseCursor;
134

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

class _RawMaterialButtonState extends State<RawMaterialButton> {
319 320 321 322 323 324 325 326 327 328
  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);
  }
329

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

341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362
  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);
  }

363 364 365
  @override
  void didUpdateWidget(RawMaterialButton oldWidget) {
    super.didUpdateWidget(oldWidget);
366 367 368 369 370 371 372
    _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);
373 374 375
    }
  }

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

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

413

414
    final Widget result = ConstrainedBox(
415
      constraints: effectiveConstraints,
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435
      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,
436
          onLongPress: widget.onLongPress,
437
          enableFeedback: widget.enableFeedback,
438
          customBorder: effectiveShape,
439
          mouseCursor: effectiveMouseCursor,
440 441 442
          child: IconTheme.merge(
            data: IconThemeData(color: effectiveTextColor),
            child: Container(
443
              padding: padding,
444 445 446 447
              child: Center(
                widthFactor: 1.0,
                heightFactor: 1.0,
                child: widget.child,
448 449 450 451 452 453
              ),
            ),
          ),
        ),
      ),
    );
454
    final Size minSize;
455 456
    switch (widget.materialTapTargetSize) {
      case MaterialTapTargetSize.padded:
457 458 459 460 461 462
        minSize = Size(
          kMinInteractiveDimension + densityAdjustment.dx,
          kMinInteractiveDimension + densityAdjustment.dy,
        );
        assert(minSize.width >= 0.0);
        assert(minSize.height >= 0.0);
463 464
        break;
      case MaterialTapTargetSize.shrinkWrap:
465
        minSize = Size.zero;
466
        break;
467 468
    }

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

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

493
  final Size minSize;
494 495 496

  @override
  RenderObject createRenderObject(BuildContext context) {
497
    return _RenderInputPadding(minSize);
498 499 500
  }

  @override
501 502
  void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) {
    renderObject.minSize = minSize;
503 504 505
  }
}

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

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

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

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

  @override
  double computeMaxIntrinsicHeight(double width) {
    if (child != null)
542
      return math.max(child!.getMaxIntrinsicHeight(width), minSize.height);
543 544 545 546 547 548
    return 0.0;
  }

  @override
  void performLayout() {
    if (child != null) {
549 550 551
      child!.layout(constraints, parentUsesSize: true);
      final double height = math.max(child!.size.width, minSize.width);
      final double width = math.max(child!.size.height, minSize.height);
552
      size = constraints.constrain(Size(height, width));
553
      final BoxParentData childParentData = child!.parentData! as BoxParentData;
554
      childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset);
555 556 557 558
    } else {
      size = Size.zero;
    }
  }
559 560

  @override
561
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
562 563 564
    if (super.hitTest(result, position: position)) {
      return true;
    }
565
    final Offset center = child!.size.center(Offset.zero);
566 567 568
    return result.addWithRawTransform(
      transform: MatrixUtils.forceToPoint(center),
      position: center,
569
      hitTest: (BoxHitTestResult result, Offset? position) {
570
        assert(position == center);
571
        return child!.hitTest(result, position: center);
572 573
      },
    );
574 575
  }
}