Interactive Cricket Shot Tracking in Flutter

Sidhdhi Canopas
10 min readNov 7, 2024

--

Building an Interactive Cricket Ground: Step by Step

Gradient background with cricket ground, bat and ball in bottom center and blog title and sub title on top which says interactive cricket shot tracking in flutter
Cover image by author using Canva

Introduction

In the making of Khelo, my open-source cricket scoring app, I faced a fun challenge: capturing the exact fielding position when the user taps the screen. 🎯 The goal? Keep the UI simple, smooth, and intuitive — basically, let the app do the hard work while users enjoy the game!

In this blog, we’ll recreate that exact feature — designing a ground UI, detecting taps, and then animating a line to show where the ball lands. We’ll use CustomPainters for the field layout and a GestureDetector for capturing user taps.

By the end, below is what we’ll create and you can grab the source code from Github to try it out yourself.

gif by author

If you’re curious about how this fits into the overall Khelo experience, feel free to check out the app!

Overview of implementation:

We will tackle this challenge by covering:

  • Designing the ground layout
  • Displaying fielding positions
  • Detecting fielding positions with taps
  • Adding animations to bring the interactions to life

Before we dive in, let’s simplify things. Here are the key files and enums you’ll need:

  • ground_layout_view.dart: Contains the layout design and logic for identifying positions in the GroundLayoutView Stateful Widget.
  • ground_custom_painters.dart: Includes CustomPainters for this task, specifically FieldingPositionsPainter and LinearPainter.
  • enums.dart: Contains all relevant enums for the project. Here’s a quick overview:
enum FieldingPositionType {
deepMidWicket(1), longOn(2), longOff(3), deepCover(4), deepPoint(5), thirdMan(6), deepFineLeg(7), deepSquareLeg(8);
final int value;
const FieldingPositionType(this.value);
String getString() {
switch (this) {
case FieldingPositionType.deepMidWicket:
return "deep mid wicket";
case FieldingPositionType.longOn:
return "long on";
case FieldingPositionType.longOff:
return "long off";
case FieldingPositionType.deepCover:
return "deep cover";
case FieldingPositionType.deepPoint:
return "deep point";
case FieldingPositionType.thirdMan:
return "third man";
case FieldingPositionType.deepFineLeg:
return "deep fine leg";
case FieldingPositionType.deepSquareLeg:
return "deep square leg";
}}
}
enum Distance { // do not change the order as position calculation depends on distance index
short(3), mid(2), afterMid(1.4), boundary(1.15);
final double divisor;
const Distance(this.divisor);
}
enum Side {
off(180), leg(0);
final double angle;
const Side(this.angle);
String getString() {
switch (this) {
case Side.off:
return "OFF";
case Side.leg:
return "LEG";
}}
}
class FieldingPosition {
FieldingPositionType type;
double startAngle;
double endAngle;
Distance distance;
bool showOnScreen;
FieldingPosition(
this.type, {
required this.startAngle,
required this.endAngle,
this.distance = Distance.boundary,
this.showOnScreen = true,
});
}

when you’re ready, let’s dive into step one! 🏏

Step 1: Designing the Ground Layout

We’ll design a layout that mirrors a real cricket ground, including the boundaries, pitch, and power play area. Start by creating a StatefulWidget named GroundLayoutView, and design ground layout:

class GroundLayoutView extends StatefulWidget {
final Function(FieldingPosition) onPositionSelect;
const GroundLayoutView({super.key, required this.onPositionSelect});
@override
State<GroundLayoutView> createState() => _GroundLayoutViewState();
}

class _GroundLayoutViewState extends State<GroundLayoutView> {
final double groundRadius = 184;
final double pitchWidth = 25;
final double positionIndicatorSize = 10;

List<FieldingPosition> positions = [
FieldingPosition(
FieldingPositionType.deepMidWicket,
startAngle: 0,
endAngle: 45,
),
FieldingPosition(
FieldingPositionType.longOn,
startAngle: 45,
endAngle: 90,
),
FieldingPosition(
FieldingPositionType.longOff,
startAngle: 90,
endAngle: 135,
),
FieldingPosition(
FieldingPositionType.deepCover,
startAngle: 135,
endAngle: 180,
),
FieldingPosition(
FieldingPositionType.deepPoint,
startAngle: 180,
endAngle: 225,
),
FieldingPosition(
FieldingPositionType.thirdMan,
startAngle: 225,
endAngle: 270,
),
FieldingPosition(
FieldingPositionType.deepFineLeg,
startAngle: 270,
endAngle: 315,
),
FieldingPosition(
FieldingPositionType.deepSquareLeg,
startAngle: 315,
endAngle: 360,
),
];

@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: groundRadius,
child: _groundCircle(),
);
}

Widget _groundCircle() {
return GestureDetector( // will use it to detect the taps
child: CircleAvatar(
radius: groundRadius,
backgroundColor: Colors.green,
child: Stack(
alignment: Alignment.center,
children: [
_boundaryCircle(),
CustomPaint(
painter: FieldingPositionsPainter(context,
positions: positions,
divisions: 8,
radius: groundRadius - 15),
),
],
),
),
);
}

Widget _boundaryCircle() {
return Container(
margin: const EdgeInsets.all(10),
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 1.5,
),
),
child: _pitch(),
);
}

Widget _pitch() {
return CircleAvatar(
radius: groundRadius / 2,
backgroundColor: Colors.lightGreen,
child: Container(
margin: EdgeInsets.only(top: (groundRadius / 3) * 0.7),
width: pitchWidth,
height: groundRadius / 3,
child: ColoredBox(color: Colors.orange.shade100),
),
);
}
}

Now, implement a custom painter called FieldingPositionsPainter in ground_custom_painters.dart:

class FieldingPositionsPainter extends CustomPainter {
final BuildContext context;
final double radius;
final List<FieldingPosition> positions;
final int divisions;
final Paint _linePaint;
FieldingPositionsPainter(
this.context, {
required this.radius,
required this.positions,
required this.divisions,
}) : _linePaint = Paint()
..color = Colors.white
..strokeWidth = 0.7
..style = PaintingStyle.stroke;
@override
void paint(Canvas canvas, Size size) {
final double centerX = size.width / 2;
final double centerY = size.height / 2;
final double angleStep = (2 * pi) / divisions;
// Draw dividers
for (int i = 0; i < divisions; i++) {
final double angle = i * angleStep;
final double startX = centerX + radius * cos(angle);
final double startY = centerY + radius * sin(angle);
final double endX = centerX + radius * cos(angle + pi);
final double endY = centerY + radius * sin(angle + pi);
canvas.drawLine(Offset(startX, startY), Offset(endX, endY), _linePaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}

Code Breakdown:

  • Finding the Center: We calculate centerX and centerY by dividing the width and height by 2, giving us the middle of the circle.
  • Calculating Angle Step: angleStep divides the full circle (360° or 2π radians) by the number of dividers (in this case, 8).
  • Drawing Each Divider: A loop runs for each divider:
    Angle: For each divider, we calculate its angle using i * angleStep (where i is the current divider).
    Start Point: We find the line’s start point (startX, startY) using the radius and angle.
    End Point: The end point (endX, endY) is 180° opposite the start.
    Rendering: Finally, we draw the line on the canvas using drawLine and our custom paint style (_linePaint).

Here’s our cricket ground — looks ready for action! Just waiting for someone to bring the snacks! 🏏🍿

Screenshot by author

Step 2: Displaying fielding positions

With the ground layout in place, it’s time to add fielding position labels. Update the paint method in FieldingPositionsPainter with the following code:

final paint = Paint()
..color = Colors.orange
..style = PaintingStyle.fill;
final textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
// Draw side labels
for (var side in Side.values) {
textPainter.text = TextSpan(
text: side.getString(),
style: const TextStyle(fontSize: 14),
);
drawLabelsAndPosition(
canvas,
paint,
textPainter,
centerX,
centerY,
angle: side.angle,
distance: radius / 2,
isHeader: true,
);
}
// Draw fielding positions and labels
for (var position in positions) {
if (position.showOnScreen) {
final positionAngle = (position.startAngle + position.endAngle) / 2;
double distance = radius / position.distance.divisor;
textPainter.text = TextSpan(
text: position.type.getString(),
style: const TextStyle(fontSize: 14),
);
drawLabelsAndPosition(
canvas,
paint,
textPainter,
centerX,
centerY,
angle: positionAngle,
distance: distance,
isHeader: false,
);
}
}

// Function drawLabelsAndPosition Definition
void drawLabelsAndPosition(
Canvas canvas,
Paint paint,
TextPainter textPainter,
double centerX,
double centerY, {
required double distance,
required double angle,
required bool isHeader,
}) {
final radius = this.radius - 12;
final angleRadians = angle * pi / 180;
final x = centerX + distance * cos(angleRadians);
final y = centerY + distance * sin(angleRadians);
textPainter.layout(maxWidth: 70);
final textWidth = textPainter.width;
final textHeight = textPainter.height;
// Adjust the position to ensure the text is inside the circle
double adjustedX = x;
double adjustedY = y;
// Calculate the boundary check offsets
if (adjustedX + textWidth / 2 > centerX + radius) {
adjustedX = centerX + radius - textWidth / 2;
} else if (adjustedX - textWidth / 2 < centerX - radius) {
adjustedX = centerX - radius + textWidth / 2;
}
if (adjustedY + textHeight / 2 > centerY + radius) {
adjustedY = centerY + radius - textHeight / 2;
} else if (adjustedY - textHeight / 2 < centerY - radius) {
adjustedY = centerY - radius + textHeight / 2;
}
if (!isHeader) {
// draw position indicator
canvas.drawCircle(Offset(x, y), 3, paint);
}
textPainter.paint(canvas, Offset(adjustedX - textWidth / 2, adjustedY + 1));
}

Code Breakdown:

  • Paint Setup: A Paint object defines the graphics' color and style.
  • TextPainter: A TextPainter manages text positioning and styling.
  • Drawing Side Labels:
    ╴ The code loops through sides of the cricket ground (north, south, etc.) and sets the text for each using textPainter.
    ╴ It calls drawLabelsAndPosition to place the text based on angle and distance from the center.
  • Drawing Fielding Positions:
    ╴ It checks which fielding positions to display and calculates position angle and distance from the center.
    ╴ The text for each position is set, followed by a call to drawLabelsAndPosition.
  • drawLabelsAndPosition Function:
    ╴ This function takes care of calculating where to draw the text and the position indicators (like dots).
    ╴ It converts the angle from degrees to radians (needed for trigonometric calculations).
    ╴ It calculates the x and y coordinates where the text and position indicators will be drawn.
    ╴ The function adjusts the positions to make sure they fit within the circle’s boundaries.
    ╴ If it’s not a header, a small circle is drawn to indicate the position.
    ╴ Finally, the text is drawn on the canvas at the adjusted position

Here’s a preview of what you’ll see after putting it all together:

Screenshot by author

Step 3: Detecting fielding positions with taps

To start tracking where the ball lands, we need to capture the fielding position when the user taps on the screen. Let’s add the following functions to the GroundLayoutView to achieve this:

FieldingPosition? _getSelectedPosition(double angle, double distance) {
final filteredPositions = positions
.where((position) =>
angle >= position.startAngle && angle < position.endAngle)
.toList();
FieldingPosition? selectedPosition;
double smallestDistanceDifference = double.infinity;
filteredPositions
.sort((a, b) => a.distance.index.compareTo(b.distance.index));
for (var position in filteredPositions) {
final double effectiveRadius = groundRadius / position.distance.divisor;
final double distanceDifference = (effectiveRadius - distance).abs();
if (distance <= effectiveRadius) {
selectedPosition = position;
break;
} else if (distanceDifference < smallestDistanceDifference) {
selectedPosition = position;
smallestDistanceDifference = distanceDifference;
}
}
return selectedPosition;
}

void _onTapUp(TapUpDetails details, Size size) {
final localPosition = details.localPosition;
final double dx = localPosition.dx - size.width / 2;
final double dy = localPosition.dy - size.height / 2;
final double distance = sqrt(dx * dx + dy * dy);
final double angle = (atan2(dy, dx) * (180 / pi) + 360) % 360;
FieldingPosition? selectedPosition = _getSelectedPosition(angle, distance);
if (selectedPosition != null) {
widget.onPositionSelect(selectedPosition);
}
}

Code Breakdown:

  • _getSelectedPosition:
    ╴Filters positions by angle range.
    ╴Loops through filtered positions to calculate an effective radius.
    ╴Selects the position if the tap distance is within this radius; otherwise, finds the closest one
    ╴Returns selected position or null if none match.
  • _onTapUp:
    ╴Calculates the tap position relative to the center.
    ╴Computes the distance and angle using the atan2 function.
    ╴Calls _getSelectedPosition to find the corresponding fielding position.
    ╴If a position is found, triggers the onPositionSelect callback with the selected position.

call function in the tap up event of Gesture detector:

GestureDetector(
onTapUp: (details) => _onTapUp(details, context.size!),
child: ...
),

Tapping now accurately identifies fielding positions!!

Screen recording by author

Step 4: add animation on tap

Now that we can detect fielding positions with taps, it’s time to bring our cricket ground to life with some animations! 🎉 To do this, we need to:

4.1 Conform to SingleTickerProviderStateMixin: This will allow us to create animations in our GroundLayoutView. Add the necessary variables and initialize them in initState. Here’s how to get started:

class _GroundLayoutViewState extends State<GroundLayoutView>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
Offset? _endOffset;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = Tween<double>(begin: 0, end: 1).animate(_controller)
..addListener(() {
setState(() {});
});
}
}

4.2 Add CustomPaint to Stack for Linear Animation:

Now, we’ll add the CustomPaint widget to our layout to visualize the linear animation.

CustomPaint(
painter: LinePainter(
endOffset: _endOffset,
progress: _animation.value,
strokeColor: Colors.blueAccent),
child: CircleAvatar(
radius: groundRadius,
backgroundColor: Colors.transparent,
))

4.3 Define the LinearPainter:

class LinePainter extends CustomPainter {
final Offset? endOffset;
final double progress;
final Color strokeColor;
LinePainter({
this.endOffset,
required this.progress,
required this.strokeColor,
});
@override
void paint(Canvas canvas, Size size) {
// Calculate the center of the widget
final startOffset = Offset(size.width / 2, size.height / 2);
if (endOffset != null) {
final paint = Paint()
..color = strokeColor
..strokeCap = StrokeCap.round
..strokeWidth = 3.0;
// Calculate the currentOffset based on the progress
final currentOffset = Offset(
startOffset.dx + (endOffset!.dx - startOffset.dx) * progress,
startOffset.dy + (endOffset!.dy - startOffset.dy) * progress,
);
// Draw the line from startOffset to currentOffset
canvas.drawLine(startOffset, currentOffset, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}

Code Breakdown:

  • Center Calculation: Determines the widget’s center using startOffset.
  • Paint Setup: Creates a Paint object with specified color and stroke properties if endOffset is available.
  • Current Position: Calculates currentOffset by interpolating between startOffset and endOffset.
  • Draw Line: Draws a line from startOffset to currentOffset on the canvas.

4.4 Update the _onTapUp Function:

Finally, add this setState call at the end of the _onTapUp function in your GroundLayoutView:

setState(() {
_endOffset = details.localPosition;
_controller.reset();
_controller.forward();
});

Phew! That was quite a bit of work, but we did it! 🎉 Call your GroundLayoutView and see the magic happen!

Screen recording by author

If you want to explore the full code or catch up on anything you might have missed, check it out here.

I hope this blog has been helpful for you! Remember, while this code works great, there are endless optimisations we can make to enhance this feature even further.

Until next time, happy coding! If you enjoyed this post, please give it a clap!👏 Thanks for reading, and see you soon! 👋

--

--

Sidhdhi Canopas
Sidhdhi Canopas

No responses yet