Skip to content

Commit feb5b72

Browse files
committed
feat: add configurable minimum frame duration
1 parent 6214b14 commit feb5b72

6 files changed

Lines changed: 109 additions & 8 deletions

File tree

‎cached_network_image/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## [4.1.0] - 2026-10-27
2+
3+
* Add an opt-in `minFrameDuration` setting for short frames in animated images, including WebP and APNG
4+
15
## [4.0.5] - 2026-10-17
26

37
* Stop decoding a static image again each time a listener is re-added, which blanked images on web (Firefox and Safari) after navigating back to a route

‎cached_network_image/lib/src/cached_image_widget.dart‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ class CachedNetworkImage extends StatelessWidget {
199199
/// Listener to be called when images fails to load.
200200
final ValueChanged<Object>? errorListener;
201201

202+
/// Minimum duration for frames in animated images, including WebP and APNG.
203+
///
204+
/// Frames with a duration of 10ms or less are displayed for at least this
205+
/// duration, and never less than 100ms. Defaults to null, which preserves
206+
/// the duration reported by the image codec.
207+
final Duration? minFrameDuration;
208+
202209
/// CachedNetworkImage shows a network image using a caching mechanism. It also
203210
/// provides support for a placeholder, showing an error and fading into the
204211
/// loaded image. Next to that it supports most features of a default Image
@@ -236,6 +243,7 @@ class CachedNetworkImage extends StatelessWidget {
236243
ImageRenderMethodForWeb imageRenderMethodForWeb =
237244
ImageRenderMethodForWeb.HtmlImage,
238245
double scale = 1.0,
246+
this.minFrameDuration,
239247
}) : _image = CachedNetworkImageProvider(
240248
imageUrl,
241249
headers: httpHeaders,
@@ -246,6 +254,7 @@ class CachedNetworkImage extends StatelessWidget {
246254
maxHeight: maxHeightDiskCache,
247255
errorListener: errorListener,
248256
scale: scale,
257+
minFrameDuration: minFrameDuration,
249258
);
250259

251260
@override

‎cached_network_image/lib/src/image_provider/cached_network_image_provider.dart‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class CachedNetworkImageProvider
2929
this.cacheManager,
3030
this.cacheKey,
3131
this.imageRenderMethodForWeb = ImageRenderMethodForWeb.HtmlImage,
32+
this.minFrameDuration,
3233
});
3334

3435
/// CacheManager from which the image files are loaded.
@@ -63,6 +64,13 @@ class CachedNetworkImageProvider
6364
/// Render option for images on the web platform.
6465
final ImageRenderMethodForWeb imageRenderMethodForWeb;
6566

