fix: Widgets flickering (#3343)

Fix the flickering in the widgets: `SpriteAnimationWidget`,
`SpriteWidget` and `NineTileBoxWidget`.
This commit is contained in:
Erick
2024-10-16 12:11:17 -03:00
committed by GitHub
parent 7311d034d4
commit ff170dc5c2
10 changed files with 664 additions and 36 deletions

View File

@ -0,0 +1,45 @@
import 'package:dashbook/dashbook.dart';
import 'package:flame/widgets.dart';
import 'package:flutter/material.dart';
var _opacity = 1.0;
Widget nineTileBoxBuilderWithAnimation(DashbookContext ctx) {
return StatefulBuilder(
builder: (context, setState) {
return Column(
children: [
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
setState(() {
_opacity = _opacity == 1.0 ? 0.0 : 1.0;
});
},
child: const Text('Toggle'),
),
const SizedBox(height: 8),
AnimatedOpacity(
duration: const Duration(seconds: 2),
opacity: _opacity,
child: NineTileBoxWidget.asset(
width: 400,
height: 400,
path: 'nine-box.png',
tileSize: 22,
destTileSize: 50,
child: const Center(
child: Text(
'Cool label',
style: TextStyle(
color: Color(0xFF000000),
),
),
),
),
),
],
);
},
);
}

View File

@ -12,8 +12,14 @@ Widget partialSpriteWidgetBuilder(DashbookContext ctx) {
decoration: BoxDecoration(border: Border.all(color: Colors.amber)),
child: SpriteWidget.asset(
path: 'bomb_ptero.png',
srcPosition: Vector2(48, 0),
srcSize: Vector2(48, 32),
srcPosition: Vector2(
ctx.numberProperty('srcPosition.x', 48),
ctx.numberProperty('srcPosition.y', 0),
),
srcSize: Vector2(
ctx.numberProperty('srcSize.x', 48),
ctx.numberProperty('srcSize.y', 32),
),
anchor: Anchor.valueOf(
ctx.listProperty('anchor', 'center', anchorOptions),
),

View File

@ -3,6 +3,7 @@ import 'package:dashbook/dashbook.dart';
import 'package:examples/commons/commons.dart';
import 'package:examples/stories/widgets/custom_painter_example.dart';
import 'package:examples/stories/widgets/nine_tile_box_example.dart';
import 'package:examples/stories/widgets/nine_tile_box_example_with_animation.dart';
import 'package:examples/stories/widgets/partial_sprite_widget_example.dart';
import 'package:examples/stories/widgets/sprite_animation_widget_example.dart';
import 'package:examples/stories/widgets/sprite_button_example.dart';
@ -21,6 +22,15 @@ void addWidgetsStories(Dashbook dashbook) {
out the settings on the pen icon.
''',
)
..add(
'Nine Tile Box (With animation widgets)',
nineTileBoxBuilderWithAnimation,
codeLink: baseLink('widgets/nine_tile_box_example_with_animation.dart'),
info: '''
Similar to the Nine Tile Box example, but here a NineTileBoxWidget is composed
with Flutter's AnimatedOpacity.
''',
)
..add(
'Sprite Button',
spriteButtonBuilder,

View File

@ -11,7 +11,7 @@ import 'package:flutter/material.dart' hide Animation;
export '../sprite_animation.dart';
/// A [StatelessWidget] that renders a [SpriteAnimation]
class SpriteAnimationWidget extends StatelessWidget {
class SpriteAnimationWidget extends StatefulWidget {
/// The positioning [Anchor].
final Anchor anchor;
@ -67,24 +67,74 @@ class SpriteAnimationWidget extends StatelessWidget {
}) : _animationFuture = SpriteAnimation.load(path, data, images: images),
_animationTicker = null;
@override
State<SpriteAnimationWidget> createState() => _SpriteAnimationWidgetState();
}
class _SpriteAnimationWidgetState extends State<SpriteAnimationWidget> {
late FutureOr<SpriteAnimation> _animationFuture = widget._animationFuture;
late SpriteAnimationTicker? _animationTicker = widget._animationTicker;
@override
void didUpdateWidget(covariant SpriteAnimationWidget oldWidget) {
super.didUpdateWidget(oldWidget);
_updateAnimation(
oldWidget._animationFuture,
widget._animationFuture,
oldWidget._animationTicker,
widget._animationTicker,
);
}
Future<void> _updateAnimation(
FutureOr<SpriteAnimation> oldFutureValue,
FutureOr<SpriteAnimation> newFutureValue,
SpriteAnimationTicker? oldTicker,
SpriteAnimationTicker? newTicker,
) async {
final oldValue = await oldFutureValue;
final newValue = await newFutureValue;
final areFramesDifferent = oldValue != newValue ||
oldValue.frames.length != newValue.frames.length ||
oldValue.frames.fold(
true,
(previous, frame) {
final newFrame = newValue.frames[oldValue.frames.indexOf(frame)];
return previous &&
(frame.sprite.image == newFrame.sprite.image ||
frame.sprite.src == newFrame.sprite.src);
},
);
if (areFramesDifferent || oldTicker != newTicker) {
setState(() {
_animationFuture = newFutureValue;
_animationTicker = newTicker;
});
}
}
@override
Widget build(BuildContext context) {
return BaseFutureBuilder<SpriteAnimation>(
future: _animationFuture,
builder: (_, spriteAnimation) {
final ticker = _animationTicker ?? spriteAnimation.createTicker();
ticker.completed.then((_) => onComplete?.call());
ticker.completed.then((_) => widget.onComplete?.call());
return InternalSpriteAnimationWidget(
animation: spriteAnimation,
animationTicker: ticker,
anchor: anchor,
playing: playing,
paint: paint,
anchor: widget.anchor,
playing: widget.playing,
paint: widget.paint,
);
},
errorBuilder: errorBuilder,
loadingBuilder: loadingBuilder,
errorBuilder: widget.errorBuilder,
loadingBuilder: widget.loadingBuilder,
);
}
}

View File

@ -37,7 +37,7 @@ class _Painter extends CustomPainter {
}
/// A [StatelessWidget] that renders NineTileBox
class NineTileBoxWidget extends StatelessWidget {
class NineTileBoxWidget extends StatefulWidget {
final FutureOr<Image> _imageFuture;
/// The size of the tile on the image
@ -91,6 +91,34 @@ class NineTileBoxWidget extends StatelessWidget {
super.key,
}) : _imageFuture = (images ?? Flame.images).load(path);
@override
State<NineTileBoxWidget> createState() => _NineTileBoxWidgetState();
}
class _NineTileBoxWidgetState extends State<NineTileBoxWidget> {
late FutureOr<Image> _imageFuture = widget._imageFuture;
@override
void didUpdateWidget(covariant NineTileBoxWidget oldWidget) {
super.didUpdateWidget(oldWidget);
_updateNineTileBox(widget._imageFuture, oldWidget._imageFuture);
}
Future<void> _updateNineTileBox(
FutureOr<Image> imageFuture,
FutureOr<Image> oldImageFuture,
) async {
final image = await imageFuture;
final oldImage = await oldImageFuture;
if (image != oldImage) {
setState(() {
_imageFuture = imageFuture;
});
}
}
@override
Widget build(BuildContext context) {
return BaseFutureBuilder<Image>(
@ -98,16 +126,16 @@ class NineTileBoxWidget extends StatelessWidget {
builder: (_, image) {
return InternalNineTileBox(
image: image,
tileSize: tileSize,
destTileSize: destTileSize,
width: width,
height: height,
padding: padding,
child: child,
tileSize: widget.tileSize,
destTileSize: widget.destTileSize,
width: widget.width,
height: widget.height,
padding: widget.padding,
child: widget.child,
);
},
errorBuilder: errorBuilder,
loadingBuilder: loadingBuilder,
errorBuilder: widget.errorBuilder,
loadingBuilder: widget.loadingBuilder,
);
}
}

View File

@ -12,7 +12,7 @@ export '../sprite.dart';
/// A [StatelessWidget] which renders a Sprite
/// To render an animation, use [SpriteAnimationWidget].
class SpriteWidget extends StatelessWidget {
class SpriteWidget extends StatefulWidget {
/// The positioning [Anchor]
final Anchor anchor;
@ -68,6 +68,34 @@ class SpriteWidget extends StatelessWidget {
images: images,
);
@override
State<SpriteWidget> createState() => _SpriteWidgetState();
}
class _SpriteWidgetState extends State<SpriteWidget> {
late FutureOr<Sprite> _spriteFuture = widget._spriteFuture;
@override
void didUpdateWidget(covariant SpriteWidget oldWidget) {
super.didUpdateWidget(oldWidget);
_updateSprite(oldWidget._spriteFuture, widget._spriteFuture);
}
Future<void> _updateSprite(
FutureOr<Sprite> oldFutureValue,
FutureOr<Sprite> newFutureValue,
) async {
final oldValue = await oldFutureValue;
final newValue = await newFutureValue;
if (oldValue.image != newValue.image || oldValue.src != newValue.src) {
setState(() {
_spriteFuture = newFutureValue;
});
}
}
@override
Widget build(BuildContext context) {
return BaseFutureBuilder<Sprite>(
@ -75,13 +103,13 @@ class SpriteWidget extends StatelessWidget {
builder: (_, sprite) {
return InternalSpriteWidget(
sprite: sprite,
anchor: anchor,
angle: angle,
paint: paint,
anchor: widget.anchor,
angle: widget.angle,
paint: widget.paint,
);
},
errorBuilder: errorBuilder,
loadingBuilder: loadingBuilder,
errorBuilder: widget.errorBuilder,
loadingBuilder: widget.loadingBuilder,
);
}
}

View File

@ -60,5 +60,67 @@ Future<void> main() async {
expect(nineTileBoxWidgetFinder, findsOneWidget);
},
);
group('when the nine tile box changes', () {
testWidgets('updates the widget', (tester) async {
const imagePath = 'test_path_2';
const imagePath2 = 'test_path_3';
final image = await generateImage(100, 100);
final image2 = await generateImage(100, 102);
Flame.images.add(imagePath, image);
Flame.images.add(imagePath2, image2);
var flag = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
height: 200,
width: 200,
child: Wrap(
children: [
ElevatedButton(
onPressed: () {
setState(() {
flag = !flag;
});
},
child: const Text('Change sprite'),
),
NineTileBoxWidget.asset(
path: flag ? imagePath2 : imagePath,
tileSize: 10,
destTileSize: 10,
loadingBuilder: (_) => const LoadingWidget(),
),
],
),
),
),
);
},
),
);
await tester.pumpAndSettle();
var internalWidget = tester
.widget<InternalNineTileBox>(find.byType(InternalNineTileBox));
expect(internalWidget.image, image);
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
internalWidget = tester
.widget<InternalNineTileBox>(find.byType(InternalNineTileBox));
expect(internalWidget.image, image2);
});
});
});
}

View File

@ -78,7 +78,7 @@ Future<void> main() async {
const executionCount = 10;
final frames = List.generate(5, (_) => Sprite(image));
final animation1 = SpriteAnimation.spriteList(frames, stepTime: 0.1);
final animation2 = SpriteAnimation.spriteList(frames, stepTime: 0.1);
final animation2 = SpriteAnimation.spriteList(frames, stepTime: 0.2);
final animationTicker1 = SpriteAnimationTicker(animation1);
final animationTicker2 = SpriteAnimationTicker(animation2);
@ -98,10 +98,13 @@ Future<void> main() async {
animationTicker: animationTicker1,
),
);
await tester.pump();
expect(animationTicker1.onComplete, isNotNull);
expect(animationTicker2.onComplete, isNull);
await tester.pump();
expect(animation1Started, true);
// This will call didUpdateWidget lifecycle
@ -111,6 +114,9 @@ Future<void> main() async {
animationTicker: animationTicker2,
),
);
await tester.pump();
expect(animationTicker1.onComplete, isNull);
expect(animationTicker2.onComplete, isNotNull);
@ -191,5 +197,334 @@ Future<void> main() async {
expect(onCompleteCalled, isTrue);
},
);
group('when the image changes', () {
testWidgets('updates the widget', (tester) async {
const imagePath = 'test_path_2';
const imagePath2 = 'test_path_3';
final image = await generateImage(100, 100);
final image2 = await generateImage(100, 102);
Flame.images.add(imagePath, image);
Flame.images.add(imagePath2, image2);
final spriteAnimationData = SpriteAnimationData.sequenced(
amount: 1,
stepTime: 0.1,
textureSize: Vector2(16, 16),
loop: false,
);
var flag = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
height: 200,
width: 200,
child: Wrap(
children: [
ElevatedButton(
onPressed: () {
setState(() {
flag = !flag;
});
},
child: const Text('Change sprite'),
),
SpriteAnimationWidget.asset(
path: flag ? imagePath2 : imagePath,
data: spriteAnimationData,
),
],
),
),
),
);
},
),
);
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump();
var internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(internalWidget.animation.frames.first.sprite.image, image);
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump();
internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(internalWidget.animation.frames.first.sprite.image, image2);
});
});
group('when the sprite data changes', () {
group('when the frame length changes', () {
testWidgets('updates the widget', (tester) async {
const imagePath = 'test_path_2';
final image = await generateImage(100, 100);
Flame.images.add(imagePath, image);
final spriteAnimationData = SpriteAnimationData.sequenced(
amount: 1,
stepTime: 0.1,
textureSize: Vector2(16, 16),
loop: false,
);
final spriteAnimationData2 = SpriteAnimationData.sequenced(
amount: 2,
stepTime: 0.1,
textureSize: Vector2(16, 16),
loop: false,
);
var flag = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
height: 200,
width: 200,
child: Wrap(
children: [
ElevatedButton(
onPressed: () {
setState(() {
flag = !flag;
});
},
child: const Text('Change sprite'),
),
SpriteAnimationWidget.asset(
path: imagePath,
data: flag
? spriteAnimationData2
: spriteAnimationData,
),
],
),
),
),
);
},
),
);
await tester.pump();
await tester.pump();
var internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(internalWidget.animation.frames, hasLength(1));
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump();
internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(internalWidget.animation.frames, hasLength(2));
});
});
group('when a single frame changes', () {
testWidgets('updates the widget', (tester) async {
const imagePath = 'test_path_2';
final image = await generateImage(100, 100);
Flame.images.add(imagePath, image);
final spriteAnimationData = SpriteAnimationData.sequenced(
amount: 1,
stepTime: 0.1,
textureSize: Vector2(16, 16),
loop: false,
);
final spriteAnimationData2 = SpriteAnimationData.sequenced(
amount: 1,
stepTime: 0.1,
textureSize: Vector2(12, 12),
loop: false,
);
var flag = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
height: 200,
width: 200,
child: Wrap(
children: [
ElevatedButton(
onPressed: () {
setState(() {
flag = !flag;
});
},
child: const Text('Change sprite'),
),
SpriteAnimationWidget.asset(
path: imagePath,
data: flag
? spriteAnimationData2
: spriteAnimationData,
),
],
),
),
),
);
},
),
);
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump();
var internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(
internalWidget.animation.frames.first.sprite.srcSize,
Vector2.all(16),
);
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump();
internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(
internalWidget.animation.frames.first.sprite.srcSize,
Vector2.all(12),
);
});
});
group('when looping changes', () {
testWidgets('updates the widget', (tester) async {
const imagePath = 'test_path_2';
final image = await generateImage(100, 100);
Flame.images.add(imagePath, image);
final spriteAnimationData = SpriteAnimationData.sequenced(
amount: 1,
stepTime: 0.1,
textureSize: Vector2(16, 16),
loop: false,
);
final spriteAnimationData2 = SpriteAnimationData.sequenced(
amount: 1,
stepTime: 0.1,
textureSize: Vector2(16, 16),
);
var flag = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
height: 200,
width: 200,
child: Wrap(
children: [
ElevatedButton(
onPressed: () {
setState(() {
flag = !flag;
});
},
child: const Text('Change sprite'),
),
SpriteAnimationWidget.asset(
path: imagePath,
data: flag
? spriteAnimationData2
: spriteAnimationData,
),
],
),
),
),
);
},
),
);
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump();
var internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(
internalWidget.animation.loop,
isFalse,
);
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump();
internalWidget = tester.widget<InternalSpriteAnimationWidget>(
find.byType(InternalSpriteAnimationWidget),
);
expect(
internalWidget.animation.loop,
isTrue,
);
});
});
});
});
}

View File

@ -1,3 +1,4 @@
import 'package:flame/components.dart';
import 'package:flame/flame.dart';
import 'package:flame/widgets.dart';
import 'package:flame_test/flame_test.dart';
@ -51,5 +52,60 @@ Future<void> main() async {
expect(spriteWidgetFinder, findsOneWidget);
},
);
group('when the sprite changes', () {
testWidgets('updates the sprite widget', (tester) async {
const imagePath = 'test_path_2';
Flame.images.add(imagePath, await generateImage(100, 100));
var flag = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
height: 200,
width: 200,
child: Wrap(
children: [
ElevatedButton(
onPressed: () {
setState(() {
flag = !flag;
});
},
child: const Text('Change sprite'),
),
SpriteWidget.asset(
path: imagePath,
srcPosition: flag ? Vector2(10, 10) : Vector2(0, 0),
loadingBuilder: (_) => const LoadingWidget(),
),
],
),
),
),
);
},
),
);
await tester.pumpAndSettle();
var internalSpriteWidgetFinder = tester
.widget<InternalSpriteWidget>(find.byType(InternalSpriteWidget));
expect(internalSpriteWidgetFinder.sprite.srcPosition, Vector2(0, 0));
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
internalSpriteWidgetFinder = tester
.widget<InternalSpriteWidget>(find.byType(InternalSpriteWidget));
expect(internalSpriteWidgetFinder.sprite.srcPosition, Vector2(10, 10));
});
});
});
}

View File

@ -1,14 +1,22 @@
import 'dart:typed_data';
import 'dart:ui';
import 'package:flame/extensions.dart';
Future<Image> generateImage([int width = 1, int height = 1]) {
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(
Rect.fromLTWH(
0,
0,
height.toDouble(),
width.toDouble(),
),
Paint()..color = const Color(0xFFFFFFFF),
);
Future<Image> generateImage() {
final data = Uint8List(4);
for (var i = 0; i < data.length; i += 4) {
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255;
}
return ImageExtension.fromPixels(data, 1, 1);
final picture = recorder.endRecording();
final image = picture.toImage(
width,
height,
);
return image;
}