Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Build a Tap Heat Map Analytics Tool in FlutterFlow

To build a heat map in FlutterFlow, wrap your screens with a GestureDetector Custom Widget that captures tap coordinates, normalizes them by screen dimensions, and logs them to a Firestore tap_events collection. A Cloud Function aggregates the data into a grid, and a second Custom Widget renders colored circles on a Canvas overlay to visualize hot spots.

What you'll learn

  • How to capture tap coordinates with GestureDetector in a Custom Widget
  • Why you must normalize coordinates by screen size before logging
  • How to store tap events in Firestore with a timestamp and page identifier
  • How to render a heat map overlay using Flutter's CustomPainter
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read40-60 minFlutterFlow Pro+ (code export required)March 2026RapidDev Engineering Team
TL;DR

To build a heat map in FlutterFlow, wrap your screens with a GestureDetector Custom Widget that captures tap coordinates, normalizes them by screen dimensions, and logs them to a Firestore tap_events collection. A Cloud Function aggregates the data into a grid, and a second Custom Widget renders colored circles on a Canvas overlay to visualize hot spots.

Building User Interaction Heat Maps in FlutterFlow

Heat maps reveal where users actually tap — not where you assume they tap. FlutterFlow has no built-in analytics visualization, but you can build one using Flutter's GestureDetector to capture taps, Firestore to store events, and CustomPainter to render the heat map overlay. The critical technical detail is coordinate normalization: raw pixel coordinates are useless across different screen sizes unless you convert them to percentages of the screen width and height before storing. This tutorial builds the full pipeline from tap capture to visual overlay.

Prerequisites

  • FlutterFlow Pro plan with code export enabled
  • Firebase project with Firestore database configured
  • At least one page in FlutterFlow you want to track
  • Basic understanding of FlutterFlow Custom Widgets and Custom Actions
  • Firebase billing enabled (Blaze plan) for Cloud Functions aggregation

Step-by-step guide

1

Create the Firestore tap_events collection schema

In the Firebase console, navigate to Firestore Database and create a new collection named tap_events. Each document should have these fields: page (String — the screen name), x_pct (Number — tap X as percentage of screen width, 0.0 to 1.0), y_pct (Number — tap Y as percentage of screen height, 0.0 to 1.0), session_id (String — a UUID per app session), created_at (Timestamp), and device (String — 'ios' or 'android'). In FlutterFlow's Firestore schema editor (left sidebar → Firestore), add the tap_events collection with these fields so FlutterFlow can type-check references to it.

Expected result: The tap_events collection appears in both the Firebase console and the FlutterFlow Firestore schema editor with all five fields defined.

2

Build the tap-capture Custom Widget with coordinate normalization

In FlutterFlow Custom Code, create a new Custom Widget named TapCaptureOverlay. This widget wraps its child in a GestureDetector with onTapUp. In the onTapUp callback, retrieve the tap position from TapUpDetails.globalPosition, divide x by MediaQuery.of(context).size.width and y by MediaQuery.of(context).size.height to get normalized percentages. Then call a Firestore write to tap_events with the normalized values. The widget accepts two parameters: pageName (String) and child (Widget). It is transparent and only captures taps, passing them through to the child so the underlying UI remains fully interactive.

