| // 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 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 | |
| /// [display]. If neither condition is met, the frames are not recorded, which | |
| /// is useful during setup phases. | |
| /// | |
| /// 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 { | |
| final List<ui.Image> frames = await _frames; | |
| assert(frames.isNotEmpty, | |
| 'No frames are collected. Have you forgot to set `recording` to true?'); | |
| return _collateFrames(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, | |
| 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<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({ | |
| super.key, | |
| required this.cellSize, | |
| required this.children, | |
| }) : assert(cellSize != null), | |
| assert(children != null && children.isNotEmpty); | |
| 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({ super.key, super.child }); | |
| @override | |
| _RenderRootableRepaintBoundary createRenderObject(BuildContext context) => _RenderRootableRepaintBoundary(); | |
| } |