How to add a border to a widget

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 these:

Animated buttons with round gradient border in Flutter
Our goal

A simple border around a simple widget

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.

Simple text widget with border in Flutter
Simple text widget surrounded by border

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.

Simple text widget with stroke border in Flutter
Simple text widget surrounded by stroke border

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
    );
  }
}
Gradient border around text in Flutter
Gradient border around text

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.

Round gradient border around text in Flutter
Round gradient border around text

Giving 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.

Round gradient border around text with padding in Flutter
Round gradient border around text with 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.

Gradient border around text with ripple effect in Flutter
Gradient border around text with ripple effect

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: () {},
);
Gradient border around text with white color in Flutter
Gradient border around text with white color

That’s why we need a border around the border. Let’s call it outline to make a better distinction.

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;
}
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
          ),
        ),
    );
  }
  ...
}

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.

Gradient border around text with white color and outline in Flutter
Gradient border around text with white color and outline

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;
}

If 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.

Partial border with gaps in Flutter
Partial border with gaps

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).

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

Leave a Comment