input_border.dart 19.3 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
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math' as math;
import 'dart:ui' show lerpDouble;

8
import 'package:flutter/foundation.dart' show clampDouble;
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
import 'package:flutter/widgets.dart';

/// Defines the appearance of an [InputDecorator]'s border.
///
/// An input decorator's border is specified by [InputDecoration.border].
///
/// The border is drawn relative to the input decorator's "container" which
/// is the optionally filled area above the decorator's helper, error,
/// and counter.
///
/// Input border's are decorated with a line whose weight and color are defined
/// by [borderSide]. The input decorator's renderer animates the input border's
/// appearance in response to state changes, like gaining or losing the focus,
/// by creating new copies of its input border with [copyWith].
///
/// See also:
///
///  * [UnderlineInputBorder], the default [InputDecorator] border which
///    draws a horizontal line at the bottom of the input decorator's container.
///  * [OutlineInputBorder], an [InputDecorator] border which draws a
///    rounded rectangle around the input decorator's container.
///  * [InputDecoration], which is used to configure an [InputDecorator].
abstract class InputBorder extends ShapeBorder {
  /// Creates a border for an [InputDecorator].
  ///
  /// The [borderSide] parameter must not be null. Applications typically do
  /// not specify a [borderSide] parameter because the input decorator
  /// substitutes its own, using [copyWith], based on the current theme and
  /// [InputDecorator.isFocused].
  const InputBorder({
39
    this.borderSide = BorderSide.none,
40
  });
41

42 43 44
  /// No input border.
  ///
  /// Use this value with [InputDecoration.border] to specify that no border
45
  /// should be drawn. The [InputDecoration.collapsed] constructor sets
46 47 48
  /// its border to this value.
  static const InputBorder none = _NoInputBorder();

49 50 51 52 53 54 55
  /// Defines the border line's color and weight.
  ///
  /// The [InputDecorator] creates copies of its input border, using [copyWith],
  /// based on the current theme and [InputDecorator.isFocused].
  final BorderSide borderSide;

  /// Creates a copy of this input border with the specified `borderSide`.
56
  InputBorder copyWith({ BorderSide? borderSide });
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74

  /// True if this border will enclose the [InputDecorator]'s container.
  ///
  /// This property affects the alignment of container's contents. For example
  /// when an input decorator is configured with an [OutlineInputBorder] its
  /// label is centered with its container.
  bool get isOutline;

  /// Paint this input border on [canvas].
  ///
  /// The [rect] parameter bounds the [InputDecorator]'s container.
  ///
  /// The additional `gap` parameters reflect the state of the [InputDecorator]'s
  /// floating label. When an input decorator gains the focus, its label
  /// animates upwards, to make room for the input child. The [gapStart] and
  /// [gapExtent] parameters define a floating label width interval, and
  /// [gapPercentage] defines the animation's progress (0.0 to 1.0).
  @override
75 76 77
  void paint(
    Canvas canvas,
    Rect rect, {
78
    double? gapStart,
79 80
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
81
    TextDirection? textDirection,
82 83 84
  });
}

85 86 87 88 89
// Used to create the InputBorder.none singleton.
class _NoInputBorder extends InputBorder {
  const _NoInputBorder() : super(borderSide: BorderSide.none);

  @override
90
  _NoInputBorder copyWith({ BorderSide? borderSide }) => const _NoInputBorder();
91 92 93 94 95 96 97 98 99 100 101

  @override
  bool get isOutline => false;

  @override
  EdgeInsetsGeometry get dimensions => EdgeInsets.zero;

  @override
  _NoInputBorder scale(double t) => const _NoInputBorder();

  @override
102
  Path getInnerPath(Rect rect, { TextDirection? textDirection }) {
103
    return Path()..addRect(rect);
104 105 106
  }

  @override
107
  Path getOuterPath(Rect rect, { TextDirection? textDirection }) {
108
    return Path()..addRect(rect);
109 110
  }

111 112 113 114 115 116 117 118
  @override
  void paintInterior(Canvas canvas, Rect rect, Paint paint, { TextDirection? textDirection }) {
    canvas.drawRect(rect, paint);
  }

  @override
  bool get preferPaintInterior => true;

119
  @override
120 121 122
  void paint(
    Canvas canvas,
    Rect rect, {
123
    double? gapStart,
124 125
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
126
    TextDirection? textDirection,
127 128 129 130 131
  }) {
    // Do not paint.
  }
}