tap_capture_overlay.dart
1import 'package:flutter/material.dart';
2import 'package:cloud_firestore/cloud_firestore.dart';
3import 'package:uuid/uuid.dart';
4
5class TapCaptureOverlay extends StatefulWidget {
6 final String pageName;
7 final Widget child;
8 final String sessionId;
9
10 const TapCaptureOverlay({
11 Key? key,
12 required this.pageName,
13 required this.child,
14 required this.sessionId,
15 }) : super(key: key);
16
17 @override
18 State<TapCaptureOverlay> createState() => _TapCaptureOverlayState();
19}
20
21class _TapCaptureOverlayState extends State<TapCaptureOverlay> {
22 Future<void> _logTap(TapUpDetails details) async {
23 final size = MediaQuery.of(context).size;
24 final xPct = details.globalPosition.dx / size.width;
25 final yPct = details.globalPosition.dy / size.height;
26
27 await FirebaseFirestore.instance.collection('tap_events').add({
28 'page': widget.pageName,
29 'x_pct': xPct,
30 'y_pct': yPct,
31 'session_id': widget.sessionId,
32 'created_at': FieldValue.serverTimestamp(),
33 'device': Theme.of(context).platform == TargetPlatform.iOS
34 ? 'ios'
35 : 'android',
36 });
37 }
38
39 @override
40 Widget build(BuildContext context) {
41 return GestureDetector(
42 behavior: HitTestBehavior.translucent,
43 onTapUp: _logTap,
44 child: widget.child,
45 );
46 }
47}

Expected result: Tapping anywhere on a page wrapped with TapCaptureOverlay writes a new document to the tap_events collection with normalized x_pct and y_pct values between 0 and 1.

3

Generate a session ID and wrap your page with the capture overlay

In FlutterFlow, add an App State variable named currentSessionId (String, default empty). On your app's initialization (or in the first page's On Page Load action), call a Custom Action named generateSessionId that uses the uuid package to create a new UUID and stores it in currentSessionId. On each page you want to track, add the TapCaptureOverlay Custom Widget as the root element of the page and pass currentSessionId as the sessionId parameter and the page route name as pageName. This ensures every tap is associated with the user's current session for funnel analysis.

Expected result: Each app session has a consistent UUID, and all tap events logged during that session share the same session_id value in Firestore.

4

Build the heat map visualization Custom Widget with CustomPainter

Create a second Custom Widget named HeatMapOverlay. This widget accepts a list of tap points (normalized x and y percentages) and a size parameter. It uses a CustomPainter to draw semi-transparent colored circles at each tap location on a transparent canvas. Scale each point back to pixel coordinates by multiplying x_pct by the canvas width and y_pct by the canvas height. Color the circles using a radial gradient from red (center, hot) to transparent (edge, cool). Increase opacity for clusters by drawing multiple overlapping circles — where many taps overlap, the combined opacity approaches full red, creating the classic heat map effect.

heat_map_overlay.dart
1import 'package:flutter/material.dart';
2
3class HeatMapPoint {
4 final double xPct;
5 final double yPct;
6 const HeatMapPoint(this.xPct, this.yPct);
7}
8
9class HeatMapOverlay extends StatelessWidget {
10 final List<HeatMapPoint> points;
11 final double radius;
12
13 const HeatMapOverlay({
14 Key? key,
15 required this.points,
16 this.radius = 40.0,
17 }) : super(key: key);
18
19 @override
20 Widget build(BuildContext context) {
21 return CustomPaint(
22 painter: _HeatMapPainter(points: points, radius: radius),
23 child: const SizedBox.expand(),
24 );
25 }
26}
27
28class _HeatMapPainter extends CustomPainter {
29 final List<HeatMapPoint> points;
30 final double radius;
31
32 _HeatMapPainter({required this.points, required this.radius});
33
34 @override
35 void paint(Canvas canvas, Size size) {
36 for (final point in points) {
37 final dx = point.xPct * size.width;
38 final dy = point.yPct * size.height;
39 final paint = Paint()
40 ..shader = RadialGradient(
41 colors: [
42 Colors.red.withOpacity(0.35),
43 Colors.orange.withOpacity(0.15),
44 Colors.transparent,
45 ],
46 stops: const [0.0, 0.5, 1.0],
47 ).createShader(Rect.fromCircle(
48 center: Offset(dx, dy),
49 radius: radius,
50 ));
51 canvas.drawCircle(Offset(dx, dy), radius, paint);
52 }
53 }
54
55 @override
56 bool shouldRepaint(_HeatMapPainter old) => old.points != points;
57}

Expected result: The HeatMapOverlay widget renders colored gradient circles on a transparent canvas, with denser areas appearing more intensely red.

