This article is about borders. Not only the boring default one, but also the fun ones that make the containing widget stand out.
The goal
Apart from the basic usage I want you to learn how to draw special borders with gradients and gaps like in the above animation.
A simple border
But let's start with something simple: how do we draw a single-colored border around a widget?
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.orangeAccent, width: 4)
),
child: Text("Surrounded by a border", style: TextStyle(fontSize: 32),),
);
We wrap a container around the widget (in this case the Text widget) and use a BoxDecoration
to define border width and color. In fact, using the Border
constructor instead of Border.all
, we can even control each of the four sides independently.
Stroke border
When you think of border around text, you might rather think about a stroke that encloses every character instead of a rectangle defined by the outer Container with a border. For this purpose, we can use the foreground property of the TextStyle
class.
Stack(
children: [
Text(
'Surrounded by a border',
style: TextStyle(
fontSize: 32,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4
..color = Colors.orangeAccent,
),
),
Text(
'Surrounded by a border',
style: TextStyle(
fontSize: 32,
color: Colors.redAccent,
),
),
]
);
If we used only the first Text widget, in the Stack
we would only have the stroke. Instead, we also want the fill. That's why I chose a Stack
widget that paints the border in the background and the fill in the foreground. Just stacking two Text widgets with different font sizes on top of each other will not work because the text is scaled from the center.
Gradient border
Okay, let's take the next step: instead of just having a single-colored border around a widget, we proceed to draw a gradient around it. This will get a little bit more complicated, but no worries, I will go through it step by step.
Let's start by implementing a CustomPainter
, the one that actually draws the border.
But first, let's think a moment what this class should do:
- In order to draw a border with a gradient, we need at least two pieces of information: the Gradient itself (containing information like the colors and how they are drawn) and the stroke width
- We draw an (inner) rectangle around the widget that we want to have bordered. It has to be an inner rectangle because we don't want to exceed the size of the widget we want to enclose
class GradientPainter extends CustomPainter {
GradientPainter({this.gradient, this.strokeWidth});
final Gradient gradient;
final double strokeWidth;
final Paint paintObject = Paint();
@override
void paint(Canvas canvas, Size size) {
Rect innerRect = Rect.fromLTRB(strokeWidth, strokeWidth, size.width - strokeWidth, size.height - strokeWidth);
Rect outerRect = Offset.zero & size;
paintObject.shader = gradient.createShader(outerRect);
Path borderPath = _calculateBorderPath(outerRect, innerRect);
canvas.drawPath(borderPath, paintObject);
}
Path _calculateBorderPath(Rect outerRect, Rect innerRect) {
Path outerRectPath = Path()..addRect(outerRect);
Path innerRectPath = Path()..addRect(innerRect);
return Path.combine(PathOperation.difference, outerRectPath, innerRectPath);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
So what we do is making use of a shortcut. Instead of drawing four rectangles (the four sides of our border), we add two paths: the outer rectangle that has the same size as the widget and the inner rectangle that has the same size but subtracted by the given strokeWidth. Then we use PathOperation.difference
to calculate the difference. The difference between a bigger and a smaller rectangle is a stroke around the smaller one.
To make the gradient work as well, we need to add a shader to the paintObject
. We use the createShader
method for that and provide the outerRect
as an argument to make the gradient reach the outer edges.
Now in order to be able to use that GradientPainter
, we have to create an enclosing widget that takes a child widget (e. g. a Text) and then draws our Gradient
around it.
class GradientBorderButtonContainer extends StatelessWidget {
GradientBorderButtonContainer({
@required gradient,
@required this.child,
this.strokeWidth = 4,
}) : this.painter = GradientPainter(
gradient: gradient, strokeWidth: strokeWidth
);
final GradientPainter painter;
final Widget child;
final VoidCallback onPressed;
final double strokeWidth;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: painter,
child: child
);
}
}
Rounded edges
Now we want the border to be round instead of having the hard rectangle corners.
class GradientPainter extends CustomPainter {
GradientPainter({this.gradient, this.strokeWidth, this.borderRadius});
final Gradient gradient;
final double strokeWidth;
final double borderRadius;
final Paint paintObject = Paint();
@override
void paint(Canvas canvas, Size size) {
Rect innerRect = Rect.fromLTRB(strokeWidth, strokeWidth, size.width - strokeWidth, size.height - strokeWidth);
RRect innerRoundedRect = RRect.fromRectAndRadius(innerRect, Radius.circular(borderRadius));
Rect outerRect = Offset.zero & size;
RRect outerRoundedRect = RRect.fromRectAndRadius(outerRect, Radius.circular(borderRadius));
paintObject.shader = gradient.createShader(outerRect);
Path borderPath = _calculateBorderPath(outerRoundedRect, innerRoundedRect);
canvas.drawPath(borderPath, paintObject);
}
Path _calculateBorderPath(RRect outerRRect, RRect innerRRect) {
Path outerRectPath = Path()..addRRect(outerRRect);
Path innerRectPath = Path()..addRRect(innerRRect);
return Path.combine(PathOperation.difference, outerRectPath, innerRectPath);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
class GradientBorderContainer extends StatelessWidget {
GradientBorderContainer({
@required gradient,
@required this.child,
@required this.onPressed,
this.strokeWidth = 4,
this.borderRadius = 64
}) : this.painter = GradientPainter(
gradient: gradient, strokeWidth: strokeWidth, borderRadius: borderRadius
);
final GradientPainter painter;
final Widget child;
final VoidCallback onPressed;
final double strokeWidth;
final double borderRadius;
final double padding;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: painter,
child: child
);
}
}
It is quite simple: we create RRect
(rounded rectangles) based on the given Rects we used above and a radius we let the caller define in the constructor of our GradientBorderContainer
widget.
Give it a padding
Looks better but there is still room for improvement. The text looks like it is too near to the border, it actually touches the border. So let's give it a padding.
class GradientBorderContainer extends StatelessWidget {
GradientBorderContainer({
@required gradient,
@required this.child,
@required this.onPressed,
this.strokeWidth = 4,
this.borderRadius = 64,
this.padding = 16
}) : this.painter = GradientPainter(
gradient: gradient, strokeWidth: strokeWidth, borderRadius: borderRadius
);
final GradientPainter painter;
final Widget child;
final VoidCallback onPressed;
final double strokeWidth;
final double borderRadius;
final double padding;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: painter,
child: Container(
padding: EdgeInsets.all(padding),
child: child
)
);
}
}
Once again we touch the constructor of our GradientBorderContainer
widget. We extend it by one parameter called padding which defaults to 16. We then use this padding to wrap a Container around the child widget with the respective padding.
Ripple effect
Looks great, doesn't it? Now we can focus our improvent on the actual interaction. Since it looks like a button, we want to give it a feedback once the user touches it. We go for the classic Material ripple effect.
class GradientBorderContainer extends StatelessWidget {
GradientBorderContainer({
@required gradient,
@required this.child,
@required this.onPressed,
this.strokeWidth = 4,
this.borderRadius = 64,
this.padding = 16,
splashColor
}) :
this.painter = GradientPainter(
gradient: gradient, strokeWidth: strokeWidth, borderRadius: borderRadius
),
this.splashColor = splashColor ?? gradient.colors.first;
final GradientPainter painter;
final Widget child;
final VoidCallback onPressed;
final double strokeWidth;
final double borderRadius;
final double padding;
final Color splashColor;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: painter,
child: InkWell(
highlightColor: Colors.transparent,
splashColor: splashColor,
borderRadius: BorderRadius.circular(borderRadius),
onTap: onPressed,
child: Container(
padding: EdgeInsets.all(padding + strokeWidth),
child: child
),
),
);
}
The ripple effect can be achieved by using an InkWell
. The splashColor determines the color of the circle that grows as long as you tap down. We set it to the first color of the gradient unless something else is provided. This way, the effect still looks cool when no extra color is given. The highlightColor is set to Colors.transparent
because otherwise it defaults to a grey that makes it look worse in my opinion. The InkWell needs the borderRadius as well. If we omitted it the splash would exceed the borders of the child.
Yo dawg, I heard you like borders
There's one last thing I would like us to improve. I noticed, when I used gradients with repeated patterns that contain a white color or generally contain the same color as background, it becomes harder to see the border.
GradientBorderContainer(
strokeWidth: 16,
borderRadius: 16,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment(-0.2, -0.4),
stops: [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1],
colors: [
Colors.pinkAccent,
Colors.pinkAccent,
Colors.white,
Colors.white,
Colors.pinkAccent,
Colors.pinkAccent,
Colors.white,
Colors.white,
],
tileMode: TileMode.repeated,
),
child: Text('NEED OUTER BORDER!',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
onPressed: () {},
);
GradientBorderContainer(
strokeWidth: 16,
borderRadius: 16,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment(-0.2, -0.4),
stops: [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1],
colors: [
Colors.pinkAccent,
Colors.pinkAccent,
Colors.white,
Colors.white,
Colors.pinkAccent,
Colors.pinkAccent,
Colors.white,
Colors.white,
],
tileMode: TileMode.repeated,
),
child: Text('NEED OUTER BORDER!',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
onPressed: () {},
);
That's why we need a border around the border. Let's call it outline
to make a better distinction.
class GradientBorderContainer extends StatelessWidget {
GradientBorderContainer({
...
this.outlineWidth = 1
}) :
this.painter = GradientPainter(
outlineWidth: outlineWidth
),
this.splashColor = splashColor ?? gradient.colors.first;
final double outlineWidth;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: painter,
child: InkWell(
highlightColor: Colors.transparent,
splashColor: splashColor,
borderRadius: BorderRadius.circular(borderRadius),
onTap: onPressed,
child: Container(
padding: EdgeInsets.all(padding + strokeWidth + outlineWidth),
child: child
),
),
);
}
...
}
class GradientPainter extends CustomPainter {
GradientPainter({this.gradient, this.strokeWidth, this.borderRadius, this.outlineWidth});
final Gradient gradient;
final double strokeWidth;
final double borderRadius;
final double outlineWidth;
final Paint paintObject = Paint();
@override
void paint(Canvas canvas, Size size) {
if (outlineWidth > 0) {
_paintOutline(outlineWidth, size, canvas);
}
Rect innerRect = Rect.fromLTRB(
strokeWidth, strokeWidth, size.width - strokeWidth, size.height - strokeWidth
);
RRect innerRoundedRect = RRect.fromRectAndRadius(innerRect, Radius.circular(borderRadius));
Rect outerRect = Offset.zero & size;
RRect outerRoundedRect = RRect.fromRectAndRadius(outerRect, Radius.circular(borderRadius));
paintObject.shader = gradient.createShader(outerRect);
Path borderPath = _calculateBorderPath(outerRoundedRect, innerRoundedRect);
canvas.drawPath(borderPath, paintObject);
}
void _paintOutline(double outlineWidth, Size size, Canvas canvas) {
Paint paint = Paint();
Rect innerRectB = Rect.fromLTRB(
strokeWidth + outlineWidth,
strokeWidth + outlineWidth,
size.width - strokeWidth - outlineWidth,
size.height - strokeWidth - outlineWidth
);
RRect innerRRectB = RRect.fromRectAndRadius(innerRectB, Radius.circular(borderRadius - outlineWidth));
Rect outerRectB = Rect.fromLTRB(-outlineWidth, -outlineWidth, size.width + outlineWidth, size.height + outlineWidth);
RRect outerRRectB = RRect.fromRectAndRadius(outerRectB, Radius.circular(borderRadius + outlineWidth));
Path borderBorderPath = _calculateBorderPath(outerRRectB, innerRRectB);
paint.color = Colors.black;
canvas.drawPath(borderBorderPath, paint);
}
Path _calculateBorderPath(RRect outerRRect, RRect innerRRect) {
Path outerRectPath = Path()..addRRect(outerRRect);
Path innerRectPath = Path()..addRRect(innerRRect);
return Path.combine(PathOperation.difference, outerRectPath, innerRectPath);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
Another argument is added to the constructor: outlineWidth
. It determines whether an outline should be visible and what the width of that outline should be.
Partial border
So far, we have drawn a border that is drawn continuously. Let's look at something new: trying to draw only the top left corner and the bottom right corner.
class PartialPainter extends CustomPainter {
PartialPainter({this.radius, this.strokeWidth, this.gradient});
final Paint paintObject = Paint();
final double radius;
final double strokeWidth;
final Gradient gradient;
@override
void paint(Canvas canvas, Size size) {
Rect topLeftTop = Rect.fromLTRB(0, 0, size.height / 4, strokeWidth);
Rect topLeftLeft = Rect.fromLTRB(0, 0, strokeWidth, size.height / 4);
Rect bottomRightBottom = Rect.fromLTRB(size.width - size.height / 4, size.height - strokeWidth, size.width, size.height);
Rect bottomRightRight = Rect.fromLTRB(size.width - strokeWidth, size.height * 3 / 4, size.width, size.height);
paintObject.shader = gradient.createShader(Offset.zero & size);
Path topLeftPath = Path()
..addRect(topLeftTop)
..addRect(topLeftLeft);
Path bottomRightPath = Path()
..addRect(bottomRightBottom)
..addRect(bottomRightRight);
Path finalPath = Path.combine(PathOperation.union, topLeftPath, bottomRightPath);
canvas.drawPath(finalPath, paintObject);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
f you have seen and understood the previous examples, then this is probably not too hart to understand. We draw four rectangles, each representing a side of the indicated border. The length of the respective rectangles is dependent on the height of the child widget (a quarter of that). We then use PathOperation.union
to combine the paths.
Final thoughts
Using a CustomPainter
, it's possible to achieve a lot more flexibility when it comes to drawing a border around a widget. In this tutorial, aside from the basics, we have learned how to draw a configurable gradient border around a widget. We are able to set a gradient, padding, a stroke width and the width of the outline around the border. On top of that, to indicate user interaction we have a ripple effect provided by an InkWell widget. Additionally, we have seen how it's possible to have only parts of the border drawn (by leaving gaps).
Top comments (0)