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
Create the Firestore tap_events collection schema
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.
Build the tap-capture Custom Widget with coordinate normalization
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.
1import 'package:flutter/material.dart';2import 'package:cloud_firestore/cloud_firestore.dart';3import 'package:uuid/uuid.dart';45class TapCaptureOverlay extends StatefulWidget {6 final String pageName;7 final Widget child;8 final String sessionId;910 const TapCaptureOverlay({11 Key? key,12 required this.pageName,13 required this.child,14 required this.sessionId,15 }) : super(key: key);1617 @override18 State<TapCaptureOverlay> createState() => _TapCaptureOverlayState();19}2021class _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;2627 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.iOS34 ? 'ios'35 : 'android',36 });37 }3839 @override40 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.
Generate a session ID and wrap your page with the capture overlay
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.
Build the heat map visualization Custom Widget with CustomPainter
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.
1import 'package:flutter/material.dart';23class HeatMapPoint {4 final double xPct;5 final double yPct;6 const HeatMapPoint(this.xPct, this.yPct);7}89class HeatMapOverlay extends StatelessWidget {10 final List<HeatMapPoint> points;11 final double radius;1213 const HeatMapOverlay({14 Key? key,15 required this.points,16 this.radius = 40.0,17 }) : super(key: key);1819 @override20 Widget build(BuildContext context) {21 return CustomPaint(22 painter: _HeatMapPainter(points: points, radius: radius),23 child: const SizedBox.expand(),24 );25 }26}2728class _HeatMapPainter extends CustomPainter {29 final List<HeatMapPoint> points;30 final double radius;3132 _HeatMapPainter({required this.points, required this.radius});3334 @override35 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 }5455 @override56 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.
Create a Cloud Function to aggregate tap events into a grid
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
1// Admin page widget showing heat map for a selected page2// Place this in a Custom Widget named HeatMapAdminView3import 'package:flutter/material.dart';4import 'package:cloud_firestore/cloud_firestore.dart';56class HeatMapPoint {7 final double xPct;8 final double yPct;9 const HeatMapPoint(this.xPct, this.yPct);10}1112class HeatMapAdminView extends StatefulWidget {13 final String pageName;14 final String screenshotUrl;1516 const HeatMapAdminView({17 Key? key,18 required this.pageName,19 required this.screenshotUrl,20 }) : super(key: key);2122 @override23 State<HeatMapAdminView> createState() => _HeatMapAdminViewState();24}2526class _HeatMapAdminViewState extends State<HeatMapAdminView> {27 List<HeatMapPoint> _points = [];28 bool _loading = true;2930 @override31 void initState() {32 super.initState();33 _loadPoints();34 }3536 Future<void> _loadPoints() async {37 final snapshot = await FirebaseFirestore.instance38 .collection('tap_events')39 .where('page', isEqualTo: widget.pageName)40 .limit(2000)41 .get();4243 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 }5354 @override55 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}8889class _HeatPainter extends CustomPainter {90 final List<HeatMapPoint> points;91 _HeatPainter({required this.points});9293 @override94 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 }110111 @override112 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.
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.
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).
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation