Skip to main content

Recommendations Viewability

Required for revenue on recommendation placements

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();
}
}
Always call 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 reqId or an unparseable position
  • 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 timeElapsed is 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 reportViewed ping is not replicated — that URL is not exposed through the current fetch payload.

Troubleshooting​

No impressions reported​

  1. Confirm dispose() is not being called early
  2. Confirm the GlobalKeys are cached, not created per build
  3. Confirm the key is attached to the visible tile widget
  4. Confirm items have a reqId — debugPrint(rec.reqId)
  5. Confirm the item is genuinely at least 50% visible for a full second
  6. Raise the log level: TeadsLog.level = LogLevel.debug and watch the RecommendationsViewability tag

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.