diff --git a/examples/lib/stories/widgets/nine_tile_box_example_with_animation.dart b/examples/lib/stories/widgets/nine_tile_box_example_with_animation.dart new file mode 100644 index 00000000000..3444ab4798d --- /dev/null +++ b/examples/lib/stories/widgets/nine_tile_box_example_with_animation.dart @@ -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), + ), + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/examples/lib/stories/widgets/partial_sprite_widget_example.dart b/examples/lib/stories/widgets/partial_sprite_widget_example.dart index 7e240345fc4..8cf1a079932 100644 --- a/examples/lib/stories/widgets/partial_sprite_widget_example.dart +++ b/examples/lib/stories/widgets/partial_sprite_widget_example.dart @@ -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), ), diff --git a/examples/lib/stories/widgets/widgets.dart b/examples/lib/stories/widgets/widgets.dart index 3bf7645843d..547eec053f1 100644 --- a/examples/lib/stories/widgets/widgets.dart +++ b/examples/lib/stories/widgets/widgets.dart @@ -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, diff --git a/packages/flame/lib/src/widgets/animation_widget.dart b/packages/flame/lib/src/widgets/animation_widget.dart index b44300d4e58..d5eb7b01941 100644 --- a/packages/flame/lib/src/widgets/animation_widget.dart +++ b/packages/flame/lib/src/widgets/animation_widget.dart @@ -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 createState() => _SpriteAnimationWidgetState(); +} + +class _SpriteAnimationWidgetState extends State { + late FutureOr _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 _updateAnimation( + FutureOr oldFutureValue, + FutureOr 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( 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, ); } } diff --git a/packages/flame/lib/src/widgets/nine_tile_box.dart b/packages/flame/lib/src/widgets/nine_tile_box.dart index 3febc2ec0b8..269eae1ccc2 100644 --- a/packages/flame/lib/src/widgets/nine_tile_box.dart +++ b/packages/flame/lib/src/widgets/nine_tile_box.dart @@ -37,7 +37,7 @@ class _Painter extends CustomPainter { } /// A [StatelessWidget] that renders NineTileBox -class NineTileBoxWidget extends StatelessWidget { +class NineTileBoxWidget extends StatefulWidget { final FutureOr _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 createState() => _NineTileBoxWidgetState(); +} + +class _NineTileBoxWidgetState extends State { + late FutureOr _imageFuture = widget._imageFuture; + + @override + void didUpdateWidget(covariant NineTileBoxWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + _updateNineTileBox(widget._imageFuture, oldWidget._imageFuture); + } + + Future _updateNineTileBox( + FutureOr imageFuture, + FutureOr oldImageFuture, + ) async { + final image = await imageFuture; + final oldImage = await oldImageFuture; + + if (image != oldImage) { + setState(() { + _imageFuture = imageFuture; + }); + } + } + @override Widget build(BuildContext context) { return BaseFutureBuilder( @@ -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, ); } } diff --git a/packages/flame/lib/src/widgets/sprite_widget.dart b/packages/flame/lib/src/widgets/sprite_widget.dart index 95d2166aaed..da72759edff 100644 --- a/packages/flame/lib/src/widgets/sprite_widget.dart +++ b/packages/flame/lib/src/widgets/sprite_widget.dart @@ -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 createState() => _SpriteWidgetState(); +} + +class _SpriteWidgetState extends State { + late FutureOr _spriteFuture = widget._spriteFuture; + + @override + void didUpdateWidget(covariant SpriteWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + _updateSprite(oldWidget._spriteFuture, widget._spriteFuture); + } + + Future _updateSprite( + FutureOr oldFutureValue, + FutureOr 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( @@ -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, ); } } diff --git a/packages/flame/test/widgets/nine_tile_box_widget_test.dart b/packages/flame/test/widgets/nine_tile_box_widget_test.dart index 9e33f8e371a..865bfd86269 100644 --- a/packages/flame/test/widgets/nine_tile_box_widget_test.dart +++ b/packages/flame/test/widgets/nine_tile_box_widget_test.dart @@ -60,5 +60,67 @@ Future 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(find.byType(InternalNineTileBox)); + + expect(internalWidget.image, image); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + internalWidget = tester + .widget(find.byType(InternalNineTileBox)); + + expect(internalWidget.image, image2); + }); + }); }); } diff --git a/packages/flame/test/widgets/sprite_animation_widget_test.dart b/packages/flame/test/widgets/sprite_animation_widget_test.dart index 2b1f0eb7295..a56c59a4558 100644 --- a/packages/flame/test/widgets/sprite_animation_widget_test.dart +++ b/packages/flame/test/widgets/sprite_animation_widget_test.dart @@ -78,7 +78,7 @@ Future 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 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 main() async { animationTicker: animationTicker2, ), ); + + await tester.pump(); + expect(animationTicker1.onComplete, isNull); expect(animationTicker2.onComplete, isNotNull); @@ -191,5 +197,334 @@ Future 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + find.byType(InternalSpriteAnimationWidget), + ); + + expect( + internalWidget.animation.loop, + isTrue, + ); + }); + }); + }); }); } diff --git a/packages/flame/test/widgets/sprite_widget_test.dart b/packages/flame/test/widgets/sprite_widget_test.dart index 4f51896e3f1..5eced36f314 100644 --- a/packages/flame/test/widgets/sprite_widget_test.dart +++ b/packages/flame/test/widgets/sprite_widget_test.dart @@ -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 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(find.byType(InternalSpriteWidget)); + + expect(internalSpriteWidgetFinder.sprite.srcPosition, Vector2(0, 0)); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + internalSpriteWidgetFinder = tester + .widget(find.byType(InternalSpriteWidget)); + + expect(internalSpriteWidgetFinder.sprite.srcPosition, Vector2(10, 10)); + }); + }); }); } diff --git a/packages/flame_test/lib/src/mock_image.dart b/packages/flame_test/lib/src/mock_image.dart index 453b299f05c..a3affabd65c 100644 --- a/packages/flame_test/lib/src/mock_image.dart +++ b/packages/flame_test/lib/src/mock_image.dart @@ -1,14 +1,22 @@ -import 'dart:typed_data'; +import 'dart:ui'; -import 'package:flame/extensions.dart'; +Future 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 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; }