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

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

import 'button_theme.dart';
import 'colors.dart';
10
import 'material_button.dart';
11
import 'material_state.dart';
12 13
import 'raised_button.dart';
import 'theme.dart';
14
import 'theme_data.dart';
15 16

// The total time to make the button's fill color opaque and change
17
// its elevation. Only applies when highlightElevation > 0.0.
18
const Duration _kPressDuration = Duration(milliseconds: 150);
19 20

// Half of _kPressDuration: just the time to change the button's
21
// elevation. Only applies when highlightElevation > 0.0.
22
const Duration _kElevationDuration = Duration(milliseconds: 75);
23

24
/// Similar to a [FlatButton] with a thin grey rounded rectangle border.
25
///
26 27 28 29 30
/// The outline button's border shape is defined by [shape]
/// and its appearance is defined by [borderSide], [disabledBorderColor],
/// and [highlightedBorderColor]. By default the border is a one pixel
/// wide grey rounded rectangle that does not change when the button is
/// pressed or disabled. By default the button's background is transparent.
31
///
32
/// If the [onPressed] or [onLongPress] callbacks are null, then the button will be disabled and by
33 34
/// default will resemble a flat button in the [disabledColor].
///
35 36 37 38 39 40 41
/// The button's [highlightElevation], which defines the size of the
/// drop shadow when the button is pressed, is 0.0 (no shadow) by default.
/// If [highlightElevation] is given a value greater than 0.0 then the button
/// becomes a cross between [RaisedButton] and [FlatButton]: a bordered
/// button whose elevation increases and whose background becomes opaque
/// when the button is pressed.
///
42 43 44
/// If you want an ink-splash effect for taps, but don't want to use a button,
/// consider using [InkWell] directly.
///
45
/// Outline buttons have a minimum size of 88.0 by 36.0 which can be overridden
46 47 48 49 50 51 52 53 54 55
/// with [ButtonTheme].
///
/// See also:
///
///  * [RaisedButton], a filled material design button with a shadow.
///  * [FlatButton], a material design button without a shadow.
///  * [DropdownButton], a button that shows options to select from.
///  * [FloatingActionButton], the round button in material applications.
///  * [IconButton], to create buttons that just contain icons.
///  * [InkWell], which implements the ink splash part of a flat button.
56
///  * <https://material.io/design/components/buttons.html>
57
class OutlineButton extends MaterialButton {
58
  /// Create an outline button.
59
  ///
60
  /// The [highlightElevation] argument must be null or a positive value
61
  /// and the [autofocus] and [clipBehavior] arguments must not be null.
62 63
  const OutlineButton({
    Key key,
64
    @required VoidCallback onPressed,
65
    VoidCallback onLongPress,
66 67 68 69
    ButtonTextTheme textTheme,
    Color textColor,
    Color disabledTextColor,
    Color color,
70 71
    Color focusColor,
    Color hoverColor,
72 73 74
    Color highlightColor,
    Color splashColor,
    double highlightElevation,
75 76 77
    this.borderSide,
    this.disabledBorderColor,
    this.highlightedBorderColor,
78
    EdgeInsetsGeometry padding,
79
    VisualDensity visualDensity,
80
    ShapeBorder shape,
81
    Clip clipBehavior = Clip.none,
82
    FocusNode focusNode,
83
    bool autofocus = false,
84 85
    Widget child,
  }) : assert(highlightElevation == null || highlightElevation >= 0.0),
86
       assert(clipBehavior != null),
87
       assert(autofocus != null),
88 89 90
       super(
         key: key,
         onPressed: onPressed,
91
         onLongPress: onLongPress,
92 93 94 95
         textTheme: textTheme,
         textColor: textColor,
         disabledTextColor: disabledTextColor,
         color: color,
96 97
         focusColor: focusColor,
         hoverColor: hoverColor,
98 99 100 101
         highlightColor: highlightColor,
         splashColor: splashColor,
         highlightElevation: highlightElevation,
         padding: padding,
102
         visualDensity: visualDensity,
103 104
         shape: shape,
         clipBehavior: clipBehavior,
105
         focusNode: focusNode,
106
         autofocus: autofocus,
107 108
         child: child,
       );
109 110 111 112 113 114 115

