blob: c6d32d75cbc849e6eee5207826d8fd271a7f1ba7 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// @docImport 'package:flutter/material.dart';
///
/// @docImport 'goldens.dart';
/// @docImport 'widget_tester.dart';
library;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
// A Future<ui.Image> that stores the resolved result.
class _AsyncImage {
_AsyncImage(Future<ui.Image> task) {
_task = task.then((ui.Image image) {
_result = image;
});
}
// Returns the resolved image.
Future<ui.Image> result() async {
if (_result != null) {
return _result!;
}
await _task;
assert(_result != null);
return _result!;
}
late final Future<void> _task;
ui.Image? _result;
// Wait for a list of `_AsyncImage` and returns the list of its resolved
// images.
static Future<List<ui.Image>> resolveList(List<_AsyncImage> targets) {
final Iterable<Future<ui.Image>> images = targets.map<Future<ui.Image>>(
(_AsyncImage target) => target.result());
return Future.wait<ui.Image>(images);
}
}
/// Records the frames of an animating widget, and later displays the frames as a
/// grid in an animation sheet.
///
/// This class does not support Web, because the animation sheet utilizes taking
/// screenshots, which is unsupported on the Web. Tests that use this class must
/// be noted with `skip: isBrowser`.
/// (https://github.com/flutter/flutter/issues/56001)
///
/// Using this class includes the following steps:
///
/// * Create an instance of this class.
/// * Register [dispose] to the test's tear down callbacks.
/// * Pump frames that render the target widget wrapped in [record]. Every frame
/// that has `recording` being true will be recorded.
/// * Acquire the output image with [collate] and compare against the golden
/// file.
///
/// {@tool snippet}
/// The following example shows how to record an animation sheet of an [InkWell]
/// being pressed then released.
///
/// ```dart
/// testWidgets('Inkwell animation sheet', (WidgetTester tester) async {
/// // Create instance
/// final AnimationSheetBuilder animationSheet = AnimationSheetBuilder(frameSize: const Size(48, 24));
/// addTearDown(animationSheet.dispose);
///
/// final Widget target = Material(
/// child: Directionality(
/// textDirection: TextDirection.ltr,
/// child: InkWell(
/// splashColor: Colors.blue,
/// onTap: () {},
/// ),
/// ),
/// );
///
/// // Optional: setup before recording (`recording` is false)
/// await tester.pumpWidget(animationSheet.record(
/// target,
/// recording: false,
/// ));
///
/// final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(InkWell)));
///
/// // Start recording (`recording` is true)
/// await tester.pumpFrames(animationSheet.record(
/// target,
/// recording: true, // ignore: avoid_redundant_argument_values
/// ), const Duration(seconds: 1));
///
/// await gesture.up();
///
/// await tester.pumpFrames(animationSheet.record(
/// target,
/// recording: true, // ignore: avoid_redundant_argument_values
/// ), const Duration(seconds: 1));
///
/// // Compare against golden file
/// await expectLater(
/// animationSheet.collate(800),
/// matchesGoldenFile('inkwell.press.animation.png'),
/// );
/// }, skip: isBrowser); // Animation sheet does not support browser https://github.com/flutter/flutter/issues/56001
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [GoldenFileComparator], which introduces Golden File Testing.
class AnimationSheetBuilder {
/// Starts a session of building an animation sheet.
///
/// The [frameSize] is a tight constraint for the child to be recorded, and must not
/// be null.
///
/// The [allLayers] controls whether to record elements drawn out of the subtree,
/// and defaults to false.
AnimationSheetBuilder({
required this.frameSize,
this.allLayers = false,
}) : assert(!kIsWeb);
/// Dispose all recorded frames and result images.
///
/// This method must be called before the test case ends (usually as a tear
/// down callback) to properly deallocate the images.
///
/// After this method is called, there will be no frames to [collate].
Future<void> dispose() async {
final List<_AsyncImage> targets = <_AsyncImage>[
..._recordedFrames,
..._results,
];
_recordedFrames.clear();
_results.clear();
for (final ui.Image image in await _AsyncImage.resolveList(targets)) {
image.dispose();
}
}
/// The size of the child to be recorded.
///
/// This size is applied as a tight layout constraint for the child, and is
/// fixed throughout the building session.
final Size frameSize;
/// Whether the captured image comes from the entire tree, or only the
/// subtree of [record].
///
/// If [allLayers] is false, then the [record] widget will capture the image
/// composited by its subtree. If [allLayers] is true, then the [record] will
/// capture the entire tree composited and clipped by [record]'s region.
///
/// The two modes are identical if there is nothing in front of [record].
/// But in rare cases, what needs to be captured has to be rendered out of
/// [record]'s subtree in its front. By setting [allLayers] to true, [record]
/// captures everything within its region even if drawn outside of its
/// subtree.
///
/// Defaults to false.
final bool allLayers;
final List<_AsyncImage> _recordedFrames = <_AsyncImage>[];
/// Returns a widget that renders a widget in a box that can be recorded.
///
/// The returned widget wraps `child` in a box with a fixed size specified by
/// [frameSize]. The `key` is also applied to the returned widget.
///
/// The frame is only recorded if the `recording` argument is true, or during
/// a procedure that is wrapped within [recording]. In either case, the
/// painted result of each frame will be stored and later available for
/// [collate]. If neither condition is met, the frames are not recorded, which
/// is useful during setup phases.
///
/// See also:
///
/// * [WidgetTester.pumpFrames], which renders a widget in a series of frames
/// with a fixed time interval.
Widget record(Widget child, {
Key? key,
bool recording = true,
}) {
return _AnimationSheetRecorder(
key: key,
size: frameSize,
allLayers: allLayers,
handleRecorded: !recording ? null : (Future<ui.Image> futureImage) {
_recordedFrames.add(_AsyncImage(() async {
final ui.Image image = await futureImage;
assert(image.width == frameSize.width && image.height == frameSize.height,
'Unexpected size mismatch: frame has (${image.width}, ${image.height}) '
'while `frameSize` is $frameSize.'
);
return image;
}()));
},
child: child,
);
}
// The result images generated by `collate`.
//
// They're stored here to be disposed by [dispose].
final List<_AsyncImage> _results = <_AsyncImage>[];
/// Returns an result image by putting all frames together in a table.
///
/// This method returns an image that arranges the captured frames in a table,
/// which has `cellsPerRow` images per row with the order from left to right,
/// top to bottom.
///
/// The result image of this method is managed by [AnimationSheetBuilder],
/// and should not be disposed by the caller.
///
/// An example of using this method can be found at [AnimationSheetBuilder].
Future<ui.Image> collate(int cellsPerRow) async {
assert(_recordedFrames.isNotEmpty,
'No frames are collected. Have you forgot to set `recording` to true?');
final _AsyncImage result = _AsyncImage(_collateFrames(_recordedFrames, frameSize, cellsPerRow));
_results.add(result);
return result.result();
}
}
typedef _RecordedHandler = void Function(Future<ui.Image> image);
class _AnimationSheetRecorder extends StatefulWidget {
const _AnimationSheetRecorder({
this.handleRecorded,
required this.child,
required this.size,
required this.allLayers,
super.key,
});
final _RecordedHandler? handleRecorded;
final Widget child;
final Size size;
final bool allLayers;
@override
State<StatefulWidget> createState() => _AnimationSheetRecorderState();
}
class _AnimationSheetRecorderState extends State<_AnimationSheetRecorder> {
GlobalKey boundaryKey = GlobalKey();
void _record(Duration duration) {
assert(widget.handleRecorded != null);
final _RenderRootableRepaintBoundary boundary = boundaryKey.currentContext!.findRenderObject()! as _RenderRootableRepaintBoundary;
if (widget.allLayers) {
widget.handleRecorded!(boundary.allLayersToImage());
} else {
widget.handleRecorded!(boundary.toImage());
}
}
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: SizedBox.fromSize(
size: widget.size,
child: _RootableRepaintBoundary(
key: boundaryKey,
child: _PostFrameCallbacker(
callback: widget.handleRecorded == null ? null : _record,
child: widget.child,
),
),
),
);
}
}
// Invokes `callback` and [markNeedsPaint] during the post-frame callback phase
// of every frame.
//
// If `callback` is non-null, `_PostFrameCallbacker` adds a post-frame callback
// every time it paints, during which it calls the provided `callback` then
// invokes [markNeedsPaint].
//
// If `callback` is null, `_PostFrameCallbacker` is equivalent to a proxy box.
class _PostFrameCallbacker extends SingleChildRenderObjectWidget {
const _PostFrameCallbacker({
super.child,
this.callback,
});
final FrameCallback? callback;
@override
_RenderPostFrameCallbacker createRenderObject(BuildContext context) => _RenderPostFrameCallbacker(
callback: callback,
);
@override
void updateRenderObject(BuildContext context, _RenderPostFrameCallbacker renderObject) {
renderObject.callback = callback;
}
}
class _RenderPostFrameCallbacker extends RenderProxyBox {
_RenderPostFrameCallbacker({
FrameCallback? callback,
}) : _callback = callback;
FrameCallback? get callback => _callback;
FrameCallback? _callback;
set callback(FrameCallback? value) {
_callback = value;
if (value != null) {
markNeedsPaint();
}
}
@override
void paint(PaintingContext context, Offset offset) {
if (callback != null) {
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
callback!(duration);
markNeedsPaint();
});
}
super.paint(context, offset);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('callback', value: callback != null, ifTrue: 'has a callback'));
}
}
Future<ui.Image> _collateFrames(List<_AsyncImage> futureFrames, Size frameSize, int cellsPerRow) async {
final List<ui.Image> frames = await _AsyncImage.resolveList(futureFrames);
final int rowNum = (frames.length / cellsPerRow).ceil();
final ui.PictureRecorder recorder = ui.PictureRecorder();
final Canvas canvas = Canvas(
recorder,
Rect.fromLTWH(0, 0, frameSize.width * cellsPerRow, frameSize.height * rowNum),
);
for (int i = 0; i < frames.length; i += 1) {
canvas.drawImage(
frames[i],
Offset(frameSize.width * (i % cellsPerRow), frameSize.height * (i / cellsPerRow).floor()),
Paint(),
);
}
final ui.Picture picture = recorder.endRecording();
final ui.Image image = await picture.toImage(
(frameSize.width * cellsPerRow).toInt(),
(frameSize.height * rowNum).toInt(),
);
picture.dispose();
return image;
}
class _RenderRootableRepaintBoundary extends RenderRepaintBoundary {
// Like [toImage], but captures an image of all layers (composited by
// RenderView and its children) clipped by the region of this object.
Future<ui.Image> allLayersToImage() {
final TransformLayer rootLayer = _rootLayer();
final Matrix4 rootTransform = (rootLayer.transform ?? Matrix4.identity()).clone();
final Matrix4 transform = rootTransform.multiplied(getTransformTo(null));
final Rect rect = MatrixUtils.transformRect(transform, Offset.zero & size);
// The scale was used to fit the actual device. Revert it since the target
// is the logical display. Take transform[0] as the scale.
return rootLayer.toImage(rect, pixelRatio: 1 / transform[0]);
}
TransformLayer _rootLayer() {
Layer layer = this.layer!;
while (layer.parent != null) {
layer = layer.parent!;
}
return layer as TransformLayer;
}
}
// A [RepaintBoundary], except that its render object has a `fullscreenToImage` method.
class _RootableRepaintBoundary extends SingleChildRenderObjectWidget {
/// Creates a widget that isolates repaints.
const _RootableRepaintBoundary({ super.key, super.child });
@override
_RenderRootableRepaintBoundary createRenderObject(BuildContext context) => _RenderRootableRepaintBoundary();
}