button.dart 18.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 '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
///
/// This class is planned to be deprecated in a future release, see
29 30
/// [ButtonStyleButton], the base class of [ElevatedButton], [FilledButton],
/// [OutlinedButton] and [TextButton].
31 32 33 34
///
/// See also:
///
///  * [ElevatedButton], a filled button whose material elevates when pressed.
35 36 37 38
///  * [FilledButton], a filled button that doesn't elevate when pressed.
///  * [FilledButton.tonal], a filled button variant that uses a secondary fill color.
///  * [OutlinedButton], a button with an outlined border and no fill color.
///  * [TextButton], a button with no outline or fill color.
39
@Category(<String>['Material', 'Button'])
40 41 42
class RawMaterialButton extends StatefulWidget {
  /// Create a button based on [Semantics], [Material], and [InkWell] widgets.
  ///
43
  /// The [shape], [elevation], [focusElevation], [hoverElevation],
44 45 46
  /// [highlightElevation], [disabledElevation], [padding], [constraints],
  /// [autofocus], and [clipBehavior] arguments must not be null. Additionally,
  /// [elevation], [focusElevation], [hoverElevation], [highlightElevation], and
47
  /// [disabledElevation] must be non-negative.
48
  const RawMaterialButton({
49
    super.key,
50
    required this.onPressed,
51
    this.onLongPress,
52
    this.onHighlightChanged,
53
    this.mouseCursor,
54 55
    this.textStyle,
    this.fillColor,
56 57
    this.focusColor,
    this.hoverColor,
58 59
    this.highlightColor,
    this.splashColor,
60
    this.elevation = 2.0,
61 62
    this.focusElevation = 4.0,
    this.hoverElevation = 4.0,
63 64 65
    this.highlightElevation = 8.0,
    this.disabledElevation = 0.0,
    this.padding = EdgeInsets.zero,
66
    this.visualDensity = VisualDensity.standard,
67 68 69
    this.constraints = const BoxConstraints(minWidth: 88.0, minHeight: 36.0),
    this.shape = const RoundedRectangleBorder(),
    this.animationDuration = kThemeChangeDuration,
70
    this.clipBehavior = Clip.none,
71
    this.focusNode,
72
    this.autofocus = false,
73
    MaterialTapTargetSize? materialTapTargetSize,
74
    this.child,
75
    this.enableFeedback = true,
76
  }) : materialTapTargetSize = materialTapTargetSize ?? MaterialTapTargetSize.padded,
77 78 79 80 81
       assert(elevation >= 0.0),
       assert(focusElevation >= 0.0),
       assert(hoverElevation >= 0.0),
       assert(highlightElevation >= 0.0),
       assert(disabledElevation >= 0.0);
82 83 84

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

92 93 94 95 96 97 98
  /// 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.
99
  final VoidCallback? onLongPress;
100

101 102
  /// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged]
  /// callback.
103 104 105 106
  ///
  /// 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).
107
  final ValueChanged<bool>? onHighlightChanged;
108

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

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

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

140
  /// The color for the button's [Material] when it has the input focus.
141
  final Color? focusColor;
142 143

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

146
  /// The highlight color for the button's [InkWell].
147
  final Color? highlightColor;
148 149

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

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

166 167 168 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
  /// 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;

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

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

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

231 232 233 234 235 236 237 238 239 240
  /// 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;

241
  /// Defines the button's size.
242
  ///
243 244 245 246
  /// Typically used to constrain the button's minimum size.
  final BoxConstraints constraints;

  /// The shape of the button's [Material].
247
  ///
248 249
  /// 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.
250 251 252 253 254 255 256 257
  ///
  /// If [shape] is a [MaterialStateProperty<ShapeBorder>], [MaterialStateProperty.resolve]
  /// is used for the following [MaterialState]s:
  ///
  /// * [MaterialState.pressed].
  /// * [MaterialState.hovered].
  /// * [MaterialState.focused].
  /// * [MaterialState.disabled].
258 259
  final ShapeBorder shape;

260 261 262 263 264
  /// Defines the duration of animated changes for [shape] and [elevation].
  ///
  /// The default value is [kThemeChangeDuration].
  final Duration animationDuration;

265
  /// Typically the button's label.
266
  final Widget? child;
267 268 269 270

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

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

283
  /// {@macro flutter.widgets.Focus.focusNode}
284
  final FocusNode? focusNode;
285

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

289
  /// {@macro flutter.material.Material.clipBehavior}
290 291
  ///
  /// Defaults to [Clip.none], and must not be null.
292 293
  final Clip clipBehavior;

294 295 296 297 298 299 300 301 302 303
  /// 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;

304
  @override
305
  State<RawMaterialButton> createState() => _RawMaterialButtonState();
306 307
}

308
class _RawMaterialButtonState extends State<RawMaterialButton> with MaterialStateMixin {
309 310 311 312

  @override
  void initState() {
    super.initState();
313
    setMaterialState(MaterialState.disabled, !widget.enabled);
314 315
  }

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

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

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

366

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

424
    return Semantics(
425 426 427
      container: true,
      button: true,
      enabled: widget.enabled,
428
      child: _InputPadding(
429 430 431
        minSize: minSize,
        child: result,
      ),
432
    );
433
  }
434 435
}

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

447
  final Size minSize;
448 449 450

  @override
  RenderObject createRenderObject(BuildContext context) {
451
    return _RenderInputPadding(minSize);
452 453 454
  }

  @override
455 456
  void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) {
    renderObject.minSize = minSize;
457 458 459
  }
}

460
class _RenderInputPadding extends RenderShiftedBox {
461
  _RenderInputPadding(this._minSize, [RenderBox? child]) : super(child);
462 463 464 465

  Size get minSize => _minSize;
  Size _minSize;
  set minSize(Size value) {
466
    if (_minSize == value) {
467
      return;
468
    }
469 470 471 472 473 474
    _minSize = value;
    markNeedsLayout();
  }

  @override
  double computeMinIntrinsicWidth(double height) {
475
    if (child != null) {
476
      return math.max(child!.getMinIntrinsicWidth(height), minSize.width);
477
    }
478 479 480 481 482
    return 0.0;
  }

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

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

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

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

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

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