  /// Create an outline button from a pair of widgets that serve as the button's
  /// [icon] and [label].
  ///
  /// The icon and label are arranged in a row and padded by 12 logical pixels
  /// at the start, and 16 at the end, with an 8 pixel gap in between.
  ///
116
  /// The [highlightElevation] argument must be null or a positive value. The
117
  /// [icon], [label], [autofocus], and [clipBehavior] arguments must not be null.
118
  factory OutlineButton.icon({
119
    Key key,
120
    @required VoidCallback onPressed,
121
    VoidCallback onLongPress,
122 123 124 125
    ButtonTextTheme textTheme,
    Color textColor,
    Color disabledTextColor,
    Color color,
126 127
    Color focusColor,
    Color hoverColor,
128 129 130 131 132 133 134
    Color highlightColor,
    Color splashColor,
    double highlightElevation,
    Color highlightedBorderColor,
    Color disabledBorderColor,
    BorderSide borderSide,
    EdgeInsetsGeometry padding,
135
    VisualDensity visualDensity,
136 137
    ShapeBorder shape,
    Clip clipBehavior,
138
    FocusNode focusNode,
139
    bool autofocus,
140 141
    @required Widget icon,
    @required Widget label,
142
  }) = _OutlineButtonWithIcon;
143 144 145

  /// The outline border's color when the button is [enabled] and pressed.
  ///
146 147
  /// By default the border's color does not change when the button
  /// is pressed.
148 149
  ///
  /// This field is ignored if [borderSide.color] is a [MaterialStateProperty<Color>].
150 151 152 153
  final Color highlightedBorderColor;

  /// The outline border's color when the button is not [enabled].
  ///
154 155
  /// By default the outline border's color does not change when the
  /// button is disabled.
156 157
  ///
  /// This field is ignored if [borderSide.color] is a [MaterialStateProperty<Color>].
158 159
  final Color disabledBorderColor;

160 161
  /// Defines the color of the border when the button is enabled but not
  /// pressed, and the border outline's width and style in general.
162
  ///
163 164
  /// If the border side's [BorderSide.style] is [BorderStyle.none], then
  /// an outline is not drawn.
165
  ///
166
  /// If null the default border's style is [BorderStyle.solid], its
167
  /// [BorderSide.width] is 1.0, and its color is a light shade of grey.
168 169 170 171
  ///
  /// If [borderSide.color] is a [MaterialStateProperty<Color>], [MaterialStateProperty.resolve]
  /// is used in all states and both [highlightedBorderColor] and [disabledBorderColor]
  /// are ignored.
172
  final BorderSide borderSide;
173 174

  @override
175 176 177 178
  Widget build(BuildContext context) {
    final ButtonThemeData buttonTheme = ButtonTheme.of(context);
    return _OutlineButton(
      onPressed: onPressed,
179
      onLongPress: onLongPress,
180 181 182 183 184
      brightness: buttonTheme.getBrightness(this),
      textTheme: textTheme,
      textColor: buttonTheme.getTextColor(this),
      disabledTextColor: buttonTheme.getDisabledTextColor(this),
      color: color,
185 186
      focusColor: buttonTheme.getFocusColor(this),
      hoverColor: buttonTheme.getHoverColor(this),
187 188 189 190 191 192 193
      highlightColor: buttonTheme.getHighlightColor(this),
      splashColor: buttonTheme.getSplashColor(this),
      highlightElevation: buttonTheme.getHighlightElevation(this),
      borderSide: borderSide,
      disabledBorderColor: disabledBorderColor,
      highlightedBorderColor: highlightedBorderColor ?? buttonTheme.colorScheme.primary,
      padding: buttonTheme.getPadding(this),
194
      visualDensity: visualDensity,
195 196
      shape: buttonTheme.getShape(this),
      clipBehavior: clipBehavior,
197
      focusNode: focusNode,
198 199 200
      child: child,
    );
  }
201 202

  @override
203 204
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
205
    properties.add(DiagnosticsProperty<BorderSide>('borderSide', borderSide, defaultValue: null));
206 207
    properties.add(ColorProperty('disabledBorderColor', disabledBorderColor, defaultValue: null));
    properties.add(ColorProperty('highlightedBorderColor', highlightedBorderColor, defaultValue: null));
208 209 210
  }
}

Shi-Hao Hong's avatar
Shi-Hao Hong committed
211
// The type of OutlineButtons created with OutlineButton.icon.
212
//
213 214
// This class only exists to give OutlineButtons created with OutlineButton.icon
// a distinct class for the sake of ButtonTheme. It can not be instantiated.
215
class _OutlineButtonWithIcon extends OutlineButton with MaterialButtonWithIconMixin {
216 217 218
  _OutlineButtonWithIcon({
    Key key,
    @required VoidCallback onPressed,
219
    VoidCallback onLongPress,
220 221 222 223
    ButtonTextTheme textTheme,
    Color textColor,
    Color disabledTextColor,
    Color color,
224 225
    Color focusColor,
    Color hoverColor,
226 227 228 229 230 231 232
    Color highlightColor,
    Color splashColor,
    double highlightElevation,
    Color highlightedBorderColor,
    Color disabledBorderColor,
    BorderSide borderSide,
    EdgeInsetsGeometry padding,
233
    VisualDensity visualDensity,
234
    ShapeBorder shape,
235
    Clip clipBehavior = Clip.none,
236
    FocusNode focusNode,
237
    bool autofocus = false,
238 239 240
    @required Widget icon,
    @required Widget label,
  }) : assert(highlightElevation == null || highlightElevation >= 0.0),
241
       assert(clipBehavior != null),
242
       assert(autofocus != null),
243 244 245 246 247
       assert(icon != null),
       assert(label != null),
       super(
         key: key,
         onPressed: onPressed,
248
         onLongPress: onLongPress,
249 250 251 252
         textTheme: textTheme,
         textColor: textColor,
         disabledTextColor: disabledTextColor,
         color: color,
253 254
         focusColor: focusColor,
         hoverColor: hoverColor,
255 256 257 258 259 260 261
         highlightColor: highlightColor,
         splashColor: splashColor,
         highlightElevation: highlightElevation,
         disabledBorderColor: disabledBorderColor,
         highlightedBorderColor: highlightedBorderColor,
         borderSide: borderSide,
         padding: padding,
262
         visualDensity: visualDensity,
263 264
         shape: shape,
         clipBehavior: clipBehavior,
265
         focusNode: focusNode,
266
         autofocus: autofocus,
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
         child: Row(
           mainAxisSize: MainAxisSize.min,
           children: <Widget>[
             icon,
             const SizedBox(width: 8.0),
             label,
           ],
         ),
       );
}

class _OutlineButton extends StatefulWidget {
  const _OutlineButton({
    Key key,
    @required this.onPressed,
282
    this.onLongPress,
283 284 285 286 287
    this.brightness,
    this.textTheme,
    this.textColor,
    this.disabledTextColor,
    this.color,
288 289
    this.focusColor,
    this.hoverColor,
290 291 292 293 294 295 296
    this.highlightColor,
    this.splashColor,
    @required this.highlightElevation,
    this.borderSide,
    this.disabledBorderColor,
    @required this.highlightedBorderColor,
    this.padding,
297
    this.visualDensity,
298
    this.shape,
299
    this.clipBehavior = Clip.none,
300
    this.focusNode,
301
    this.autofocus = false,
302 303 304
    this.child,
  }) : assert(highlightElevation != null && highlightElevation >= 0.0),
       assert(highlightedBorderColor != null),
305
       assert(clipBehavior != null),
306
       assert(autofocus != null),
307 308 309
       super(key: key);

