Unverified Commit fb94d3fb authored by Tong Mu's avatar Tong Mu Committed by GitHub

Animation sheet recorder (#55527)

parent 533cd7a6
// 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:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
/*
* Here lies tests for packages/flutter_test/lib/src/animation_sheet.dart
* because [matchesGoldenFile] does not use Skia Gold in its native package.
*/
testWidgets('correctly records frames', (WidgetTester tester) async {
final AnimationSheetBuilder builder = AnimationSheetBuilder(frameSize: _DecuplePixels.size);
await tester.pumpFrames(
builder.record(
const _DecuplePixels(Duration(seconds: 1)),
),
const Duration(milliseconds: 200),
const Duration(milliseconds: 100),
);
await tester.pumpFrames(
builder.record(
const _DecuplePixels(Duration(seconds: 1)),
recording: false,
),
const Duration(milliseconds: 200),
const Duration(milliseconds: 100),
);
await tester.pumpFrames(
builder.record(
const _DecuplePixels(Duration(seconds: 1)),
recording: true,
),
const Duration(milliseconds: 400),
const Duration(milliseconds: 100),
);
final Widget display = await builder.display();
await tester.binding.setSurfaceSize(builder.sheetSize());
await tester.pumpWidget(display);
await expectLater(find.byWidget(display), matchesGoldenFile('test.animation_sheet_builder.records.png'));
}, skip: isBrowser);
testWidgets('correctly wraps a row', (WidgetTester tester) async {
final AnimationSheetBuilder builder = AnimationSheetBuilder(frameSize: _DecuplePixels.size);
const Duration duration = Duration(seconds: 2);
await tester.pumpFrames(
builder.record(const _DecuplePixels(duration)),
duration,
const Duration(milliseconds: 200),
);
final Widget display = await builder.display();
await tester.binding.setSurfaceSize(builder.sheetSize(maxWidth: 80));
await tester.pumpWidget(display);
await expectLater(find.byWidget(display), matchesGoldenFile('test.animation_sheet_builder.wraps.png'));
}, skip: isBrowser);
}
// An animation of a yellow pixel moving from left to right, in a container of
// (10, 1) with a 1-pixel-wide black border.
class _DecuplePixels extends StatefulWidget {
const _DecuplePixels(this.duration);
static const Size size = Size(12, 3);
final Duration duration;
@override
State<StatefulWidget> createState() => _DecuplePixelsState();
}
class _DecuplePixelsState extends State<_DecuplePixels> with SingleTickerProviderStateMixin {
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_controller.repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller.view,
builder: (BuildContext context, Widget child) {
return CustomPaint(
painter: _PaintDecuplePixels(_controller.value),
);
},
);
}
}
class _PaintDecuplePixels extends CustomPainter {
_PaintDecuplePixels(this.value);
final double value;
@override
bool shouldRepaint(_PaintDecuplePixels oldDelegate) {
return oldDelegate.value != value;
}
@override
void paint(Canvas canvas, Size size) {
canvas.save();
final Rect rect = RectTween(
begin: const Rect.fromLTWH(1, 1, 1, 1),
end: const Rect.fromLTWH(11, 1, 1, 1),
).transform(value);
canvas.drawRect(rect, Paint()..color = Colors.yellow);
final Paint black = Paint()..color = Colors.black;
canvas
// Top border
..drawRect(const Rect.fromLTRB(0, 0, 12, 1), black)
// Bottom border
..drawRect(const Rect.fromLTRB(0, 2, 12, 3), black)
// Left border
..drawRect(const Rect.fromLTRB(0, 0, 1, 3), black)
// Right border
..drawRect(const Rect.fromLTRB(11, 0, 12, 3), black);
canvas.restore();
}
}
......@@ -49,6 +49,7 @@ export 'dart:async' show Future;
export 'src/_goldens_io.dart' if (dart.library.html) 'src/_goldens_web.dart';
export 'src/accessibility.dart';
export 'src/all_elements.dart';
export 'src/animation_sheet.dart';
export 'src/binding.dart';
export 'src/controller.dart';
export 'src/event_simulation.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/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.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.
/// * 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}
///
/// 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.
AnimationSheetBuilder({@required this.frameSize}) : 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;
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,
child: child,
size: frameSize,
handleRecorded: recording ? _recordedFrames.add : null,
);
}
/// 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.
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,
width: frameSize.width,
height: frameSize.height,
)).toList(),
);
}
/// 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.
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,
this.child,
this.size,
Key key,
}) : super(key: key);
final _RecordedHandler handleRecorded;
final Widget child;
final Size size;
@override
State<StatefulWidget> createState() => _AnimationSheetRecorderState();
}
class _AnimationSheetRecorderState extends State<_AnimationSheetRecorder> {
GlobalKey boundaryKey = GlobalKey();
void _record(Duration duration) {
final RenderRepaintBoundary boundary = boundaryKey.currentContext.findRenderObject() as RenderRepaintBoundary;
widget.handleRecorded(boundary.toImage());
}
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: SizedBox.fromSize(
size: widget.size,
child: RepaintBoundary(
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(callback == null ? null : (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'));
}
}
// 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,
);
});
}
}
......@@ -566,6 +566,29 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
}).then<int>((_) => count);
}
/// Repeatedly pump frames that render the `target` widget with a fixed time
/// `interval` as many as `maxDuration` allows.
///
/// The `maxDuration` argument is required. The `interval` argument defaults to
/// 16.683 milliseconds (59.94 FPS).
Future<void> pumpFrames(
Widget target,
Duration maxDuration, [
Duration interval = const Duration(milliseconds: 16, microseconds: 683),
]) {
assert(maxDuration != null);
// The interval following the last frame doesn't have to be within the fullDuration.
Duration elapsed = Duration.zero;
return TestAsyncUtils.guard<void>(() async {
binding.attachRootWidget(target);
binding.scheduleFrame();
while (elapsed < maxDuration) {
await binding.pump(interval);
elapsed += interval;
}
});
}
/// Runs a [callback] that performs real asynchronous work.
///
/// This is intended for callers who need to call asynchronous methods where
......
......@@ -244,7 +244,9 @@ void main() {
expect(message, contains('Actual: _TextFinder:<exactly one widget with text "foo" (ignoring offstage widgets): Text("foo", textDirection: ltr)>\n'));
expect(message, contains('Which: means one was found but none were expected\n'));
});
});
group('pumping', () {
testWidgets('pumping', (WidgetTester tester) async {
await tester.pumpWidget(const Text('foo', textDirection: TextDirection.ltr));
int count;
......@@ -278,6 +280,28 @@ void main() {
count = await tester.pumpAndSettle(const Duration(seconds: 1));
expect(count, 6);
});
testWidgets('pumpFrames', (WidgetTester tester) async {
final List<int> logPaints = <int>[];
int initial;
final Widget target = _AlwaysAnimating(
onPaint: () {
final int current = SchedulerBinding.instance.currentFrameTimeStamp.inMicroseconds;
initial ??= current;
logPaints.add(current - initial);
},
);
await tester.pumpFrames(target, const Duration(milliseconds: 55));
expect(logPaints, <int>[0, 17000, 34000, 50000]);
logPaints.clear();
await tester.pumpFrames(target, const Duration(milliseconds: 30), const Duration(milliseconds: 10));
expect(logPaints, <int>[60000, 70000, 80000]);
});
});
group('find.byElementPredicate', () {
......@@ -793,3 +817,63 @@ class _SingleTickerTestState extends State<_SingleTickerTest> with SingleTickerP
return Container();
}
}
class _AlwaysAnimating extends StatefulWidget {
const _AlwaysAnimating({
this.child,
this.onPaint,
});
final Widget child;
final VoidCallback onPaint;
@override
State<StatefulWidget> createState() => _AlwaysAnimatingState();
}
class _AlwaysAnimatingState extends State<_AlwaysAnimating> with SingleTickerProviderStateMixin {
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_controller.repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller.view,
builder: (BuildContext context, Widget child) {
return CustomPaint(
painter: _AlwaysRepaint(widget.onPaint),
child: widget.child,
);
},
);
}
}
class _AlwaysRepaint extends CustomPainter {
_AlwaysRepaint(this.onPaint);
final VoidCallback onPaint;
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
@override
void paint(Canvas canvas, Size size) {
onPaint();
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment