// 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' as ui show lerpDouble; import 'package:flutter/foundation.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4; import 'basic_types.dart'; import 'borders.dart'; import 'circle_border.dart'; import 'rounded_rectangle_border.dart'; import 'stadium_border.dart'; // Conversion from radians to degrees. const double _kRadToDeg = 180 / math.pi; // Conversion from degrees to radians. const double _kDegToRad = math.pi / 180; /// A border that fits a star or polygon-shaped border within the rectangle of /// the widget it is applied to. /// /// Typically used with a [ShapeDecoration] to draw a polygonal or star shaped /// border. /// /// {@tool dartpad} /// This example serves both as a usage example, as well as an explorer for /// determining the parameters to use with a [StarBorder]. The resulting code /// can be copied and pasted into your app. A [Container] is just one widget /// which takes a [ShapeBorder]. [Dialog]s, [OutlinedButton]s, /// [ElevatedButton]s, etc. all can be shaped with a [ShapeBorder]. /// /// ** See code in examples/api/lib/painting/star_border/star_border.0.dart ** /// {@end-tool} /// /// See also: /// /// * [BorderSide], which is used to describe how the edge of the shape is /// drawn. class StarBorder extends OutlinedBorder { /// Create a const star-shaped border with the given number [points] on the /// star. const StarBorder({ super.side, this.points = 5, double innerRadiusRatio = 0.4, this.pointRounding = 0, this.valleyRounding = 0, double rotation = 0, this.squash = 0, }) : assert(squash >= 0), assert(squash <= 1), assert(pointRounding >= 0), assert(pointRounding <= 1), assert(valleyRounding >= 0), assert(valleyRounding <= 1), assert( (valleyRounding + pointRounding) <= 1, 'The sum of valleyRounding ($valleyRounding) and ' 'pointRounding ($pointRounding) must not exceed one.'), assert(innerRadiusRatio >= 0), assert(innerRadiusRatio <= 1), assert(points >= 2), _rotationRadians = rotation * _kDegToRad, _innerRadiusRatio = innerRadiusRatio; /// Create a const polygon border with the given number of [sides]. const StarBorder.polygon({ super.side, double sides = 5, this.pointRounding = 0, double rotation = 0, this.squash = 0, }) : assert(squash >= 0), assert(squash <= 1), assert(pointRounding >= 0), assert(pointRounding <= 1), assert(sides >= 2), points = sides, valleyRounding = 0, _rotationRadians = rotation * _kDegToRad, _innerRadiusRatio = null; /// The number of points in this star, or sides on a polygon. /// /// This is a floating point number: if this is not a whole number, then an /// additional star point or corner shorter than the others will be added to /// finish the shape. Only whole-numbered values will yield a symmetric shape. /// (This enables the number of points to be animated smoothly.) /// /// For stars created with [StarBorder], this is the number of points on /// the star. For polygons created with [StarBorder.polygon], this is the /// number of sides on the polygon. /// /// Must be greater than or equal to two. final double points; /// The ratio of the inner radius of a star with the outer radius. /// /// When making a star using [StarBorder], this is the ratio of the inner /// radius that to the outer radius. If it is one, then the inner radius /// will equal the outer radius. /// /// For polygons created with [StarBorder.polygon], getting this value will /// return the incircle radius of the polygon (the radius of a circle /// inscribed inside the polygon). /// /// Defaults to 0.4 for stars, and must be between zero and one, inclusive. double get innerRadiusRatio { // Polygons are just a special case of a star where the inner radius is the // incircle radius of the polygon (the radius of an inscribed circle). return _innerRadiusRatio ?? math.cos(math.pi / points); } final double? _innerRadiusRatio; /// The amount of rounding on the points of stars, or the corners of polygons. /// /// This is a value between zero and one which describes how rounded the point /// or corner should be. A value of zero means no rounding (sharp corners), /// and a value of one means that the entire point or corner is a portion of a /// circle. /// /// Defaults to zero. The sum of [pointRounding] and [valleyRounding] must be /// less than or equal to one. final double pointRounding; /// The amount of rounding of the interior corners of stars. /// /// This is a value between zero and one which describes how rounded the inner /// corners in a star (the "valley" between points) should be. A value of zero /// means no rounding (sharp corners), and a value of one means that the /// entire corner is a portion of a circle. /// /// Defaults to zero. The sum of [pointRounding] and [valleyRounding] must be /// less than or equal to one. For polygons created with [StarBorder.polygon], /// this will always be zero. final double valleyRounding; /// The rotation in clockwise degrees around the center of the shape. /// /// The rotation occurs before the [squash] effect is applied, so that you can /// fine tune where the points of a star or corners of a polygon start. /// /// Defaults to zero, meaning that the first point or corner is pointing up. double get rotation => _rotationRadians * _kRadToDeg; final double _rotationRadians; /// How much of the aspect ratio of the attached widget to take on. /// /// If [squash] is non-zero, the border will match the aspect ratio of the /// bounding box of the widget that it is attached to, which can give a /// squashed appearance. /// /// The [squash] parameter lets you control how much of that aspect ratio this /// border takes on. /// /// A value of zero means that the border will be drawn with a square aspect /// ratio at the size of the shortest side of the bounding rectangle, ignoring /// the aspect ratio of the widget, and a value of one means it will be drawn /// with the aspect ratio of the widget. The value of [squash] has no effect /// if the widget is square to begin with. /// /// Defaults to zero, and must be between zero and one, inclusive. final double squash; @override ShapeBorder scale(double t) { return StarBorder( points: points, side: side.scale(t), rotation: rotation, innerRadiusRatio: innerRadiusRatio, pointRounding: pointRounding, valleyRounding: valleyRounding, squash: squash, ); } ShapeBorder? _twoPhaseLerp( double t, double split, ShapeBorder? Function(double t) first, ShapeBorder? Function(double t) second, ) { // If the rectangle has square corners, then skip the extra lerp to round the corners. if (t < split) { return first(t * (1 / split)); } else { t = (1 / (1.0 - split)) * (t - split); return second(t); } } @override ShapeBorder? lerpFrom(ShapeBorder? a, double t) { if (t == 0) { return a; } if (t == 1.0) { return this; } if (a is StarBorder) { return StarBorder( side: BorderSide.lerp(a.side, side, t), points: ui.lerpDouble(a.points, points, t)!, rotation: ui.lerpDouble(a._rotationRadians, _rotationRadians, t)! * _kRadToDeg, innerRadiusRatio: ui.lerpDouble(a.innerRadiusRatio, innerRadiusRatio, t)!, pointRounding: ui.lerpDouble(a.pointRounding, pointRounding, t)!, valleyRounding: ui.lerpDouble(a.valleyRounding, valleyRounding, t)!, squash: ui.lerpDouble(a.squash, squash, t)!, ); } if (a is CircleBorder) { if (points >= 2.5) { final double lerpedPoints = ui.lerpDouble(points.round(), points, t)!; return StarBorder( side: BorderSide.lerp(a.side, side, t), points: lerpedPoints, squash: ui.lerpDouble(a.eccentricity, squash, t)!, rotation: rotation, innerRadiusRatio: ui.lerpDouble(math.cos(math.pi / lerpedPoints), innerRadiusRatio, t)!, pointRounding: ui.lerpDouble(1.0, pointRounding, t)!, valleyRounding: ui.lerpDouble(0.0, valleyRounding, t)!, ); } else { // Have a slightly different lerp for two-pointed stars, since they get // kind of squirrelly with near-zero innerRadiusRatios. final double lerpedPoints = ui.lerpDouble(points, 2, t)!; return StarBorder( side: BorderSide.lerp(a.side, side, t), points: lerpedPoints, squash: ui.lerpDouble(a.eccentricity, squash, t)!, rotation: rotation, innerRadiusRatio: ui.lerpDouble(1, innerRadiusRatio, t)!, pointRounding: ui.lerpDouble(0.5, pointRounding, t)!, valleyRounding: ui.lerpDouble(0.5, valleyRounding, t)!, ); } } if (a is StadiumBorder) { // Lerp from a stadium to a circle first, and from there to a star. final BorderSide lerpedSide = BorderSide.lerp(a.side, side, t); return _twoPhaseLerp( t, 0.5, (double t) => a.lerpTo(CircleBorder(side: lerpedSide), t), (double t) => lerpFrom(CircleBorder(side: lerpedSide), t), ); } if (a is RoundedRectangleBorder) { // Lerp from a rectangle to a stadium, then from a Stadium to a circle, // then from a circle to a star. final BorderSide lerpedSide = BorderSide.lerp(a.side, side, t); return _twoPhaseLerp( t, 1 / 3, (double t) { return StadiumBorder(side: lerpedSide).lerpFrom(a, t); }, (double t) { return _twoPhaseLerp( t, 0.5, (double t) => StadiumBorder(side: lerpedSide).lerpTo(CircleBorder(side: lerpedSide), t), (double t) => lerpFrom(CircleBorder(side: lerpedSide), t), ); }, ); } return super.lerpFrom(a, t); } @override ShapeBorder? lerpTo(ShapeBorder? b, double t) { if (t == 0) { return this; } if (t == 1.0) { return b; } if (b is StarBorder) { return StarBorder( side: BorderSide.lerp(side, b.side, t), points: ui.lerpDouble(points, b.points, t)!, rotation: ui.lerpDouble(_rotationRadians, b._rotationRadians, t)! * _kRadToDeg, innerRadiusRatio: ui.lerpDouble(innerRadiusRatio, b.innerRadiusRatio, t)!, pointRounding: ui.lerpDouble(pointRounding, b.pointRounding, t)!, valleyRounding: ui.lerpDouble(valleyRounding, b.valleyRounding, t)!, squash: ui.lerpDouble(squash, b.squash, t)!, ); } if (b is CircleBorder) { // Have a slightly different lerp for two-pointed stars, since they get // kind of squirrelly with near-zero innerRadiusRatios. if (points >= 2.5) { final double lerpedPoints = ui.lerpDouble(points, points.round(), t)!; return StarBorder( side: BorderSide.lerp(side, b.side, t), points: lerpedPoints, squash: ui.lerpDouble(squash, b.eccentricity, t)!, rotation: rotation, innerRadiusRatio: ui.lerpDouble(innerRadiusRatio, math.cos(math.pi / lerpedPoints), t)!, pointRounding: ui.lerpDouble(pointRounding, 1.0, t)!, valleyRounding: ui.lerpDouble(valleyRounding, 0.0, t)!, ); } else { final double lerpedPoints = ui.lerpDouble(points, 2, t)!; return StarBorder( side: BorderSide.lerp(side, b.side, t), points: lerpedPoints, squash: ui.lerpDouble(squash, b.eccentricity, t)!, rotation: rotation, innerRadiusRatio: ui.lerpDouble(innerRadiusRatio, 1, t)!, pointRounding: ui.lerpDouble(pointRounding, 0.5, t)!, valleyRounding: ui.lerpDouble(valleyRounding, 0.5, t)!, ); } } if (b is StadiumBorder) { // Lerp to a circle first, then to a stadium. final BorderSide lerpedSide = BorderSide.lerp(side, b.side, t); return _twoPhaseLerp( t, 0.5, (double t) => lerpTo(CircleBorder(side: lerpedSide), t), (double t) => b.lerpFrom(CircleBorder(side: lerpedSide), t), ); } if (b is RoundedRectangleBorder) { // Lerp to a circle, and then to a stadium, then to a rounded rect. final BorderSide lerpedSide = BorderSide.lerp(side, b.side, t); return _twoPhaseLerp( t, 2 / 3, (double t) { return _twoPhaseLerp( t, 0.5, (double t) => lerpTo(CircleBorder(side: lerpedSide), t), (double t) => StadiumBorder(side: lerpedSide).lerpFrom(CircleBorder(side: lerpedSide), t), ); }, (double t) { return StadiumBorder(side: lerpedSide).lerpTo(b, t); }, ); } return super.lerpTo(b, t); } @override StarBorder copyWith({ BorderSide? side, double? points, double? innerRadiusRatio, double? pointRounding, double? valleyRounding, double? rotation, double? squash, }) { return StarBorder( side: side ?? this.side, points: points ?? this.points, rotation: rotation ?? this.rotation, innerRadiusRatio: innerRadiusRatio ?? this.innerRadiusRatio, pointRounding: pointRounding ?? this.pointRounding, valleyRounding: valleyRounding ?? this.valleyRounding, squash: squash ?? this.squash, ); } @override Path getInnerPath(Rect rect, {TextDirection? textDirection}) { final Rect adjustedRect = rect.deflate(side.strokeInset); return _StarGenerator( points: points, rotation: _rotationRadians, innerRadiusRatio: innerRadiusRatio, pointRounding: pointRounding, valleyRounding: valleyRounding, squash: squash, ).generate(adjustedRect); } @override Path getOuterPath(Rect rect, {TextDirection? textDirection}) { return _StarGenerator( points: points, rotation: _rotationRadians, innerRadiusRatio: innerRadiusRatio, pointRounding: pointRounding, valleyRounding: valleyRounding, squash: squash, ).generate(rect); } @override void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { switch (side.style) { case BorderStyle.none: break; case BorderStyle.solid: final Rect adjustedRect = rect.inflate(side.strokeOffset / 2); final Path path = _StarGenerator( points: points, rotation: _rotationRadians, innerRadiusRatio: innerRadiusRatio, pointRounding: pointRounding, valleyRounding: valleyRounding, squash: squash, ).generate(adjustedRect); canvas.drawPath(path, side.toPaint()); } } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is StarBorder && other.side == side && other.points == points && other._innerRadiusRatio == _innerRadiusRatio && other.pointRounding == pointRounding && other.valleyRounding == valleyRounding && other._rotationRadians == _rotationRadians && other.squash == squash; } @override int get hashCode => side.hashCode; @override String toString() { return '${objectRuntimeType(this, 'StarBorder')}($side, points: $points, innerRadiusRatio: $innerRadiusRatio)'; } } class _PointInfo { _PointInfo({ required this.valley, required this.point, required this.valleyArc1, required this.pointArc1, required this.valleyArc2, required this.pointArc2, }); Offset valley; Offset point; Offset valleyArc1; Offset pointArc1; Offset pointArc2; Offset valleyArc2; } class _StarGenerator { const _StarGenerator({ required this.points, required this.innerRadiusRatio, required this.pointRounding, required this.valleyRounding, required this.rotation, required this.squash, }) : assert(points > 1), assert(innerRadiusRatio <= 1), assert(innerRadiusRatio >= 0), assert(squash >= 0), assert(squash <= 1), assert(pointRounding >= 0), assert(pointRounding <= 1), assert(valleyRounding >= 0), assert(valleyRounding <= 1), assert(pointRounding + valleyRounding <= 1); final double points; final double innerRadiusRatio; final double pointRounding; final double valleyRounding; final double rotation; final double squash; Path generate(Rect rect) { final double radius = rect.shortestSide / 2; final Offset center = rect.center; // The minimum allowed inner radius ratio. Numerical instabilities occur near // zero, so we just don't allow values in that range. const double minInnerRadiusRatio = .002; // Map the innerRadiusRatio so that we don't get values close to zero, since // things get a little squirrelly there because the path thinks that the // length of the conicTo is small enough that it can render it as a straight // line, even though it will be scaled up later. This maps the range from // [0, 1] to [minInnerRadiusRatio, 1]. final double mappedInnerRadiusRatio = (innerRadiusRatio * (1.0 - minInnerRadiusRatio)) + minInnerRadiusRatio; // First, generate the "points" of the star. final List<_PointInfo> points = <_PointInfo>[]; final double maxDiameter = 2.0 * _generatePoints( pointList: points, center: center, radius: radius, innerRadius: radius * mappedInnerRadiusRatio, ); // Calculate the endpoints of each of the arcs, then draw the arcs. final Path path = Path(); _drawPoints(path, points); Offset scale = Offset(rect.width / maxDiameter, rect.height / maxDiameter); if (rect.shortestSide == rect.width) { scale = Offset(scale.dx, squash * scale.dy + (1 - squash) * scale.dx); } else { scale = Offset(squash * scale.dx + (1 - squash) * scale.dy, scale.dy); } // Scale the border so that it matches the size of the widget rectangle, so // that "rotation" of the shape doesn't affect how much of the rectangle it // covers. final Matrix4 squashMatrix = Matrix4.translationValues(rect.center.dx, rect.center.dy, 0); squashMatrix.multiply(Matrix4.diagonal3Values(scale.dx, scale.dy, 1)); squashMatrix.multiply(Matrix4.rotationZ(rotation)); squashMatrix.multiply(Matrix4.translationValues(-rect.center.dx, -rect.center.dy, 0)); return path.transform(squashMatrix.storage); } double _generatePoints({ required List<_PointInfo> pointList, required Offset center, required double radius, required double innerRadius, }) { final double step = math.pi / points; // Start initial rotation one step before zero. double angle = -math.pi / 2 - step; Offset valley = Offset( center.dx + math.cos(angle) * innerRadius, center.dy + math.sin(angle) * innerRadius, ); // In order to do overall scale properly, calculate the actual radius at the // point, taking into account the rounding of the points and the weight of // the corner point. This effectively is evaluating the rational quadratic // bezier at the midpoint of the curve. Offset getCurveMidpoint(Offset a, Offset b, Offset c, Offset a1, Offset c1) { final double angle = _getAngle(a, b, c); final double w = _getWeight(angle) / 2; return (a1 / 4 + b * w + c1 / 4) / (0.5 + w); } double addPoint( double pointAngle, double pointStep, double pointRadius, double pointInnerRadius, ) { pointAngle += pointStep; final Offset point = Offset( center.dx + math.cos(pointAngle) * pointRadius, center.dy + math.sin(pointAngle) * pointRadius, ); pointAngle += pointStep; final Offset nextValley = Offset( center.dx + math.cos(pointAngle) * pointInnerRadius, center.dy + math.sin(pointAngle) * pointInnerRadius, ); final Offset valleyArc1 = valley + (point - valley) * valleyRounding; final Offset pointArc1 = point + (valley - point) * pointRounding; final Offset pointArc2 = point + (nextValley - point) * pointRounding; final Offset valleyArc2 = nextValley + (point - nextValley) * valleyRounding; pointList.add(_PointInfo( valley: valley, point: point, valleyArc1: valleyArc1, pointArc1: pointArc1, pointArc2: pointArc2, valleyArc2: valleyArc2, )); valley = nextValley; return pointAngle; } final double remainder = points - points.truncateToDouble(); final bool hasIntegerSides = remainder < 1e-6; final double wholeSides = points - (hasIntegerSides ? 0 : 1); for (int i = 0; i < wholeSides; i += 1) { angle = addPoint(angle, step, radius, innerRadius); } double valleyRadius = 0; double pointRadius = 0; final _PointInfo thisPoint = pointList[0]; final _PointInfo nextPoint = pointList[1]; final Offset pointMidpoint = getCurveMidpoint(thisPoint.valley, thisPoint.point, nextPoint.valley, thisPoint.pointArc1, thisPoint.pointArc2); final Offset valleyMidpoint = getCurveMidpoint( thisPoint.point, nextPoint.valley, nextPoint.point, thisPoint.valleyArc2, nextPoint.valleyArc1); valleyRadius = (valleyMidpoint - center).distance; pointRadius = (pointMidpoint - center).distance; // Add the final point to close the shape if there are fractional sides to // account for. if (!hasIntegerSides) { final double effectiveInnerRadius = math.max(valleyRadius, innerRadius); final double endingRadius = effectiveInnerRadius + remainder * (radius - effectiveInnerRadius); addPoint(angle, step * remainder, endingRadius, innerRadius); } // The rounding added to the valley radius can sometimes push it outside of // the rounding of the point, since the rounding amount can be different // between the points and the valleys, so we have to evaluate both the // valley and the point radii, and pick the largest. Also, since this value // is used later to determine the scale, we need to keep it finite and // non-zero. return clampDouble(math.max(valleyRadius, pointRadius), double.minPositive, double.maxFinite); } void _drawPoints(Path path, List<_PointInfo> points) { final Offset startingPoint = points.first.pointArc1; path.moveTo(startingPoint.dx, startingPoint.dy); final double pointAngle = _getAngle(points[0].valley, points[0].point, points[1].valley); final double pointWeight = _getWeight(pointAngle); final double valleyAngle = _getAngle(points[1].point, points[1].valley, points[0].point); final double valleyWeight = _getWeight(valleyAngle); for (int i = 0; i < points.length; i += 1) { final _PointInfo point = points[i]; final _PointInfo nextPoint = points[(i + 1) % points.length]; path.lineTo(point.pointArc1.dx, point.pointArc1.dy); if (pointAngle != 180 && pointAngle != 0) { path.conicTo(point.point.dx, point.point.dy, point.pointArc2.dx, point.pointArc2.dy, pointWeight); } else { path.lineTo(point.pointArc2.dx, point.pointArc2.dy); } path.lineTo(point.valleyArc2.dx, point.valleyArc2.dy); if (valleyAngle != 180 && valleyAngle != 0) { path.conicTo( nextPoint.valley.dx, nextPoint.valley.dy, nextPoint.valleyArc1.dx, nextPoint.valleyArc1.dy, valleyWeight); } else { path.lineTo(nextPoint.valleyArc1.dx, nextPoint.valleyArc1.dy); } } path.close(); } double _getWeight(double angle) { return math.cos((angle / 2) % (math.pi / 2)); } // Returns the included angle between points ABC in radians. double _getAngle(Offset a, Offset b, Offset c) { if (a == c || b == c || b == a) { return 0; } final Offset u = a - b; final Offset v = c - b; final double dot = u.dx * v.dx + u.dy * v.dy; final double m1 = b.dx == a.dx ? double.infinity : -u.dy / -u.dx; final double m2 = b.dx == c.dx ? double.infinity : -v.dy / -v.dx; double angle = math.atan2(m1 - m2, 1 + m1 * m2).abs(); if (dot < 0) { angle += math.pi; } return angle; } }