  final VoidCallback onPressed;
310
  final VoidCallback onLongPress;
311 312 313 314 315 316
  final Brightness brightness;
  final ButtonTextTheme textTheme;
  final Color textColor;
  final Color disabledTextColor;
  final Color color;
  final Color splashColor;
317 318
  final Color focusColor;
  final Color hoverColor;
319 320 321 322 323 324
  final Color highlightColor;
  final double highlightElevation;
  final BorderSide borderSide;
  final Color disabledBorderColor;
  final Color highlightedBorderColor;
  final EdgeInsetsGeometry padding;
325
  final VisualDensity visualDensity;
326 327
  final ShapeBorder shape;
  final Clip clipBehavior;
328
  final FocusNode focusNode;
329
  final bool autofocus;
330 331
  final Widget child;

332
  bool get enabled => onPressed != null || onLongPress != null;
333 334 335 336 337 338 339

  @override
  _OutlineButtonState createState() => _OutlineButtonState();
}


class _OutlineButtonState extends State<_OutlineButton> with SingleTickerProviderStateMixin {
340 341 342 343 344 345 346 347 348
  AnimationController _controller;
  Animation<double> _fillAnimation;
  Animation<double> _elevationAnimation;
  bool _pressed = false;

  @override
  void initState() {
    super.initState();

349 350 351 352 353 354 355
    // When highlightElevation > 0.0, the Material widget animates its
    // shape (which includes the outline border) and elevation over
    // _kElevationDuration. When pressed, the button makes its fill
    // color opaque white first, and then sets its
    // highlightElevation. We can't change the elevation while the
    // button's fill is translucent, because the shadow fills the
    // interior of the button.
356

357
    _controller = AnimationController(
358
      duration: _kPressDuration,
359
      vsync: this,
360
    );
361
    _fillAnimation = CurvedAnimation(
362 363 364 365 366
      parent: _controller,
      curve: const Interval(0.0, 0.5,
        curve: Curves.fastOutSlowIn,
      ),
    );
367
    _elevationAnimation = CurvedAnimation(
368 369 370 371 372 373
      parent: _controller,
      curve: const Interval(0.5, 0.5),
      reverseCurve: const Interval(1.0, 1.0),
    );
  }

374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394
  @override
  void didUpdateWidget(_OutlineButton oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (_pressed && !widget.enabled) {
      _pressed = false;
      _controller.reverse();
    }
  }

  void _handleHighlightChanged(bool value) {
    if (_pressed == value)
      return;
    setState(() {
      _pressed = value;
      if (value)
        _controller.forward();
      else
        _controller.reverse();
    });
  }

395 396 397 398 399 400
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

401
  Color _getFillColor() {
402 403
    if (widget.highlightElevation == null || widget.highlightElevation == 0.0)
      return Colors.transparent;
404
    final Color color = widget.color ?? Theme.of(context).canvasColor;
405
    final Tween<Color> colorTween = ColorTween(
406 407 408 409 410 411
      begin: color.withAlpha(0x00),
      end: color.withAlpha(0xFF),
    );
    return colorTween.evaluate(_fillAnimation);
  }

412 413 414 415 416 417 418 419 420 421 422 423
  Color get _outlineColor {
    // If outline color is a `MaterialStateProperty`, it will be used in all
    // states, otherwise we determine the outline color in the current state.
    if (widget.borderSide?.color is MaterialStateProperty<Color>)
      return widget.borderSide.color;
    if (!widget.enabled)
      return widget.disabledBorderColor;
    if (_pressed)
      return widget.highlightedBorderColor;
    return widget.borderSide?.color;
  }

424
  BorderSide _getOutline() {
425 426 427
    if (widget.borderSide?.style == BorderStyle.none)
      return widget.borderSide;

428
    final Color themeColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.12);
429

430
    return BorderSide(
431
      color: _outlineColor ?? themeColor,
432
      width: widget.borderSide?.width ?? 1.0,
433 434 435 436
    );
  }

  double _getHighlightElevation() {
437 438
    if (widget.highlightElevation == null || widget.highlightElevation == 0.0)
      return 0.0;
439
    return Tween<double>(
440
      begin: 0.0,
441
      end: widget.highlightElevation,
442 443 444 445 446
    ).evaluate(_elevationAnimation);
  }

  @override
  Widget build(BuildContext context) {
447 448
    final ThemeData theme = Theme.of(context);

449
    return AnimatedBuilder(
450 451
      animation: _controller,
      builder: (BuildContext context, Widget child) {
452
        return RaisedButton(
453
          textColor: widget.textColor,
454
          disabledTextColor: widget.disabledTextColor,
455 456
          color: _getFillColor(),
          splashColor: widget.splashColor,
457 458
          focusColor: widget.focusColor,
          hoverColor: widget.hoverColor,
459 460 461
          highlightColor: widget.highlightColor,
          disabledColor: Colors.transparent,
          onPressed: widget.onPressed,
462
          onLongPress: widget.onLongPress,
463 464
          elevation: 0.0,
          disabledElevation: 0.0,
465 466
          focusElevation: 0.0,
          hoverElevation: 0.0,
467
          highlightElevation: _getHighlightElevation(),
468
          onHighlightChanged: _handleHighlightChanged,
469
          padding: widget.padding,
470
          visualDensity: widget.visualDensity ?? theme.visualDensity,
471
          shape: _OutlineBorder(
472 473
            shape: widget.shape,
            side: _getOutline(),
474
          ),
475
          clipBehavior: widget.clipBehavior,
476
          focusNode: widget.focusNode,
477 478 479 480 481 482 483 484 485 486
          animationDuration: _kElevationDuration,
          child: widget.child,
        );
      },
    );
  }
}

// Render the button's outline border using using the OutlineButton's
// border parameters and the button or buttonTheme's shape.
487
class _OutlineBorder extends ShapeBorder implements MaterialStateProperty<ShapeBorder>{
488 489 490 491 492 493 494 495 496 497 498
  const _OutlineBorder({
    @required this.shape,
    @required this.side,
  }) : assert(shape != null),
       assert(side != null);

  final ShapeBorder shape;
  final BorderSide side;

  @override
  EdgeInsetsGeometry get dimensions {
499
    return EdgeInsets.all(side.width);
500 501 502 503
  }

  @override
  ShapeBorder scale(double t) {
504
    return _OutlineBorder(
505 506 507 508 509 510 511 512 513
      shape: shape.scale(t),
      side: side.scale(t),
    );
  }

  @override
  ShapeBorder lerpFrom(ShapeBorder a, double t) {
    assert(t != null);
    if (a is _OutlineBorder) {
514
      return _OutlineBorder(
515
        side: BorderSide.lerp(a.side, side, t),
516
        shape: ShapeBorder.lerp(a.shape, shape, t),
517 518 519 520 521 522 523 524 525
      );
    }
    return super.lerpFrom(a, t);
  }

  @override
  ShapeBorder lerpTo(ShapeBorder b, double t) {
    assert(t != null);
    if (b is _OutlineBorder) {
526
      return _OutlineBorder(
527
        side: BorderSide.lerp(side, b.side, t),
528
        shape: ShapeBorder.lerp(shape, b.shape, t),
529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549
      );
    }
    return super.lerpTo(b, t);
  }

  @override
  Path getInnerPath(Rect rect, { TextDirection textDirection }) {
    return shape.getInnerPath(rect.deflate(side.width), textDirection: textDirection);
  }

  @override
  Path getOuterPath(Rect rect, { TextDirection textDirection }) {
    return shape.getOuterPath(rect, textDirection: textDirection);
  }

  @override
  void paint(Canvas canvas, Rect rect, { TextDirection textDirection }) {
    switch (side.style) {
      case BorderStyle.none:
        break;
      case BorderStyle.solid:
550
        canvas.drawPath(shape.getOuterPath(rect, textDirection: textDirection), side.toPaint());
551 552 553 554
    }
  }

  @override
555
  bool operator ==(Object other) {
556 557
    if (identical(this, other))
      return true;
558
    if (other.runtimeType != runtimeType)
559
      return false;
560 561 562
    return other is _OutlineBorder
        && other.side == side
        && other.shape == shape;
563 564 565 566
  }

  @override
  int get hashCode => hashValues(side, shape);
567 568 569 570 571 572 573 574

  @override
  ShapeBorder resolve(Set<MaterialState> states) {
    return _OutlineBorder(
      shape: shape,
      side: side.copyWith(color: MaterialStateProperty.resolveAs<Color>(side.color, states),
    ));
  }
575
}