diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index ce5e5cab8a8..d1e62578d42 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -13,6 +13,7 @@ import 'basic.dart'; import 'framework.dart'; import 'localizations.dart'; import 'media_query.dart'; +import 'ticker_provider.dart'; export 'package:flutter/services.dart' show ImageProvider, @@ -445,10 +446,17 @@ class Image extends StatefulWidget { class _ImageState extends State { ImageStream _imageStream; ImageInfo _imageInfo; + bool _isListeningToStream = false; @override void didChangeDependencies() { _resolveImage(); + + if (TickerMode.of(context)) + _listenToStream(); + else + _stopListeningToStream(); + super.didChangeDependencies(); } @@ -466,18 +474,13 @@ class _ImageState extends State { } void _resolveImage() { - final ImageStream oldImageStream = _imageStream; - _imageStream = widget.image.resolve(createLocalImageConfiguration( - context, - size: widget.width != null && widget.height != null ? new Size(widget.width, widget.height) : null - )); - assert(_imageStream != null); - if (_imageStream.key != oldImageStream?.key) { - oldImageStream?.removeListener(_handleImageChanged); - if (!widget.gaplessPlayback) - setState(() { _imageInfo = null; }); - _imageStream.addListener(_handleImageChanged); - } + final ImageStream newStream = + widget.image.resolve(createLocalImageConfiguration( + context, + size: widget.width != null && widget.height != null ? new Size(widget.width, widget.height) : null + )); + assert(newStream != null); + _updateSourceStream(newStream); } void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) { @@ -486,10 +489,42 @@ class _ImageState extends State { }); } + // Update _imageStream to newStream, and moves the stream listener + // registration from the old stream to the new stream (if a listener was + // registered). + void _updateSourceStream(ImageStream newStream) { + if (_imageStream?.key == newStream?.key) + return; + + if (_isListeningToStream) + _imageStream.removeListener(_handleImageChanged); + + _imageStream = newStream; + if (_isListeningToStream) + _imageStream.addListener(_handleImageChanged); + + if (!widget.gaplessPlayback) + setState(() { _imageInfo = null; }); + } + + void _listenToStream() { + if (_isListeningToStream) + return; + _imageStream.addListener(_handleImageChanged); + _isListeningToStream = true; + } + + void _stopListeningToStream() { + if (!_isListeningToStream) + return; + _imageStream.removeListener(_handleImageChanged); + _isListeningToStream = false; + } + @override void dispose() { assert(_imageStream != null); - _imageStream.removeListener(_handleImageChanged); + _stopListeningToStream(); super.dispose(); } diff --git a/packages/flutter/test/services/fake_codec.dart b/packages/flutter/test/services/fake_codec.dart new file mode 100644 index 00000000000..6d2b5fcde08 --- /dev/null +++ b/packages/flutter/test/services/fake_codec.dart @@ -0,0 +1,49 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui show Codec, FrameInfo, instantiateImageCodec; + +import 'package:flutter/foundation.dart'; + +/// A [ui.Codec] implementation for testing that pre-fetches all the image +/// frames, and provides synchronous [getNextFrame] implementation. +/// +/// This is useful for running in the test Zone, where it is tricky to receive +/// callbacks originating from the IO thread. +class FakeCodec extends ui.Codec { + final int _frameCount; + final int _repetitionCount; + final List _frameInfos; + int _nextFrame = 0; + + FakeCodec._(this._frameCount, this._repetitionCount, this._frameInfos); + + /// Creates a FakeCodec from encoded image data. + /// + /// Only call this method outside of the test zone. + static Future fromData(Uint8List data) async { + final ui.Codec codec = await ui.instantiateImageCodec(data); + final int frameCount = codec.frameCount; + final List frameInfos = new List(frameCount); + for (int i = 0; i < frameCount; i += 1) + frameInfos[i] = await codec.getNextFrame(); + return new FakeCodec._(frameCount, codec.repetitionCount, frameInfos); + } + + @override + int get frameCount => _frameCount; + + @override + int get repetitionCount => _repetitionCount; + + @override + Future getNextFrame() { + final SynchronousFuture result = + new SynchronousFuture(_frameInfos[_nextFrame]); + _nextFrame = (_nextFrame + 1) % _frameCount; + return result; + } +} diff --git a/packages/flutter/test/services/fake_image_provider.dart b/packages/flutter/test/services/fake_image_provider.dart new file mode 100644 index 00000000000..71983c93a30 --- /dev/null +++ b/packages/flutter/test/services/fake_image_provider.dart @@ -0,0 +1,36 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:ui' as ui show Codec; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// An image provider implementation for testing that is using a [ui.Codec] +/// that it was given at construction time (typically the job of real image +/// providers is to resolve some data and instantiate a [ui.Codec] from it). +class FakeImageProvider extends ImageProvider { + + const FakeImageProvider(this._codec, { this.scale: 1.0 }); + + final ui.Codec _codec; + + /// The scale to place in the [ImageInfo] object of the image. + final double scale; + + @override + Future obtainKey(ImageConfiguration configuration) { + return new SynchronousFuture(this); + } + + @override + ImageStreamCompleter load(FakeImageProvider key) { + assert(key == this); + return new MultiFrameImageStreamCompleter( + codec: new SynchronousFuture(_codec), + scale: scale + ); + } +} diff --git a/packages/flutter/test/services/image_data.dart b/packages/flutter/test/services/image_data.dart index 02680e2c376..6e8b30cd697 100644 --- a/packages/flutter/test/services/image_data.dart +++ b/packages/flutter/test/services/image_data.dart @@ -9,3 +9,17 @@ const List kTransparentImage = const [ 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, ]; + +/// An animated GIF image with 3 1x1 pixel frames (a red, green, and blue +/// frames). The gif animates forever, and each frame has a 100ms delay. +const List kAnimatedGif = const [ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0xa1, 0x03, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0xff, 0xff, 0xff, 0x21, + 0xff, 0x0b, 0x4e, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2e, 0x30, + 0x03, 0x01, 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x00, 0x0a, 0x00, 0xff, 0x00, + 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x4c, + 0x01, 0x00, 0x21, 0xf9, 0x04, 0x00, 0x0a, 0x00, 0xff, 0x00, 0x2c, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x54, 0x01, 0x00, 0x21, + 0xf9, 0x04, 0x00, 0x0a, 0x00, 0xff, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, +]; diff --git a/packages/flutter/test/widgets/image_test.dart b/packages/flutter/test/widgets/image_test.dart index 7a487d8418e..af4216d8de1 100644 --- a/packages/flutter/test/widgets/image_test.dart +++ b/packages/flutter/test/widgets/image_test.dart @@ -342,12 +342,40 @@ void main() { stream.addListener((ImageInfo image, bool sync) { isSync = sync; }); expect(isSync, isTrue); }); + + testWidgets('TickerMode controls stream registration', (WidgetTester tester) async { + final TestImageStreamCompleter imageStreamCompleter = new TestImageStreamCompleter(); + final Image image = new Image( + image: new TestImageProvider(streamCompleter: imageStreamCompleter), + ); + await tester.pumpWidget( + new TickerMode( + enabled: true, + child: image, + ), + ); + expect(imageStreamCompleter.listeners.length, 1); + await tester.pumpWidget( + new TickerMode( + enabled: false, + child: image, + ), + ); + expect(imageStreamCompleter.listeners.length, 0); + }); + } class TestImageProvider extends ImageProvider { final Completer _completer = new Completer(); + ImageStreamCompleter _streamCompleter; ImageConfiguration _lastResolvedConfiguration; + TestImageProvider({ImageStreamCompleter streamCompleter}) { + _streamCompleter = streamCompleter + ?? new OneFrameImageStreamCompleter(_completer.future); + } + @override Future obtainKey(ImageConfiguration configuration) { return new SynchronousFuture(this); @@ -360,7 +388,7 @@ class TestImageProvider extends ImageProvider { } @override - ImageStreamCompleter load(TestImageProvider key) => new OneFrameImageStreamCompleter(_completer.future); + ImageStreamCompleter load(TestImageProvider key) => _streamCompleter; void complete() { _completer.complete(new ImageInfo(image: new TestImage())); @@ -370,6 +398,20 @@ class TestImageProvider extends ImageProvider { String toString() => '${describeIdentity(this)}()'; } +class TestImageStreamCompleter extends ImageStreamCompleter { + final List listeners = []; + + @override + void addListener(ImageListener listener) { + listeners.add(listener); + } + + @override + void removeListener(ImageListener listener) { + listeners.remove(listener); + } +} + class TestImage extends ui.Image { @override int get width => 100; diff --git a/packages/flutter/test/widgets/obscured_animated_image.dart b/packages/flutter/test/widgets/obscured_animated_image.dart new file mode 100644 index 00000000000..1a449314aa4 --- /dev/null +++ b/packages/flutter/test/widgets/obscured_animated_image.dart @@ -0,0 +1,48 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; +import 'dart:ui' as ui show Image; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../services/fake_codec.dart'; +import '../services/fake_image_provider.dart'; +import '../services/image_data.dart'; + +Future main() async { + final FakeCodec fakeCodec = await FakeCodec.fromData(new Uint8List.fromList(kAnimatedGif)); + final FakeImageProvider fakeImageProvider = new FakeImageProvider(fakeCodec); + + testWidgets('Obscured image does not animate', (WidgetTester tester) async { + final GlobalKey imageKey = new GlobalKey(); + await tester.pumpWidget( + new MaterialApp( + home: new Image(image: fakeImageProvider, key: imageKey), + routes: { + '/page': (BuildContext context) => new Container() + } + ) + ); + final RenderImage renderImage = tester.renderObject(find.byType(Image)); + final ui.Image image1 = renderImage.image; + await tester.pump(const Duration(milliseconds: 100)); + final ui.Image image2 = renderImage.image; + expect(image1, isNot(same(image2))); + + + Navigator.pushNamed(imageKey.currentContext, '/page'); + await tester.pump(); // Starts the page animation. + await tester.pump(const Duration(seconds: 1)); // Let the page animation complete. + + // The image is now obscured by another page, it should not be changing + // frames. + final ui.Image image3 = renderImage.image; + await tester.pump(const Duration(milliseconds: 100)); + final ui.Image image4 = renderImage.image; + expect(image3, same(image4)); + }); +}