Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in
Toggle navigation
F
Front-End
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
abdullh.alsoleman
Front-End
Commits
fb94d3fb
Unverified
Commit
fb94d3fb
authored
May 14, 2020
by
Tong Mu
Committed by
GitHub
May 14, 2020
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Animation sheet recorder (#55527)
parent
533cd7a6
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
606 additions
and
0 deletions
+606
-0
animation_sheet_test.dart
packages/flutter/test/animation/animation_sheet_test.dart
+148
-0
flutter_test.dart
packages/flutter_test/lib/flutter_test.dart
+1
-0
animation_sheet.dart
packages/flutter_test/lib/src/animation_sheet.dart
+350
-0
widget_tester.dart
packages/flutter_test/lib/src/widget_tester.dart
+23
-0
widget_tester_test.dart
packages/flutter_test/test/widget_tester_test.dart
+84
-0
No files found.
packages/flutter/test/animation/animation_sheet_test.dart
0 → 100644
View file @
fb94d3fb
// 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
();
}
}
packages/flutter_test/lib/flutter_test.dart
View file @
fb94d3fb
...
...
@@ -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'
;
...
...
packages/flutter_test/lib/src/animation_sheet.dart
0 → 100644
View file @
fb94d3fb
// 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
,
);
});
}
}
packages/flutter_test/lib/src/widget_tester.dart
View file @
fb94d3fb
...
...
@@ -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
...
...
packages/flutter_test/test/widget_tester_test.dart
View file @
fb94d3fb
...
...
@@ -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
();
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment