← Back to Articles

Flutter Custom Shapes and Paths: Drawing Geometry-Based Widgets

Flutter Custom Shapes and Paths: Drawing Geometry-Based Widgets

Flutter Custom Shapes and Paths: Drawing Geometry-Based Widgets

Have you ever looked at a design mockup and thought, "How do I create that custom shape in Flutter?" While Flutter provides many built-in widgets, sometimes you need to draw custom geometric shapes that don't fit into standard rectangles and circles. That's where Flutter's powerful custom painting system comes in.

In this article, we'll explore how to create custom geometric shapes using Flutter's CustomPainter and Path classes. Whether you want to draw triangles, hexagons, stars, or completely custom shapes, you'll learn the fundamentals to bring your unique designs to life.

Understanding the Basics: CustomPainter and Canvas

At the heart of custom shape drawing in Flutter is the CustomPainter class. Think of it as your artist's canvas where you can draw anything you imagine. The CustomPainter works with a Canvas object that provides methods for drawing lines, shapes, and paths.

Here's a simple example to get started:


import 'package:flutter/material.dart';

class TrianglePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.fill;

    final path = Path();
    path.moveTo(size.width / 2, 0); // Top point
    path.lineTo(0, size.height); // Bottom left
    path.lineTo(size.width, size.height); // Bottom right
    path.close(); // Connect back to start

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

To use this painter, wrap it in a CustomPaint widget:


CustomPaint(
  painter: TrianglePainter(),
  size: Size(100, 100),
)
CustomPainter Architecture CustomPaint CustomPainter paint() method draws on Canvas

Working with Paths: Your Drawing Tool

The Path class is your primary tool for defining custom shapes. It works like connecting dots on paper - you move to a point, draw lines to other points, and create curves. Let's explore the essential Path methods:

Basic Path Operations

moveTo() - Moves the "pen" to a specific point without drawing

lineTo() - Draws a straight line from the current point to a new point

close() - Connects the last point back to the first point, creating a closed shape

Here's a practical example creating a hexagon:


class HexagonPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.purple
      ..style = PaintingStyle.fill
      ..strokeWidth = 2;

    final path = Path();
    final centerX = size.width / 2;
    final centerY = size.height / 2;
    final radius = size.width / 2;

    // Calculate hexagon points (6 points in a circle)
    for (int i = 0; i < 6; i++) {
      final angle = (i * 60 - 90) * (3.14159 / 180); // Convert to radians
      final x = centerX + radius * cos(angle);
      final y = centerY + radius * sin(angle);
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    path.close();

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

Don't forget to import dart:math for the trigonometric functions:


import 'dart:math' as math;
// Then use math.cos() and math.sin()

Creating Curved Shapes

Paths aren't limited to straight lines. Flutter provides several methods for creating smooth curves:

quadraticBezierTo() - Quadratic Bezier Curves

This method creates a curved line using a control point. Think of it as bending a straight line toward a point:


class CurvedShapePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.green
      ..style = PaintingStyle.fill;

    final path = Path();
    path.moveTo(0, size.height / 2);
    path.quadraticBezierTo(
      size.width / 2, 0, // Control point (creates the curve)
      size.width, size.height / 2, // End point
    );
    path.lineTo(size.width, size.height);
    path.lineTo(0, size.height);
    path.close();

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Quadratic Bezier Curve Start Control End

cubicBezierTo() - Cubic Bezier Curves

For more complex curves, use cubic Bezier curves with two control points:


path.cubicBezierTo(
  controlPoint1X, controlPoint1Y, // First control point
  controlPoint2X, controlPoint2Y, // Second control point
  endPointX, endPointY, // End point
);

Practical Example: Creating a Star Shape

Let's combine what we've learned to create a star shape. Stars are great examples because they require both straight lines and precise calculations:


class StarPainter extends CustomPainter {
  final Color color;
  
  StarPainter({this.color = Colors.amber});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;

    final path = Path();
    final centerX = size.width / 2;
    final centerY = size.height / 2;
    final outerRadius = size.width / 2;
    final innerRadius = outerRadius * 0.4; // Inner points are closer to center

    // A 5-pointed star has 10 points total (5 outer, 5 inner)
    for (int i = 0; i < 10; i++) {
      final angle = (i * 36 - 90) * (math.pi / 180);
      final radius = i.isEven ? outerRadius : innerRadius;
      final x = centerX + radius * math.cos(angle);
      final y = centerY + radius * math.sin(angle);

      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    path.close();

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(StarPainter oldDelegate) => oldDelegate.color != color;
}

Notice how we alternate between outer and inner radii to create the star's points. The shouldRepaint method now checks if the color changed, which is more efficient than always repainting.

Advanced Techniques: Clipping and Masks

Sometimes you want to use a custom shape as a mask or clip for other widgets. Flutter provides ClipPath for this purpose:


class HexagonClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    final path = Path();
    final centerX = size.width / 2;
    final centerY = size.height / 2;
    final radius = size.width / 2;

    for (int i = 0; i < 6; i++) {
      final angle = (i * 60 - 90) * (math.pi / 180);
      final x = centerX + radius * math.cos(angle);
      final y = centerY + radius * math.sin(angle);
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    path.close();
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

// Usage:
ClipPath(
  clipper: HexagonClipper(),
  child: Image.network('https://example.com/image.jpg'),
)
Clipping with Custom Shapes Original Widget ClipPath CustomClipper Clipped

Optimization Tips

When working with custom shapes, performance matters. Here are some best practices:

  • Cache your paths: If your shape doesn't change, create the Path once and reuse it:

class OptimizedPainter extends CustomPainter {
  static Path? _cachedPath;
  
  @override
  void paint(Canvas canvas, Size size) {
    _cachedPath ??= _createPath(size);
    // Use _cachedPath
  }
}
  • Implement shouldRepaint correctly: Only return true when your shape actually needs to be redrawn:

@override
bool shouldRepaint(MyPainter oldDelegate) {
  return oldDelegate.color != color || 
         oldDelegate.size != size;
}
  • Use const constructors when possible: If your CustomPaint widget doesn't need to change, make it const:

const CustomPaint(
  painter: TrianglePainter(),
  size: Size(100, 100),
)

Real-World Example: Custom Badge Shape

Let's create a practical example - a custom badge shape that could be used for notifications or labels:


class BadgeShapePainter extends CustomPainter {
  final Color backgroundColor;
  final double cornerRadius;
  
  BadgeShapePainter({
    this.backgroundColor = Colors.red,
    this.cornerRadius = 8.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.fill;

    final path = Path();
    
    // Rounded rectangle with a small triangle at the bottom
    path.moveTo(cornerRadius, 0);
    path.lineTo(size.width - cornerRadius, 0);
    path.quadraticBezierTo(size.width, 0, size.width, cornerRadius);
    path.lineTo(size.width, size.height - cornerRadius - 10);
    path.lineTo(size.width / 2 + 5, size.height - 10);
    path.lineTo(size.width / 2, size.height);
    path.lineTo(size.width / 2 - 5, size.height - 10);
    path.lineTo(0, size.height - cornerRadius - 10);
    path.quadraticBezierTo(0, 0, cornerRadius, 0);
    path.close();

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(BadgeShapePainter oldDelegate) {
    return oldDelegate.backgroundColor != backgroundColor ||
           oldDelegate.cornerRadius != cornerRadius;
  }
}

// Usage with text:
Stack(
  children: [
    CustomPaint(
      painter: BadgeShapePainter(backgroundColor: Colors.red),
      size: Size(80, 30),
    ),
    Center(
      child: Text(
        'NEW',
        style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
      ),
    ),
  ],
)

Common Patterns and Shapes

Here are some quick reference patterns for common geometric shapes:

Diamond/Rhombus


path.moveTo(size.width / 2, 0);
path.lineTo(size.width, size.height / 2);
path.lineTo(size.width / 2, size.height);
path.lineTo(0, size.height / 2);
path.close();

Arrow


path.moveTo(0, size.height / 2);
path.lineTo(size.width * 0.7, size.height / 2);
path.lineTo(size.width * 0.7, 0);
path.lineTo(size.width, size.height / 2);
path.lineTo(size.width * 0.7, size.height);
path.lineTo(size.width * 0.7, size.height / 2);
path.close();

Heart Shape


final path = Path();
path.moveTo(size.width / 2, size.height * 0.7);
path.cubicBezierTo(
  size.width / 2, size.height * 0.5,
  size.width * 0.1, size.height * 0.3,
  size.width * 0.1, size.height * 0.5,
);
path.cubicBezierTo(
  size.width * 0.1, size.height * 0.7,
  size.width / 2, size.height * 0.9,
  size.width / 2, size.height * 0.9,
);
path.cubicBezierTo(
  size.width / 2, size.height * 0.9,
  size.width * 0.9, size.height * 0.7,
  size.width * 0.9, size.height * 0.5,
);
path.cubicBezierTo(
  size.width * 0.9, size.height * 0.3,
  size.width / 2, size.height * 0.5,
  size.width / 2, size.height * 0.7,
);

Conclusion

Creating custom geometric shapes in Flutter opens up endless possibilities for unique UI designs. By mastering CustomPainter, Path, and the various drawing methods, you can bring any geometric concept to life in your Flutter applications.

Remember to start simple with basic shapes like triangles and rectangles, then gradually work your way up to more complex curves and multi-point shapes. Don't forget to optimize your painters by caching paths and implementing efficient shouldRepaint methods.

The key is practice - experiment with different shapes, play with curves, and don't be afraid to combine multiple techniques. With these tools at your disposal, you're well-equipped to create stunning custom shapes that make your Flutter apps stand out.

Happy drawing!