132 133
/// Draws a horizontal line at the bottom of an [InputDecorator]'s container and
/// defines the container's shape.
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
///
/// The input decorator's "container" is the optionally filled area above the
/// decorator's helper, error, and counter.
///
/// See also:
///
///  * [OutlineInputBorder], an [InputDecorator] border which draws a
///    rounded rectangle around the input decorator's container.
///  * [InputDecoration], which is used to configure an [InputDecorator].
class UnderlineInputBorder extends InputBorder {
  /// Creates an underline border for an [InputDecorator].
  ///
  /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be
  /// null). Applications typically do not specify a [borderSide] parameter
  /// because the input decorator substitutes its own, using [copyWith], based
  /// on the current theme and [InputDecorator.isFocused].
150 151 152 153
  ///
  /// The [borderRadius] parameter defaults to a value where the top left
  /// and right corners have a circular radius of 4.0. The [borderRadius]
  /// parameter must not be null.
154
  const UnderlineInputBorder({
155
    super.borderSide = const BorderSide(),
156
    this.borderRadius = const BorderRadius.only(
157 158
      topLeft: Radius.circular(4.0),
      topRight: Radius.circular(4.0),
159
    ),
160
  });
161 162 163 164 165 166 167 168 169 170 171

  /// The radii of the border's rounded rectangle corners.
  ///
  /// When this border is used with a filled input decorator, see
  /// [InputDecoration.filled], the border radius defines the shape
  /// of the background fill as well as the bottom left and right
  /// edges of the underline itself.
  ///
  /// By default the top right and top left corners have a circular radius
  /// of 4.0.
  final BorderRadius borderRadius;
172 173 174 175 176

  @override
  bool get isOutline => false;

  @override
177
  UnderlineInputBorder copyWith({ BorderSide? borderSide, BorderRadius? borderRadius }) {
178
    return UnderlineInputBorder(
179 180 181
      borderSide: borderSide ?? this.borderSide,
      borderRadius: borderRadius ?? this.borderRadius,
    );
182 183 184 185
  }

  @override
  EdgeInsetsGeometry get dimensions {
186
    return EdgeInsets.only(bottom: borderSide.width);
187 188 189 190
  }

  @override
  UnderlineInputBorder scale(double t) {
191
    return UnderlineInputBorder(borderSide: borderSide.scale(t));
192 193 194
  }

  @override
195
  Path getInnerPath(Rect rect, { TextDirection? textDirection }) {
196 197
    return Path()
      ..addRect(Rect.fromLTWH(rect.left, rect.top, rect.width, math.max(0.0, rect.height - borderSide.width)));
198 199 200
  }

  @override
201
  Path getOuterPath(Rect rect, { TextDirection? textDirection }) {
202
    return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect));
203 204
  }

205 206 207 208 209 210 211 212
  @override
  void paintInterior(Canvas canvas, Rect rect, Paint paint, { TextDirection? textDirection }) {
    canvas.drawRRect(borderRadius.resolve(textDirection).toRRect(rect), paint);
  }

  @override
  bool get preferPaintInterior => true;

213
  @override
214
  ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
215
    if (a is UnderlineInputBorder) {
216
      return UnderlineInputBorder(
217
        borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
218
        borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t)!,
219 220 221 222 223 224
      );
    }
    return super.lerpFrom(a, t);
  }

  @override
225
  ShapeBorder? lerpTo(ShapeBorder? b, double t) {
226
    if (b is UnderlineInputBorder) {
227
      return UnderlineInputBorder(
228
        borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
229
        borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t)!,
230 231 232 233 234 235 236 237 238 239
      );
    }
    return super.lerpTo(b, t);
  }

  /// Draw a horizontal line at the bottom of [rect].
  ///
  /// The [borderSide] defines the line's color and weight. The `textDirection`
  /// `gap` and `textDirection` parameters are ignored.
  @override
240 241 242
  void paint(
    Canvas canvas,
    Rect rect, {
243
    double? gapStart,
244 245
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
246
    TextDirection? textDirection,
247
  }) {
248
    if (borderRadius.bottomLeft != Radius.zero || borderRadius.bottomRight != Radius.zero) {
249
      canvas.clipPath(getOuterPath(rect, textDirection: textDirection));
250
    }
251 252 253 254
    canvas.drawLine(rect.bottomLeft, rect.bottomRight, borderSide.toPaint());
  }

  @override
