recording_canvas.dart 7.13 KB
Newer Older
1 2 3 4
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/foundation.dart';
6
import 'package:flutter/rendering.dart';
7
import 'package:flutter/src/rendering/layer.dart';
8

9 10 11 12 13 14 15 16
/// An [Invocation] and the [stack] trace that led to it.
///
/// Used by [TestRecordingCanvas] to trace canvas calls.
class RecordedInvocation {
  /// Create a record for an invocation list.
  const RecordedInvocation(this.invocation, { this.stack });

  /// The method that was called and its arguments.
17 18 19 20 21 22
  ///
  /// The arguments preserve identity, but not value. Thus, if two invocations
  /// were made with the same [Paint] object, but with that object configured
  /// differently each time, then they will both have the same object as their
  /// argument, and inspecting that object will return the object's current
  /// values (mostly likely those passed to the second call).
23 24 25 26 27 28 29 30 31
  final Invocation invocation;

  /// The stack trace at the time of the method call.
  final StackTrace stack;

  @override
  String toString() => _describeInvocation(invocation);

  /// Converts [stack] to a string using the [FlutterError.defaultStackFilter] logic.
32
  String stackToString({ String indent = '' }) {
33 34 35 36 37 38 39
    assert(indent != null);
    return indent + FlutterError.defaultStackFilter(
      stack.toString().trimRight().split('\n')
    ).join('\n$indent');
  }
}

40 41
/// A [Canvas] for tests that records its method calls.
///
42
/// This class can be used in conjunction with [TestRecordingPaintingContext]
43 44 45 46
/// to record the [Canvas] method calls made by a renderer. For example:
///
/// ```dart
/// RenderBox box = tester.renderObject(find.text('ABC'));
47 48
/// TestRecordingCanvas canvas = TestRecordingCanvas();
/// TestRecordingPaintingContext context = TestRecordingPaintingContext(canvas);
49 50 51 52 53
/// box.paint(context, Offset.zero);
/// // Now test the expected canvas.invocations.
/// ```
///
/// In some cases it may be useful to define a subclass that overrides the
54
/// [Canvas] methods the test is checking and squirrels away the parameters
55
/// that the test requires.
56 57 58
///
/// For simple tests, consider using the [paints] matcher, which overlays a
/// pattern matching API over [TestRecordingCanvas].
59 60
class TestRecordingCanvas implements Canvas {
  /// All of the method calls on this canvas.
61
  final List<RecordedInvocation> invocations = <RecordedInvocation>[];
62 63 64 65 66 67 68 69 70

  int _saveCount = 0;

  @override
  int getSaveCount() => _saveCount;

