Recommendations Viewability
Recommendations.fetch returns data and you render the list, so the SDK cannot see
what is on screen. Viewable impressions are not reported unless you drive
RecommendationsViewabilityReporter. Without it, recommendation placements earn nothing.
This applies only to Recommendations. Feed, Media, Banner and Interstitial report
their own viewability with no work on your part.
How It Works
The reporter measures each tracked item's on-screen geometry and reports an impression once the item has been at least 50% visible, continuously, for its viewability threshold (1000 ms by default). Falling below 50% resets the accumulated time to zero rather than pausing it — the same rule both native SDKs apply.
Reports are batched into one request every 2 seconds, and each item is reported at most once for the life of the reporter.
Basic Usage
Create one reporter per list, track each rendered item, and dispose when the list
goes away.
class _RecommendationsListState extends State<RecommendationsList> {
final _reporter = RecommendationsViewabilityReporter();
final _keys = <int, GlobalKey>{};
List<TeadsRecommendation> _recommendations = const [];
void initState() {
super.initState();
_fetch();
}
Future<void> _fetch() async {
final recommendations = await Recommendations.fetch(
RecommendationsPlacementConfig.url(
widgetId: 'SDK_1',
articleUrl: widget.articleUrl,
),
);
if (!mounted) return;
setState(() => _recommendations = recommendations);
}
Widget build(BuildContext context) {
return ListView.builder(
itemCount: _recommendations.length,
itemBuilder: (context, index) {
final rec = _recommendations[index];
final key = _keys[index] ??= GlobalKey();
_reporter.track(key, rec);
return RecommendationTile(key: key, recommendation: rec);
},
);
}
void dispose() {
_reporter.dispose();
super.dispose();
}
}
dispose()The reporter owns a batching timer plus one polling timer per tracked item. Nothing else releases them — a leaked reporter keeps polling for the life of the app.
The GlobalKey Requirement
The reporter measures the geometry of a real render object, so each tracked item needs a
GlobalKey attached to its widget. Two rules follow:
Keys must be stable per item. Creating a new GlobalKey on every build makes the
reporter start over each frame and nothing ever reaches the threshold. Cache them, as
above.
Pass the key to the widget you actually want measured — the tile itself, not a wrapper that is larger or smaller than the visible card.
// ✅ Stable key per index, cached
final key = _keys[index] ??= GlobalKey();
// ❌ New key every build — nothing is ever reported
final key = GlobalKey();
Recycling keys
When a GlobalKey is about to be reused for a different item, untrack it first so its
polling timer is released:
void _rebind(int index, GlobalKey key, TeadsRecommendation rec) {
_reporter.untrack(key);
_reporter.track(key, rec);
}
track on an already-tracked key replaces the tracked recommendation, so this is not
strictly required for correctness — but it releases the old timer promptly.
Safe to Call Repeatedly
track no-ops rather than double-reporting when:
- the item has no
reqIdor an unparseableposition - viewability reporting is disabled server-side for that fetch
- this exact item was already reported
So calling it from itemBuilder on every build — as the example does — is correct and
intended.
Tuning
RecommendationsViewabilityReporter(
reportingInterval: Duration(milliseconds: 2000), // batching cadence
pollInterval: Duration(milliseconds: 250), // per-item measurement
)
The defaults match native. Lower pollInterval for more precise timing at more CPU
cost; raise it to save battery in a long list. The 50% / 1000 ms threshold itself is
server-driven and not configurable here.
Refreshing the List
Fetching again produces new items with new reqId values. Create a new reporter for
a new fetch, or reuse the existing one — dedup is keyed on the item, not the reporter, so
reuse is safe. Dispose the old one if you replace it:
Future<void> _refresh() async {
final recommendations = await Recommendations.fetch(config);
if (!mounted) return;
setState(() {
_recommendations = recommendations;
_keys.clear(); // new items need new keys
});
}
Complete Example
import 'package:flutter/material.dart';
import 'package:teads_flutter/teads_flutter.dart';
class RecommendationsSection extends StatefulWidget {
const RecommendationsSection({super.key, required this.articleUrl});
final String articleUrl;
State<RecommendationsSection> createState() => _RecommendationsSectionState();
}
class _RecommendationsSectionState extends State<RecommendationsSection> {
final _reporter = RecommendationsViewabilityReporter();
final _keys = <int, GlobalKey>{};
List<TeadsRecommendation> _recommendations = const [];
Object? _error;
void initState() {
super.initState();
_fetch();
}
Future<void> _fetch() async {
try {
final recommendations = await Recommendations.fetch(
RecommendationsPlacementConfig.url(
widgetId: 'SDK_1',
articleUrl: widget.articleUrl,
),
);
if (!mounted) return;
setState(() => _recommendations = recommendations);
} catch (error) {
if (!mounted) return;
setState(() => _error = error);
}
}
Future<void> _onTap(TeadsRecommendation rec) async {
// Registers the click AND resolves the URL. Only call this on a real tap.
final url = await Recommendations.registerClick(rec);
if (url == null || !mounted) return;
// Open `url` however your app opens links.
}
void dispose() {
_reporter.dispose();
super.dispose();
}
Widget build(BuildContext context) {
if (_error != null || _recommendations.isEmpty) {
return const SizedBox.shrink(); // no-fill is normal — collapse quietly
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _recommendations.length,
itemBuilder: (context, index) {
final rec = _recommendations[index];
final key = _keys[index] ??= GlobalKey();
_reporter.track(key, rec);
return ListTile(
key: key,
leading: rec.thumbnail?.url != null
? Image.network(rec.thumbnail!.url!, width: 80, fit: BoxFit.cover)
: null,
title: Text(rec.content ?? ''),
subtitle: Text(rec.sourceName ?? ''),
trailing: rec.shouldDisplayDisclosureIcon &&
rec.disclosure?.iconUrl != null
? Image.network(rec.disclosure!.iconUrl!, width: 16, height: 16)
: null,
onTap: () => _onTap(rec),
);
},
);
}
}
Disclosure Icons
When shouldDisplayDisclosureIcon is true, render disclosure.iconUrl on the item.
It is a regulatory requirement for paid recommendations, not decoration — tapping it
should open disclosure.clickUrl.
Known Limitations
These are documented rather than hidden, so you can judge whether they matter to you:
- Visibility is measured against screen height, assuming a full-width, unclipped item. Accurate for a typical full-width card; it is not a general occlusion or clipping check, so an item hidden behind an overlay may still be counted.
- Reported
timeElapsedis measured from just after the fetch completes, whereas native measures from just before the request goes out. The value therefore undercounts by roughly one fetch round-trip. - Native's separate
reportViewedping is not replicated — that URL is not exposed through the current fetch payload.
Troubleshooting
No impressions reported
- Confirm
dispose()is not being called early - Confirm the
GlobalKeys are cached, not created per build - Confirm the key is attached to the visible tile widget
- Confirm items have a
reqId—debugPrint(rec.reqId) - Confirm the item is genuinely at least 50% visible for a full second
- Raise the log level:
TeadsLog.level = LogLevel.debugand watch theRecommendationsViewabilitytag
Impressions reported for off-screen items
Check for an overlay covering the list — see the limitation above.
Every item reports at once
Usually a shrinkWrap: true list inside another scroll view where all items are laid
out and considered on screen. Confirm your list actually clips to the viewport.
Related Documentation
- Integration Guide — fetching and rendering
- Settings & Configuration — the reporter's constructor arguments
- Logging — the
RecommendationsViewabilitylog tag