255
  bool operator ==(Object other) {
256
    if (identical(this, other)) {
257
      return true;
258 259
    }
    if (other.runtimeType != runtimeType) {
260
      return false;
261
    }
262 263 264
    return other is UnderlineInputBorder
        && other.borderSide == borderSide
        && other.borderRadius == borderRadius;
265 266 267
  }

  @override
268
  int get hashCode => Object.hash(borderSide, borderRadius);
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286
}

/// Draws a rounded rectangle around an [InputDecorator]'s container.
///
/// When the input decorator's label is floating, for example because its
/// input child has the focus, the label appears in a gap in the border outline.
///
/// The input decorator's "container" is the optionally filled area above the
/// decorator's helper, error, and counter.
///
/// See also:
///
///  * [UnderlineInputBorder], the default [InputDecorator] border which
///    draws a horizontal line at the bottom of the input decorator's container.
///  * [InputDecoration], which is used to configure an [InputDecorator].
class OutlineInputBorder extends InputBorder {
  /// Creates a rounded rectangle outline border for an [InputDecorator].
  ///
287 288 289 290 291 292 293
  /// If the [borderSide] parameter is [BorderSide.none], it will not draw a
  /// border. However, it will still define a shape (which you can see if
  /// [InputDecoration.filled] is true).
  ///
  /// If an application does not specify a [borderSide] parameter of
  /// value [BorderSide.none], the input decorator substitutes its own, using
  /// [copyWith], based on the current theme and [InputDecorator.isFocused].
294
  ///
295 296 297 298
  /// The [borderRadius] parameter defaults to a value where all four
  /// corners have a circular radius of 4.0. The [borderRadius] parameter
  /// must not be null and the corner radii must be circular, i.e. their
  /// [Radius.x] and [Radius.y] values must be the same.
299 300
  ///
  /// See also:
301
  ///
302 303 304
  ///  * [InputDecoration.floatingLabelBehavior], which should be set to
  ///    [FloatingLabelBehavior.never] when the [borderSide] is
  ///    [BorderSide.none]. If let as [FloatingLabelBehavior.auto], the label
305 306
  ///    will extend beyond the container as if the border were still being
  ///    drawn.
307
  const OutlineInputBorder({
308
    super.borderSide = const BorderSide(),
309
    this.borderRadius = const BorderRadius.all(Radius.circular(4.0)),
310
    this.gapPadding = 4.0,
311
  }) : assert(gapPadding >= 0.0);
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342

  // The label text's gap can extend into the corners (even both the top left
  // and the top right corner). To avoid the more complicated problem of finding
  // how far the gap penetrates into an elliptical corner, just require them
  // to be circular.
  //
  // This can't be checked by the constructor because const constructor.
  static bool _cornersAreCircular(BorderRadius borderRadius) {
    return borderRadius.topLeft.x == borderRadius.topLeft.y
        && borderRadius.bottomLeft.x == borderRadius.bottomLeft.y
        && borderRadius.topRight.x == borderRadius.topRight.y
        && borderRadius.bottomRight.x == borderRadius.bottomRight.y;
  }

  /// Horizontal padding on either side of the border's
  /// [InputDecoration.labelText] width gap.
  ///
  /// This value is used by the [paint] method to compute the actual gap width.
  final double gapPadding;

  /// The radii of the border's rounded rectangle corners.
  ///
  /// The corner radii must be circular, i.e. their [Radius.x] and [Radius.y]
  /// values must be the same.
  final BorderRadius borderRadius;

  @override
  bool get isOutline => true;

  @override
  OutlineInputBorder copyWith({
343 344 345
    BorderSide? borderSide,
    BorderRadius? borderRadius,
    double? gapPadding,
346
  }) {
347
    return OutlineInputBorder(
348 349 350 351 352 353 354 355
      borderSide: borderSide ?? this.borderSide,
      borderRadius: borderRadius ?? this.borderRadius,
      gapPadding: gapPadding ?? this.gapPadding,
    );
  }

  @override
  EdgeInsetsGeometry get dimensions {
356
    return EdgeInsets.all(borderSide.width);
357 358 359 360
  }

  @override
  OutlineInputBorder scale(double t) {
361
    return OutlineInputBorder(
362 363 364 365 366 367 368
      borderSide: borderSide.scale(t),
      borderRadius: borderRadius * t,
      gapPadding: gapPadding * t,
    );
  }

  @override
369
  ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
370 371
    if (a is OutlineInputBorder) {
      final OutlineInputBorder outline = a;
372
      return OutlineInputBorder(
373
        borderRadius: BorderRadius.lerp(outline.borderRadius, borderRadius, t)!,
374 375 376 377 378 379 380 381
        borderSide: BorderSide.lerp(outline.borderSide, borderSide, t),
        gapPadding: outline.gapPadding,
      );
    }
    return super.lerpFrom(a, t);
  }

  @override
382
  ShapeBorder? lerpTo(ShapeBorder? b, double t) {
383 384
    if (b is OutlineInputBorder) {
      final OutlineInputBorder outline = b;
385
      return OutlineInputBorder(
386
        borderRadius: BorderRadius.lerp(borderRadius, outline.borderRadius, t)!,
387 388 389 390 391 392 393 394
        borderSide: BorderSide.lerp(borderSide, outline.borderSide, t),
        gapPadding: outline.gapPadding,
      );
    }
    return super.lerpTo(b, t);
  }

  @override
395
  Path getInnerPath(Rect rect, { TextDirection? textDirection }) {
396
    return Path()
397 398 399 400
      ..addRRect(borderRadius.resolve(textDirection).toRRect(rect).deflate(borderSide.width));
  }

  @override
401
  Path getOuterPath(Rect rect, { TextDirection? textDirection }) {
402
    return Path()
403 404 405
      ..addRRect(borderRadius.resolve(textDirection).toRRect(rect));
  }

406 407 408 409 410 411 412 413
  @override
  void paintInterior(Canvas canvas, Rect rect, Paint paint, { TextDirection? textDirection }) {
    canvas.drawRRect(borderRadius.resolve(textDirection).toRRect(rect), paint);
  }

  @override
  bool get preferPaintInterior => true;