  @override
  void save() {
    _saveCount += 1;
71
    invocations.add(RecordedInvocation(_MethodCall(#save), stack: StackTrace.current));
72 73
  }

74 75 76
  @override
  void saveLayer(Rect bounds, Paint paint) {
    _saveCount += 1;
77
    invocations.add(RecordedInvocation(_MethodCall(#saveLayer, <dynamic>[bounds, paint]), stack: StackTrace.current));
78 79
  }

80 81 82 83
  @override
  void restore() {
    _saveCount -= 1;
    assert(_saveCount >= 0);
84
    invocations.add(RecordedInvocation(_MethodCall(#restore), stack: StackTrace.current));
85 86 87 88
  }

  @override
  void noSuchMethod(Invocation invocation) {
89
    invocations.add(RecordedInvocation(invocation, stack: StackTrace.current));
90 91 92 93
  }
}

/// A [PaintingContext] for tests that use [TestRecordingCanvas].
94
class TestRecordingPaintingContext extends ClipContext implements PaintingContext {
95 96 97 98 99 100 101 102 103 104 105 106
  /// Creates a [PaintingContext] for tests that use [TestRecordingCanvas].
  TestRecordingPaintingContext(this.canvas);

  @override
  final Canvas canvas;

  @override
  void paintChild(RenderObject child, Offset offset) {
    child.paint(this, offset);
  }

  @override
107 108
  ClipRectLayer pushClipRect(bool needsCompositing, Offset offset, Rect clipRect,
      PaintingContextCallback painter, { Clip clipBehavior = Clip.hardEdge, ClipRectLayer oldLayer }) {
109
    clipRectAndPaint(clipRect.shift(offset), clipBehavior, clipRect.shift(offset), () => painter(this, offset));
110
    return null;
111 112
  }

113
  @override
114 115
  ClipRRectLayer pushClipRRect(bool needsCompositing, Offset offset, Rect bounds, RRect clipRRect,
      PaintingContextCallback painter, { Clip clipBehavior = Clip.antiAlias, ClipRRectLayer oldLayer }) {
116 117
    assert(clipBehavior != null);
    clipRRectAndPaint(clipRRect.shift(offset), clipBehavior, bounds.shift(offset), () => painter(this, offset));
118
    return null;
119 120
  }

121
  @override
122 123
  ClipPathLayer pushClipPath(bool needsCompositing, Offset offset, Rect bounds, Path clipPath,
      PaintingContextCallback painter, { Clip clipBehavior = Clip.antiAlias, ClipPathLayer oldLayer }) {
124
    clipPathAndPaint(clipPath.shift(offset), clipBehavior, bounds.shift(offset), () => painter(this, offset));
125
    return null;
126 127
  }

128
  @override
129 130
  TransformLayer pushTransform(bool needsCompositing, Offset offset, Matrix4 transform,
      PaintingContextCallback painter, { TransformLayer oldLayer }) {
131 132 133 134
    canvas.save();
    canvas.transform(transform.storage);
    painter(this, offset);
    canvas.restore();
135
    return null;
136
  }
137 138

  @override
139 140
  OpacityLayer pushOpacity(Offset offset, int alpha, PaintingContextCallback painter,
      { OpacityLayer oldLayer }) {
141 142 143
    canvas.saveLayer(null, null); // TODO(ianh): Expose the alpha somewhere.
    painter(this, offset);
    canvas.restore();
144
    return null;
145
  }
146

147
  @override
148 149
  void pushLayer(Layer childLayer, PaintingContextCallback painter, Offset offset,
      { Rect childPaintBounds }) {
150 151 152
    painter(this, offset);
  }

153
  @override
154
  void noSuchMethod(Invocation invocation) { }
155 156 157
}

class _MethodCall implements Invocation {
158
  _MethodCall(this._name, [ this._arguments = const <dynamic>[], this._typeArguments = const <Type> []]);
159
  final Symbol _name;
160
  final List<dynamic> _arguments;
161
  final List<Type> _typeArguments;
162 163 164 165 166 167 168 169 170 171 172 173 174
  @override
  bool get isAccessor => false;
  @override
  bool get isGetter => false;
  @override
  bool get isMethod => true;
  @override
  bool get isSetter => false;
  @override
  Symbol get memberName => _name;
  @override
  Map<Symbol, dynamic> get namedArguments => <Symbol, dynamic>{};
  @override
175
  List<dynamic> get positionalArguments => _arguments;
176 177
  @override
  List<Type> get typeArguments => _typeArguments;
178
}
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195

String _valueName(Object value) {
  if (value is double)
    return value.toStringAsFixed(1);
  return value.toString();
}

// Workaround for https://github.com/dart-lang/sdk/issues/28372
String _symbolName(Symbol symbol) {
  // WARNING: Assumes a fixed format for Symbol.toString which is *not*
  // guaranteed anywhere.
  final String s = '$symbol';
  return s.substring(8, s.length - 2);
}

// Workaround for https://github.com/dart-lang/sdk/issues/28373
String _describeInvocation(Invocation call) {
196
  final StringBuffer buffer = StringBuffer();
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214
  buffer.write(_symbolName(call.memberName));
  if (call.isSetter) {
    buffer.write(call.positionalArguments[0].toString());
  } else if (call.isMethod) {
    buffer.write('(');
    buffer.writeAll(call.positionalArguments.map<String>(_valueName), ', ');
    String separator = call.positionalArguments.isEmpty ? '' : ', ';
    call.namedArguments.forEach((Symbol name, Object value) {
      buffer.write(separator);
      buffer.write(_symbolName(name));
      buffer.write(': ');
      buffer.write(_valueName(value));
      separator = ', ';
    });
    buffer.write(')');
  }
  return buffer.toString();
}