
This auto-formats all *.dart files in the repository outside of the `engine` subdirectory and enforces that these files stay formatted with a presubmit check. **Reviewers:** Please carefully review all the commits except for the one titled "formatted". The "formatted" commit was auto-generated by running `dev/tools/format.sh -a -f`. The other commits were hand-crafted to prepare the repo for the formatting change. I recommend reviewing the commits one-by-one via the "Commits" tab and avoiding Github's "Files changed" tab as it will likely slow down your browser because of the size of this PR. --------- Co-authored-by: Kate Lovett <katelovett@google.com> Co-authored-by: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com>
399 lines
13 KiB
Dart
399 lines
13 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.
|
|
|
|
/// @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();
|
|
}
|