5

Create a Cloud Function to aggregate tap events into a grid

For large tap datasets (over 10,000 events), fetching every document to the client is slow and expensive. Create a Firebase Cloud Function named aggregateTapEvents that accepts a pageName and a grid resolution (e.g., 20x20 cells). The function queries all tap_events for that page, buckets each point into its grid cell (Math.floor(x_pct * resolution)), counts the cell occupancy, and returns a compact JSON array of {cellX, cellY, count} objects. In FlutterFlow, call this Cloud Function from a Custom Action and convert the grid cells back to screen percentage coordinates before passing them to HeatMapOverlay.

Expected result: The Cloud Function returns a compact grid array instead of thousands of individual documents, and the admin heat map page loads in under 2 seconds.

Complete working example

heat_map_admin_page.dart
1// Admin page widget showing heat map for a selected page
2// Place this in a Custom Widget named HeatMapAdminView
3import 'package:flutter/material.dart';
4import 'package:cloud_firestore/cloud_firestore.dart';
5
6class HeatMapPoint {
7 final double xPct;
8 final double yPct;
9 const HeatMapPoint(this.xPct, this.yPct);
10}
11
12class HeatMapAdminView extends StatefulWidget {
13 final String pageName;
14 final String screenshotUrl;
15
16 const HeatMapAdminView({
17 Key? key,
18 required this.pageName,
19 required this.screenshotUrl,
20 }) : super(key: key);
21
22 @override
23 State<HeatMapAdminView> createState() => _HeatMapAdminViewState();
24}
25
26class _HeatMapAdminViewState extends State<HeatMapAdminView> {
27 List<HeatMapPoint> _points = [];
28 bool _loading = true;
29
30 @override
31 void initState() {
32 super.initState();
33 _loadPoints();
34 }
35
36 Future<void> _loadPoints() async {
37 final snapshot = await FirebaseFirestore.instance
38 .collection('tap_events')
39 .where('page', isEqualTo: widget.pageName)
40 .limit(2000)
41 .get();
42
43 setState(() {
44 _points = snapshot.docs.map((doc) {
45 return HeatMapPoint(
46 (doc['x_pct'] as num).toDouble(),
47 (doc['y_pct'] as num).toDouble(),
48 );
49 }).toList();
50 _loading = false;
51 });
52 }
53
54 @override
55 Widget build(BuildContext context) {
56 if (_loading) {
57 return const Center(child: CircularProgressIndicator());
58 }
59 return Stack(
60 children: [
61 Positioned.fill(
62 child: Image.network(
63 widget.screenshotUrl,
64 fit: BoxFit.contain,
65 ),
66 ),
67 Positioned.fill(
68 child: CustomPaint(
69 painter: _HeatPainter(points: _points),
70 ),
71 ),
72 Positioned(
73 bottom: 16,
74 right: 16,
75 child: Container(
76 padding: const EdgeInsets.all(8),
77 color: Colors.black54,
78 child: Text(
79 '${_points.length} taps logged',
80 style: const TextStyle(color: Colors.white),
81 ),
82 ),
83 ),
84 ],
85 );
86 }
87}
88
89class _HeatPainter extends CustomPainter {
90 final List<HeatMapPoint> points;
91 _HeatPainter({required this.points});
92
93 @override
94 void paint(Canvas canvas, Size size) {
95 for (final p in points) {
96 final paint = Paint()
97 ..shader = RadialGradient(
98 colors: [Colors.red.withOpacity(0.4), Colors.transparent],
99 ).createShader(Rect.fromCircle(
100 center: Offset(p.xPct * size.width, p.yPct * size.height),
101 radius: 36,
102 ));
103 canvas.drawCircle(
104 Offset(p.xPct * size.width, p.yPct * size.height),
105 36,
106 paint,
107 );
108 }
109 }
110
111 @override
112 bool shouldRepaint(_HeatPainter old) => old.points != points;
113}

