// Copyright 2014 The Flutter Authors. All rights reserved. // 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; import 'package:flutter/foundation.dart' show clampDouble; 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({ this.borderSide = BorderSide.none, }) : assert(borderSide != null); /// No input border. /// /// Use this value with [InputDecoration.border] to specify that no border /// should be drawn. The [InputDecoration.collapsed] constructor sets /// its border to this value. static const InputBorder none = _NoInputBorder(); /// 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`. InputBorder copyWith({ BorderSide? borderSide }); /// 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 void paint( Canvas canvas, Rect rect, { double? gapStart, double gapExtent = 0.0, double gapPercentage = 0.0, TextDirection? textDirection, }); } // Used to create the InputBorder.none singleton. class _NoInputBorder extends InputBorder { const _NoInputBorder() : super(borderSide: BorderSide.none); @override _NoInputBorder copyWith({ BorderSide? borderSide }) => const _NoInputBorder(); @override bool get isOutline => false; @override EdgeInsetsGeometry get dimensions => EdgeInsets.zero; @override _NoInputBorder scale(double t) => const _NoInputBorder(); @override Path getInnerPath(Rect rect, { TextDirection? textDirection }) { return Path()..addRect(rect); } @override Path getOuterPath(Rect rect, { TextDirection? textDirection }) { return Path()..addRect(rect); } @override void paintInterior(Canvas canvas, Rect rect, Paint paint, { TextDirection? textDirection }) { canvas.drawRect(rect, paint); } @override bool get preferPaintInterior => true; @override void paint( Canvas canvas, Rect rect, { double? gapStart, double gapExtent = 0.0, double gapPercentage = 0.0, TextDirection? textDirection, }) { // Do not paint. } } /// Draws a horizontal line at the bottom of an [InputDecorator]'s container and /// defines the container's shape. /// /// 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]. /// /// 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. const UnderlineInputBorder({ super.borderSide = const BorderSide(), this.borderRadius = const BorderRadius.only( topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0), ), }) : assert(borderRadius != null); /// 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; @override bool get isOutline => false; @override UnderlineInputBorder copyWith({ BorderSide? borderSide, BorderRadius? borderRadius }) { return UnderlineInputBorder( borderSide: borderSide ?? this.borderSide, borderRadius: borderRadius ?? this.borderRadius, ); } @override EdgeInsetsGeometry get dimensions { return EdgeInsets.only(bottom: borderSide.width); } @override UnderlineInputBorder scale(double t) { return UnderlineInputBorder(borderSide: borderSide.scale(t)); } @override Path getInnerPath(Rect rect, { TextDirection? textDirection }) { return Path() ..addRect(Rect.fromLTWH(rect.left, rect.top, rect.width, math.max(0.0, rect.height - borderSide.width))); } @override Path getOuterPath(Rect rect, { TextDirection? textDirection }) { return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); } @override void paintInterior(Canvas canvas, Rect rect, Paint paint, { TextDirection? textDirection }) { canvas.drawRRect(borderRadius.resolve(textDirection).toRRect(rect), paint); } @override bool get preferPaintInterior => true; @override ShapeBorder? lerpFrom(ShapeBorder? a, double t) { if (a is UnderlineInputBorder) { return UnderlineInputBorder( borderSide: BorderSide.lerp(a.borderSide, borderSide, t), borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t)!, ); } return super.lerpFrom(a, t); } @override ShapeBorder? lerpTo(ShapeBorder? b, double t) { if (b is UnderlineInputBorder) { return UnderlineInputBorder( borderSide: BorderSide.lerp(borderSide, b.borderSide, t), borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t)!, ); } 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 void paint( Canvas canvas, Rect rect, { double? gapStart, double gapExtent = 0.0, double gapPercentage = 0.0, TextDirection? textDirection, }) { if (borderRadius.bottomLeft != Radius.zero || borderRadius.bottomRight != Radius.zero) { canvas.clipPath(getOuterPath(rect, textDirection: textDirection)); } canvas.drawLine(rect.bottomLeft, rect.bottomRight, borderSide.toPaint()); } @override bool operator ==(Object other) { if (identical(this, other)) { return true; } if (other.runtimeType != runtimeType) { return false; } return other is UnderlineInputBorder && other.borderSide == borderSide && other.borderRadius == borderRadius; } @override int get hashCode => Object.hash(borderSide, borderRadius); } /// 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]. /// /// 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]. /// /// 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. /// /// See also: /// /// * [InputDecoration.floatingLabelBehavior], which should be set to /// [FloatingLabelBehavior.never] when the [borderSide] is /// [BorderSide.none]. If let as [FloatingLabelBehavior.auto], the label /// will extend beyond the container as if the border were still being /// drawn. const OutlineInputBorder({ super.borderSide = const BorderSide(), this.borderRadius = const BorderRadius.all(Radius.circular(4.0)), this.gapPadding = 4.0, }) : assert(borderRadius != null), assert(gapPadding != null && gapPadding >= 0.0); // 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({ BorderSide? borderSide, BorderRadius? borderRadius, double? gapPadding, }) { return OutlineInputBorder( borderSide: borderSide ?? this.borderSide, borderRadius: borderRadius ?? this.borderRadius, gapPadding: gapPadding ?? this.gapPadding, ); } @override EdgeInsetsGeometry get dimensions { return EdgeInsets.all(borderSide.width); } @override OutlineInputBorder scale(double t) { return OutlineInputBorder( borderSide: borderSide.scale(t), borderRadius: borderRadius * t, gapPadding: gapPadding * t, ); } @override ShapeBorder? lerpFrom(ShapeBorder? a, double t) { if (a is OutlineInputBorder) { final OutlineInputBorder outline = a; return OutlineInputBorder( borderRadius: BorderRadius.lerp(outline.borderRadius, borderRadius, t)!, borderSide: BorderSide.lerp(outline.borderSide, borderSide, t), gapPadding: outline.gapPadding, ); } return super.lerpFrom(a, t); } @override ShapeBorder? lerpTo(ShapeBorder? b, double t) { if (b is OutlineInputBorder) { final OutlineInputBorder outline = b; return OutlineInputBorder( borderRadius: BorderRadius.lerp(borderRadius, outline.borderRadius, t)!, borderSide: BorderSide.lerp(borderSide, outline.borderSide, t), gapPadding: outline.gapPadding, ); } return super.lerpTo(b, t); } @override Path getInnerPath(Rect rect, { TextDirection? textDirection }) { return Path() ..addRRect(borderRadius.resolve(textDirection).toRRect(rect).deflate(borderSide.width)); } @override Path getOuterPath(Rect rect, { TextDirection? textDirection }) { return Path() ..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); } @override void paintInterior(Canvas canvas, Rect rect, Paint paint, { TextDirection? textDirection }) { canvas.drawRRect(borderRadius.resolve(textDirection).toRRect(rect), paint); } @override bool get preferPaintInterior => true; Path _gapBorderPath(Canvas canvas, RRect center, double start, double extent) { // 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(); final Rect tlCorner = Rect.fromLTWH( scaledRRect.left, scaledRRect.top, scaledRRect.tlRadiusX * 2.0, scaledRRect.tlRadiusY * 2.0, ); final Rect trCorner = Rect.fromLTWH( scaledRRect.right - scaledRRect.trRadiusX * 2.0, scaledRRect.top, scaledRRect.trRadiusX * 2.0, scaledRRect.trRadiusY * 2.0, ); final Rect brCorner = Rect.fromLTWH( scaledRRect.right - scaledRRect.brRadiusX * 2.0, scaledRRect.bottom - scaledRRect.brRadiusY * 2.0, scaledRRect.brRadiusX * 2.0, scaledRRect.brRadiusY * 2.0, ); final Rect blCorner = Rect.fromLTWH( scaledRRect.left, scaledRRect.bottom - scaledRRect.blRadiusY * 2.0, scaledRRect.blRadiusX * 2.0, scaledRRect.blRadiusY * 2.0, ); // This assumes that the radius is circular (x and y radius are equal). // Currently, BorderRadius only supports circular radii. const double cornerArcSweep = math.pi / 2.0; 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); } // Draw top border from top left corner to gap start. if (start > scaledRRect.tlRadiusX) { path.lineTo(scaledRRect.left + start, scaledRRect.top); } // Draw top border from gap end to top right corner and draw top right corner. const double trCornerArcStart = (3 * math.pi) / 2.0; const double trCornerArcSweep = cornerArcSweep; if (start + extent < scaledRRect.width - scaledRRect.trRadiusX) { path.moveTo(scaledRRect.left + start + extent, scaledRRect.top); path.lineTo(scaledRRect.right - scaledRRect.trRadiusX, scaledRRect.top); if (scaledRRect.trRadius != Radius.zero) { path.addArc(trCorner, trCornerArcStart, trCornerArcSweep); } } else if (start + extent < scaledRRect.width) { final double dx = scaledRRect.width - (start + extent); final double sweep = math.asin(clampDouble(1 - dx / scaledRRect.trRadiusX, 0.0, 1.0)); path.addArc(trCorner, trCornerArcStart + sweep, trCornerArcSweep - sweep); } // 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; } /// 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 void paint( Canvas canvas, Rect rect, { double? gapStart, double gapExtent = 0.0, double gapPercentage = 0.0, TextDirection? textDirection, }) { assert(gapExtent != null); 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 { final double extent = lerpDouble(0.0, gapExtent + gapPadding * 2.0, gapPercentage)!; switch (textDirection!) { case TextDirection.rtl: final Path path = _gapBorderPath(canvas, center, math.max(0.0, gapStart + gapPadding - extent), extent); canvas.drawPath(path, paint); break; case TextDirection.ltr: final Path path = _gapBorderPath(canvas, center, math.max(0.0, gapStart - gapPadding), extent); canvas.drawPath(path, paint); break; } } } @override bool operator ==(Object other) { if (identical(this, other)) { return true; } if (other.runtimeType != runtimeType) { return false; } return other is OutlineInputBorder && other.borderSide == borderSide && other.borderRadius == borderRadius && other.gapPadding == gapPadding; } @override int get hashCode => Object.hash(borderSide, borderRadius, gapPadding); }