414
  Path _gapBorderPath(Canvas canvas, RRect center, double start, double extent) {
415 416 417 418 419
    // When the corner radii on any side add up to be greater than the
    // given height, each radius has to be scaled to not exceed the
    // size of the width/height of the RRect.
    final RRect scaledRRect = center.scaleRadii();

420
    final Rect tlCorner = Rect.fromLTWH(
421 422 423 424
      scaledRRect.left,
      scaledRRect.top,
      scaledRRect.tlRadiusX * 2.0,
      scaledRRect.tlRadiusY * 2.0,
425
    );
426
    final Rect trCorner = Rect.fromLTWH(
427 428 429 430
      scaledRRect.right - scaledRRect.trRadiusX * 2.0,
      scaledRRect.top,
      scaledRRect.trRadiusX * 2.0,
      scaledRRect.trRadiusY * 2.0,
431
    );
432
    final Rect brCorner = Rect.fromLTWH(
433 434 435 436
      scaledRRect.right - scaledRRect.brRadiusX * 2.0,
      scaledRRect.bottom - scaledRRect.brRadiusY * 2.0,
      scaledRRect.brRadiusX * 2.0,
      scaledRRect.brRadiusY * 2.0,
437
    );
438
    final Rect blCorner = Rect.fromLTWH(
439 440 441
      scaledRRect.left,
      scaledRRect.bottom - scaledRRect.blRadiusY * 2.0,
      scaledRRect.blRadiusX * 2.0,
442
      scaledRRect.blRadiusY * 2.0,
443 444
    );

445 446
    // This assumes that the radius is circular (x and y radius are equal).
    // Currently, BorderRadius only supports circular radii.
447
    const double cornerArcSweep = math.pi / 2.0;
448 449 450 451 452 453 454 455 456 457 458
    final Path path = Path();

    // Top left corner
    if (scaledRRect.tlRadius != Radius.zero) {
      final double tlCornerArcSweep = math.acos(clampDouble(1 - start / scaledRRect.tlRadiusX, 0.0, 1.0));
      path.addArc(tlCorner, math.pi, tlCornerArcSweep);
    } else {
      // Because the path is painted with Paint.strokeCap = StrokeCap.butt, horizontal coordinate is moved
      // to the left using borderSide.width / 2.
      path.moveTo(scaledRRect.left - borderSide.width / 2, scaledRRect.top);
    }
459

460
    // Draw top border from top left corner to gap start.
461
    if (start > scaledRRect.tlRadiusX) {
462
      path.lineTo(scaledRRect.left + start, scaledRRect.top);
463
    }
464

465
    // Draw top border from gap end to top right corner and draw top right corner.
466 467
    const double trCornerArcStart = (3 * math.pi) / 2.0;
    const double trCornerArcSweep = cornerArcSweep;
468
    if (start + extent < scaledRRect.width - scaledRRect.trRadiusX) {
469 470
      path.moveTo(scaledRRect.left + start + extent, scaledRRect.top);
      path.lineTo(scaledRRect.right - scaledRRect.trRadiusX, scaledRRect.top);
471 472 473
      if (scaledRRect.trRadius != Radius.zero) {
        path.addArc(trCorner, trCornerArcStart, trCornerArcSweep);
      }
474 475
    } else if (start + extent < scaledRRect.width) {
      final double dx = scaledRRect.width - (start + extent);
476
      final double sweep = math.asin(clampDouble(1 - dx / scaledRRect.trRadiusX, 0.0, 1.0));
477 478 479
      path.addArc(trCorner, trCornerArcStart + sweep, trCornerArcSweep - sweep);
    }

480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498
    // Draw right border and bottom right corner.
    if (scaledRRect.brRadius != Radius.zero) {
      path.moveTo(scaledRRect.right, scaledRRect.top + scaledRRect.trRadiusY);
    }
    path.lineTo(scaledRRect.right, scaledRRect.bottom - scaledRRect.brRadiusY);
    if (scaledRRect.brRadius != Radius.zero) {
      path.addArc(brCorner, 0.0, cornerArcSweep);
    }

    // Draw bottom border and bottom left corner.
    path.lineTo(scaledRRect.left + scaledRRect.blRadiusX, scaledRRect.bottom);
    if (scaledRRect.blRadius != Radius.zero) {
      path.addArc(blCorner, math.pi / 2.0, cornerArcSweep);
    }

    // Draw left border
    path.lineTo(scaledRRect.left, scaledRRect.top + scaledRRect.tlRadiusY);

    return path;
499 500 501 502 503 504 505 506 507 508 509
  }

  /// Draw a rounded rectangle around [rect] using [borderRadius].
  ///
  /// The [borderSide] defines the line's color and weight.
  ///
  /// The top side of the rounded rectangle may be interrupted by a single gap
  /// if [gapExtent] is non-null. In that case the gap begins at
  /// `gapStart - gapPadding` (assuming that the [textDirection] is [TextDirection.ltr]).
  /// The gap's width is `(gapPadding + gapExtent + gapPadding) * gapPercentage`.
  @override
510 511 512
  void paint(
    Canvas canvas,
    Rect rect, {
513
    double? gapStart,
514 515
    double gapExtent = 0.0,
    double gapPercentage = 0.0,
516
    TextDirection? textDirection,
517 518 519 520 521 522 523 524 525 526
  }) {
    assert(gapPercentage >= 0.0 && gapPercentage <= 1.0);
    assert(_cornersAreCircular(borderRadius));

    final Paint paint = borderSide.toPaint();
    final RRect outer = borderRadius.toRRect(rect);
    final RRect center = outer.deflate(borderSide.width / 2.0);
    if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) {
      canvas.drawRRect(center, paint);
    } else {
527 528
      final double extent = lerpDouble(0.0, gapExtent + gapPadding * 2.0, gapPercentage)!;
      switch (textDirection!) {
529
        case TextDirection.rtl:
530
          final Path path = _gapBorderPath(canvas, center, math.max(0.0, gapStart + gapPadding - extent), extent);
531 532
          canvas.drawPath(path, paint);
          break;
533 534

        case TextDirection.ltr:
535
          final Path path = _gapBorderPath(canvas, center, math.max(0.0, gapStart - gapPadding), extent);
536 537 538 539 540 541 542
          canvas.drawPath(path, paint);
          break;
      }
    }
  }

  @override
543
  bool operator ==(Object other) {
544
    if (identical(this, other)) {
545
      return true;
546 547
    }
    if (other.runtimeType != runtimeType) {
548
      return false;
549
    }
550 551 552 553
    return other is OutlineInputBorder
        && other.borderSide == borderSide
        && other.borderRadius == borderRadius
        && other.gapPadding == gapPadding;
554 555 556
  }

  @override
557
  int get hashCode => Object.hash(borderSide, borderRadius, gapPadding);
558
}