Common mistakes when building a Tap Heat Map Analytics Tool in FlutterFlow

Why it's a problem: Logging tap events with raw pixel coordinates instead of normalized percentages

How to avoid: Always divide x by MediaQuery.of(context).size.width and y by MediaQuery.of(context).size.height before storing. Store values between 0.0 and 1.0.

Why it's a problem: Fetching all tap_events documents to the client for visualization

How to avoid: Use a Cloud Function to aggregate events into a compact grid before sending to the client, or use a Firestore query limit and date filter to show only recent events.

Why it's a problem: Not setting HitTestBehavior.translucent on the GestureDetector

How to avoid: Always set behavior: HitTestBehavior.translucent in your GestureDetector so taps are captured AND forwarded to child widgets.

Why it's a problem: Writing one Firestore document per tap without any batching

How to avoid: Buffer taps locally in a list and write a batch to Firestore every 10 taps or every 30 seconds using WriteBatch, whichever comes first.

Best practices

  • Normalize all tap coordinates to 0.0-1.0 percentages before storing so the data is device-independent.
  • Add a Firestore index on (page, created_at) to enable fast time-ranged queries for your heat map admin panel.
  • Set Firestore security rules to allow tap_events writes only for authenticated users and restrict reads to admin roles only.
  • Include a sampling rate option — for high-traffic apps, log only 1 in 5 taps by generating a random number in onTapUp and skipping writes when it exceeds your threshold.
  • Store a screenshot URL in Firestore for each tracked page so the admin can overlay the heat map on the correct background image.
  • Use Firestore TTL policies to auto-delete tap events older than 90 days, keeping storage costs manageable.
  • Test the heat map overlay on both portrait and landscape orientations since the coordinate system changes with device rotation.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I am building a Flutter app and want to log tap coordinates to Firestore for heat map analytics. Explain how to normalize tap coordinates by screen size, write them to Firestore, and render the data as a CustomPainter heat map overlay.

FlutterFlow Prompt

Create a Custom Widget in FlutterFlow that wraps a page in a GestureDetector, captures onTapUp coordinates normalized by screen size, and logs them to a Firestore collection named tap_events with fields: page, x_pct, y_pct, session_id, and created_at.

Frequently asked questions

Why do I need to normalize tap coordinates before storing them?

Different devices have different screen sizes. A tap at pixel (342, 891) on an iPhone SE represents a different UI location than the same pixels on a large Android tablet. Normalizing to percentages (0.0-1.0) makes coordinates device-independent so you can aggregate data across all users.

Does GestureDetector block taps from reaching buttons underneath it?

Only if you use the default HitTestBehavior. Set behavior: HitTestBehavior.translucent in your GestureDetector to capture the tap event for logging while also forwarding it to the widgets below.

How many tap events will a typical Firestore heat map project accumulate?

An app with 100 daily active users tapping 60 times per session generates around 6,000 documents per day. After 30 days that is 180,000 documents. Plan your Firestore read costs and set up TTL policies to auto-delete old events.

Can I build this without FlutterFlow Pro?

No. The GestureDetector wrapper and CustomPainter heat map renderer both require Custom Widgets with custom Dart code, which requires code export — a Pro plan feature.

How do I show the heat map overlaid on the actual screen?

Take a screenshot of the tracked page (or export it as an image from the FlutterFlow canvas), upload it to Firebase Storage, and use it as the background in a Stack with the HeatMapOverlay Custom Widget on top in your admin page.

Can I track scroll position in addition to taps?

Yes. Wrap your ScrollView in a NotificationListener widget and listen for ScrollUpdateNotification events. Log the scroll offset normalized by the total scrollable height alongside the tap events.

How do I filter the heat map by date range in the admin view?

Add a date range picker to your admin page and pass the start and end dates as Timestamp filters to your Firestore query: .where('created_at', isGreaterThan: startDate).where('created_at', isLessThan: endDate). Make sure you have a composite index on (page, created_at).

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.