mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
516 lines
18 KiB
Dart
516 lines
18 KiB
Dart
// 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.
|
|
|
|
import 'dart:math' as math;
|
|
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';
|
|
|
|
/// 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.
|
|
/// * 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));
|
|
///
|
|
/// 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,
|
|
/// ), const Duration(seconds: 1));
|
|
///
|
|
/// await gesture.up();
|
|
///
|
|
/// await tester.pumpFrames(animationSheet.record(
|
|
/// target,
|
|
/// recording: true,
|
|
/// ), 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), // Does not support Web. See [AnimationSheetBuilder].
|
|
assert(frameSize != null);
|
|
|
|
/// 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<Future<ui.Image>> _recordedFrames = <Future<ui.Image>>[];
|
|
Future<List<ui.Image>> get _frames async {
|
|
final List<ui.Image> frames = await Future.wait<ui.Image>(_recordedFrames, eagerError: true);
|
|
assert(() {
|
|
for (final ui.Image frame in frames) {
|
|
assert(frame.width == frameSize.width && frame.height == frameSize.height,
|
|
'Unexpected size mismatch: frame has (${frame.width}, ${frame.height}) '
|
|
'while `frameSize` is $frameSize.'
|
|
);
|
|
}
|
|
return true;
|
|
}());
|
|
return frames;
|
|
}
|
|
|
|
/// 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 `recording` defaults to true, which means the painted result of each
|
|
/// frame will be stored and later available for [display]. If `recording` is
|
|
/// false, then frames are not recorded. This is useful during the setup phase
|
|
/// that shouldn't be recorded; if the target widget isn't wrapped in [record]
|
|
/// during the setup phase, the states will be lost when it starts recording.
|
|
///
|
|
/// The `child` must not be null.
|
|
///
|
|
/// 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,
|
|
}) {
|
|
assert(child != null);
|
|
return _AnimationSheetRecorder(
|
|
key: key,
|
|
size: frameSize,
|
|
allLayers: allLayers,
|
|
handleRecorded: recording ? _recordedFrames.add : null,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
/// Constructs a widget that renders the recorded frames in an animation sheet.
|
|
///
|
|
/// The resulting widget takes as much space as its parent allows, which is
|
|
/// usually the screen size. It is then filled with all recorded frames, each
|
|
/// having a size specified by [frameSize], chronologically from top-left to
|
|
/// bottom-right in a row-major order.
|
|
///
|
|
/// This widget does not check whether its size fits all recorded frames.
|
|
/// Having too many frames can cause overflow errors, while having too few can
|
|
/// waste the size of golden files. Therefore you should usually adjust the
|
|
/// viewport size to [sheetSize] before calling this method.
|
|
///
|
|
/// The `key` is applied to the root widget.
|
|
///
|
|
/// This method can only be called if at least one frame has been recorded.
|
|
///
|
|
/// The [display] is the legacy way of acquiring the output for comparison.
|
|
/// It is not recommended because it requires more boilerplate, and produces
|
|
/// a much large image than necessary: each pixel is rendered in 3x3 pixels
|
|
/// without higher definition. Use [collate] instead.
|
|
///
|
|
/// Using this way includes the following steps:
|
|
///
|
|
/// * Create an instance of this class.
|
|
/// * Pump frames that render the target widget wrapped in [record]. Every frame
|
|
/// that has `recording` being true will be recorded.
|
|
/// * Adjust the size of the test viewport to the [sheetSize] (see the
|
|
/// documentation of [sheetSize] for more information).
|
|
/// * Pump a frame that renders [display], which shows all recorded frames in an
|
|
/// animation sheet, and can be matched against the golden test.
|
|
///
|
|
/// {@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));
|
|
///
|
|
/// 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,
|
|
/// ), const Duration(seconds: 1));
|
|
///
|
|
/// await gesture.up();
|
|
///
|
|
/// await tester.pumpFrames(animationSheet.record(
|
|
/// target,
|
|
/// recording: true,
|
|
/// ), const Duration(seconds: 1));
|
|
///
|
|
/// // Adjust view port size
|
|
/// tester.binding.setSurfaceSize(animationSheet.sheetSize());
|
|
///
|
|
/// // Display
|
|
/// final Widget display = await animationSheet.display();
|
|
/// await tester.pumpWidget(display);
|
|
///
|
|
/// // Compare against golden file
|
|
/// await expectLater(
|
|
/// find.byWidget(display),
|
|
/// matchesGoldenFile('inkwell.press.animation.png'),
|
|
/// );
|
|
/// }, skip: isBrowser); // Animation sheet does not support browser https://github.com/flutter/flutter/issues/56001
|
|
/// ```
|
|
/// {@end-tool}
|
|
@Deprecated(
|
|
'Use AnimationSheetBuilder.collate instead. '
|
|
'This feature was deprecated after v2.3.0-13.0.pre.',
|
|
)
|
|
Future<Widget> display({Key? key}) async {
|
|
assert(_recordedFrames.isNotEmpty);
|
|
final List<ui.Image> frames = await _frames;
|
|
return _CellSheet(
|
|
key: key,
|
|
cellSize: frameSize,
|
|
children: frames.map((ui.Image image) => RawImage(
|
|
image: image.clone(),
|
|
width: frameSize.width,
|
|
height: frameSize.height,
|
|
// Disable quality enhancement because the point of this class is to
|
|
// precisely record what the widget looks like.
|
|
filterQuality: ui.FilterQuality.none,
|
|
)).toList(),
|
|
);
|
|
}
|
|
|
|
/// Returns an result image by putting all frames together in a table.
|
|
///
|
|
/// This method returns a table of captured frames, `cellsPerRow` images
|
|
/// per row, from left to right, top to bottom.
|
|
///
|
|
/// An example of using this method can be found at [AnimationSheetBuilder].
|
|
Future<ui.Image> collate(int cellsPerRow) async {
|
|
return _collateFrames(await _frames, frameSize, cellsPerRow);
|
|
}
|
|
|
|
/// Returns the smallest size that can contain all recorded frames.
|
|
///
|
|
/// This is used to adjust the viewport during unit tests, i.e. the size of
|
|
/// virtual screen. Having too many frames recorded than the default viewport
|
|
/// size can contain will lead to overflow errors, while having too few frames
|
|
/// means the golden file might be larger than necessary.
|
|
///
|
|
/// The [sheetSize] returns the smallest possible size by placing the
|
|
/// recorded frames, each of which has a size specified by [frameSize], in a
|
|
/// row-major grid with a maximum width specified by `maxWidth`, and returns
|
|
/// the size of that grid.
|
|
///
|
|
/// Setting the viewport size during a widget test usually involves
|
|
/// [TestWidgetsFlutterBinding.setSurfaceSize] and [WidgetTester.binding].
|
|
///
|
|
/// The `maxWidth` defaults to the width of the default viewport, 800.0.
|
|
///
|
|
/// This method can only be called if at least one frame has been recorded.
|
|
@Deprecated(
|
|
'The `sheetSize` is only useful for `display`, which should be migrated to `collate`. '
|
|
'This feature was deprecated after v2.3.0-13.0.pre.',
|
|
)
|
|
Size sheetSize({double maxWidth = _kDefaultTestViewportWidth}) {
|
|
assert(_recordedFrames.isNotEmpty);
|
|
final int cellsPerRow = (maxWidth / frameSize.width).floor();
|
|
final int rowNum = (_recordedFrames.length / cellsPerRow).ceil();
|
|
final double width = math.min(cellsPerRow, _recordedFrames.length) * frameSize.width;
|
|
return Size(width, frameSize.height * rowNum);
|
|
}
|
|
|
|
// The width of _kDefaultTestViewportSize in [TestViewConfiguration].
|
|
static const double _kDefaultTestViewportWidth = 800.0;
|
|
}
|
|
|
|
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,
|
|
Key? key,
|
|
}) : super(key: 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({
|
|
Key? key,
|
|
Widget? child,
|
|
this.callback,
|
|
}) : super(key: key, child: child);
|
|
|
|
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<ui.Image> frames, Size frameSize, int cellsPerRow) async {
|
|
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(),
|
|
);
|
|
}
|
|
return recorder.endRecording().toImage(
|
|
(frameSize.width * cellsPerRow).toInt(),
|
|
(frameSize.height * rowNum).toInt(),
|
|
);
|
|
}
|
|
|
|
// Layout children in a grid of fixed-sized cells.
|
|
//
|
|
// The sheet fills up as much space as the parent allows. The cells are
|
|
// positioned from top left to bottom right in a row-major order.
|
|
class _CellSheet extends StatelessWidget {
|
|
_CellSheet({
|
|
Key? key,
|
|
required this.cellSize,
|
|
required this.children,
|
|
}) : assert(cellSize != null),
|
|
assert(children != null && children.isNotEmpty),
|
|
super(key: key);
|
|
|
|
final Size cellSize;
|
|
final List<Widget> children;
|
|
|
|
@override
|
|
Widget build(BuildContext _context) {
|
|
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
|
|
final double rowWidth = constraints.biggest.width;
|
|
final int cellsPerRow = (rowWidth / cellSize.width).floor();
|
|
final List<Widget> rows = <Widget>[];
|
|
for (int rowStart = 0; rowStart < children.length; rowStart += cellsPerRow) {
|
|
final Iterable<Widget> rowTargets = children.sublist(rowStart, math.min(rowStart + cellsPerRow, children.length));
|
|
rows.add(Row(
|
|
textDirection: TextDirection.ltr,
|
|
children: rowTargets.map((Widget target) => SizedBox.fromSize(
|
|
size: cellSize,
|
|
child: target,
|
|
)).toList(),
|
|
));
|
|
}
|
|
return Column(
|
|
textDirection: TextDirection.ltr,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: rows,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
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({ Key? key, Widget? child }) : super(key: key, child: child);
|
|
|
|
@override
|
|
_RenderRootableRepaintBoundary createRenderObject(BuildContext context) => _RenderRootableRepaintBoundary();
|
|
}
|