Interactive Cricket Shot Tracking in Flutter
Building an Interactive Cricket Ground: Step by Step
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.
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 theGroundLayoutView
Stateful Widget.ground_custom_painters.dart
: Includes CustomPainters for this task, specificallyFieldingPositionsPainter
andLinearPainter
.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
andcenterY
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 usingi * angleStep
(wherei
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 usingdrawLine
and our custom paint style (_linePaint
).
Here’s our cricket ground — looks ready for action! Just waiting for someone to bring the snacks! 🏏🍿
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 usingtextPainter
.
╴ It callsdrawLabelsAndPosition
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 todrawLabelsAndPosition
. 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:
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 theatan2
function.
╴Calls_getSelectedPosition
to find the corresponding fielding position.
╴If a position is found, triggers theonPositionSelect
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!!
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 betweenstartOffset
andendOffset
. - Draw Line: Draws a line from
startOffset
tocurrentOffset
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!
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! 👋