67+
/// Minimum duration for frames in animated images, including WebP and APNG.
68+
///
69+
/// Frames with a duration of 10ms or less are displayed for at least this
70+
/// duration, and never less than 100ms. Defaults to null, which preserves
71+
/// the duration reported by the image codec.
72+
final Duration? minFrameDuration;
73+
6674
@override
6775
Future<CachedNetworkImageProvider> obtainKey(
6876
ImageConfiguration configuration,
@@ -81,6 +89,7 @@ class CachedNetworkImageProvider
8189
codec: _loadBufferAsync(key, chunkEvents, decode),
8290
chunkEvents: chunkEvents.stream,
8391
scale: key.scale,
92+
minFrameDuration: key.minFrameDuration,
8493
debugLabel: key.url,
8594
informationCollector: () => <DiagnosticsNode>[
8695
DiagnosticsProperty<ImageProvider>('Image provider', this),
@@ -133,6 +142,7 @@ class CachedNetworkImageProvider
133142
codec: _loadImageAsync(key, chunkEvents, decode),
134143
chunkEvents: chunkEvents.stream,
135144
scale: key.scale,
145+
minFrameDuration: key.minFrameDuration,
136146
debugLabel: key.url,
137147
informationCollector: () => <DiagnosticsNode>[
138148
DiagnosticsProperty<ImageProvider>('Image provider', this),
@@ -180,13 +190,15 @@ class CachedNetworkImageProvider
180190
return ((cacheKey ?? url) == (other.cacheKey ?? other.url)) &&
181191
scale == other.scale &&
182192
maxHeight == other.maxHeight &&
183-
maxWidth == other.maxWidth;
193+
maxWidth == other.maxWidth &&
194+
minFrameDuration == other.minFrameDuration;
184195
}
185196
return false;
186197
}
187198

188199
@override
189-
int get hashCode => Object.hash(cacheKey ?? url, scale, maxHeight, maxWidth);
200+
int get hashCode => Object.hash(
201+
cacheKey ?? url, scale, maxHeight, maxWidth, minFrameDuration);
190202

191203
@override
192204
String toString() => 'CachedNetworkImageProvider("$url", scale: $scale)';

‎cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import 'package:flutter/scheduler.dart';
99
double get timeDilation => _timeDilation;
1010
double _timeDilation = 1;
1111

12+
// Browsers commonly clamp animated image frame delays below 10ms to 100ms.
13+
const Duration _browserMinimumFrameDelay = Duration(milliseconds: 10);
14+
const Duration _browserFrameDelayFallback = Duration(milliseconds: 100);
15+
1216
/// An ImageStreamCompleter with support for loading multiple images.
1317
class MultiImageStreamCompleter extends ImageStreamCompleter {
1418
/// The constructor to create an MultiImageStreamCompleter. The [codec]
@@ -18,6 +22,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
1822
MultiImageStreamCompleter({
1923
required Stream<ui.Codec> codec,
2024
required this._scale,
25+
this.minFrameDuration,
2126
String? debugLabel,
2227
Stream<ImageChunkEvent>? chunkEvents,
2328
InformationCollector? informationCollector,
@@ -57,6 +62,13 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
5762
}
5863
}
5964

65+
/// Minimum duration for frames in animated images, including WebP and APNG.
66+
///
67+
/// Frames with a duration of 10ms or less are displayed for at least this
68+
/// duration, and never less than 100ms. If null, frame durations are used
69+
/// without adjustment.
70+
final Duration? minFrameDuration;
71+
6072
ui.Codec? _codec;
6173
ui.Codec? _nextImageCodec;
6274
final double _scale;
@@ -108,6 +120,14 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
108120
_frameCallbackScheduled = false;
109121
if (!hasListeners || _nextFrame == null) return;
110122
if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) {
123+
final frameDuration = _nextFrame!.duration;
124+
_frameDuration = minFrameDuration != null &&
125+
frameDuration <= _browserMinimumFrameDelay &&
126+
frameDuration < minFrameDuration!
127+
? minFrameDuration! > _browserFrameDelayFallback
128+
? minFrameDuration!
129+
: _browserFrameDelayFallback
130+
: frameDuration;
111131
_emitFrame(
112132
ImageInfo(
113133
image: _nextFrame!.image,
@@ -116,11 +136,6 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
116136
),
117137
);
118138
_shownTimestamp = timestamp;
119-
_frameDuration = _nextFrame!.duration;
120-
121-
if (_frameDuration! <= const Duration(milliseconds: 10)) {
122-
_frameDuration = const Duration(milliseconds: 100);
123-
}
124139

125140
_nextFrame = null;
126141
if (_framesEmitted % _codec!.frameCount == 0 && _nextImageCodec != null) {

‎cached_network_image/pubspec.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ topics:
66
- cache
77
- image
88
- network-image
9-
version: 4.0.5
9+
version: 4.1.0
1010

1111
environment:
1212
sdk: ^3.12.0

‎cached_network_image/test/image_stream_completer_test.dart‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,67 @@ void main() {
9090
expect(tester.takeException(), 'failure message');
9191
});
9292

93+
testWidgets('minimum frame duration clamps 0ms and 10ms, not 11ms', (
94+
WidgetTester tester,
95+
) async {
96+
final codecStream = StreamController<Codec>();
97+
final mockCodec = MockCodec()..frameCount = 5;
98+
final images = <Image>[];
99+
final completer = MultiImageStreamCompleter(
100+
codec: codecStream.stream,
101+
scale: 1.0,
102+
minFrameDuration: const Duration(milliseconds: 100),
103+
);
104+
completer.addListener(
105+
ImageStreamListener((ImageInfo info, bool synchronousCall) {
106+
images.add(info.image);
107+
}),
108+
);
109+
codecStream.add(mockCodec);
110+
await tester.idle();
111+
112+
mockCodec.completeNextFrame(
113+
FakeFrameInfo(const Duration(milliseconds: 200), image20x10),
114+
);
115+
await tester.pump();
116+
expect(images, <Image>[image20x10]);
117+
118+
// The zero-duration frame must be held for at least 100ms.
119+
mockCodec.completeNextFrame(FakeFrameInfo(Duration.zero, image200x100));
120+
await tester.idle();
121+
await tester.pump(const Duration(milliseconds: 199));
122+
expect(images, hasLength(1));
123+
await tester.pump(const Duration(milliseconds: 1));
124+
expect(images, hasLength(2));
125+
126+
mockCodec.completeNextFrame(
127+
FakeFrameInfo(const Duration(milliseconds: 10), image300x100),
128+
);
129+
await tester.idle();
130+
await tester.pump(const Duration(milliseconds: 99));
131+
expect(images, hasLength(2));
132+
await tester.pump(const Duration(milliseconds: 1));
133+
expect(images, hasLength(3));
134+
135+
// The 11ms frame is not clamped, so the following frame appears after
136+
// 11ms rather than 100ms.
137+
mockCodec.completeNextFrame(
138+
FakeFrameInfo(const Duration(milliseconds: 11), image20x10),
139+
);
140+
await tester.idle();
141+
await tester.pump(Duration.zero);
142+
expect(images, hasLength(4));
143+
144+
mockCodec.completeNextFrame(
145+
FakeFrameInfo(const Duration(milliseconds: 200), image200x100),
146+
);
147+
await tester.idle();
148+
await tester.pump(const Duration(milliseconds: 10));
149+
expect(images, hasLength(4));
150+
await tester.pump(const Duration(milliseconds: 1));
151+
expect(images, hasLength(5));
152+
});
153+
93154
test('Completer unsubscribes to chunk events when disposed', () async {
94155
final codecStream = StreamController<Codec>();
95156
final chunkStream = StreamController<ImageChunkEvent>();

0 commit comments

Comments
 (0)