[flutter] macOS/iOS builds, fixed examples wrt new API

This commit is contained in:
Mario Zechner 2025-07-30 09:44:13 +02:00
parent 83f5bd0e0a
commit 4225214caf
24 changed files with 1252 additions and 5383 deletions

View File

@ -24,7 +24,7 @@ class AnimationStateEvents extends StatelessWidget {
controller.animationState.setListener((type, trackEntry, event) {
if (type == EventType.event) {
print(
"User event: { name: ${event?.getData().getName()}, intValue: ${event?.getIntValue()}, floatValue: ${event?.getFloatValue()}, stringValue: ${event?.getStringValue()} }",
"User event: { name: ${event?.data.name}, intValue: ${event?.intValue}, floatValue: ${event?.floatValue}, stringValue: ${event?.stringValue} }",
);
}
});

View File

@ -40,7 +40,7 @@ class DebugRendering extends StatelessWidget {
const debugRenderer = DebugRenderer();
final controller = SpineWidgetController(
onInitialized: (controller) {
controller.animationState.setAnimationByName(0, "walk", true);
controller.animationState.setAnimation(0, "walk", true);
},
onBeforePaint: (controller, canvas) {
// Save the current transform and other canvas state

View File

@ -41,7 +41,7 @@ class DressUp extends StatefulWidget {
class DressUpState extends State<DressUp> {
static const double thumbnailSize = 200;
late SkeletonDrawable _drawable;
late SkeletonDrawableFlutter _drawable;
Skin? _customSkin;
final Map<String, RawImageData> _skinImages = {};
final Map<String, bool> _selectedSkins = {};
@ -50,17 +50,19 @@ class DressUpState extends State<DressUp> {
void initState() {
reportLeaks();
super.initState();
SkeletonDrawable.fromAsset("assets/mix-and-match.atlas", "assets/mix-and-match-pro.skel").then((drawable) async {
SkeletonDrawableFlutter.fromAsset("assets/mix-and-match.atlas", "assets/mix-and-match-pro.skel")
.then((drawable) async {
_drawable = drawable;
for (var skin in drawable.skeletonData.getSkins()) {
if (skin.getName() == "default") continue;
for (var skin in drawable.skeletonData.skins) {
if (skin == null) continue;
if (skin.name == "default") continue;
var skeleton = drawable.skeleton;
skeleton.setSkin(skin);
skeleton.setToSetupPose();
skeleton.setSkin2(skin);
skeleton.setupPose();
skeleton.update(0);
skeleton.updateWorldTransform(Physics.update);
_skinImages[skin.getName()] = await drawable.renderToRawImageData(thumbnailSize, thumbnailSize, 0xffffffff);
_selectedSkins[skin.getName()] = false;
_skinImages[skin.name] = await drawable.renderToRawImageData(thumbnailSize, thumbnailSize, 0xffffffff);
_selectedSkins[skin.name] = false;
}
_toggleSkin("full-skins/girl");
setState(() {});
@ -69,7 +71,7 @@ class DressUpState extends State<DressUp> {
void _toggleSkin(String skinName) {
_selectedSkins[skinName] = !_selectedSkins[skinName]!;
_drawable.skeleton.setSkinByName("default");
_drawable.skeleton.setSkin("default");
if (_customSkin != null) _customSkin?.dispose();
_customSkin = Skin("custom-skin");
for (var skinName in _selectedSkins.keys) {
@ -78,15 +80,15 @@ class DressUpState extends State<DressUp> {
if (skin != null) _customSkin?.addSkin(skin);
}
}
_drawable.skeleton.setSkin(_customSkin!);
_drawable.skeleton.setSlotsToSetupPose();
_drawable.skeleton.setSkin2(_customSkin!);
_drawable.skeleton.setupPoseSlots();
}
@override
Widget build(BuildContext context) {
final controller = SpineWidgetController(
onInitialized: (controller) {
controller.animationState.setAnimationByName(0, "dance", true);
controller.animationState.setAnimation(0, "dance", true);
},
);

View File

@ -36,7 +36,7 @@ import 'package:flutter/material.dart';
class SpineComponent extends PositionComponent {
final BoundsProvider _boundsProvider;
final SkeletonDrawable _drawable;
final SkeletonDrawableFlutter _drawable;
late final Bounds _bounds;
final bool _ownsDrawable;
@ -70,7 +70,7 @@ class SpineComponent extends PositionComponent {
int? priority,
}) async {
return SpineComponent(
await SkeletonDrawable.fromAsset(atlasFile, skeletonFile, bundle: bundle),
await SkeletonDrawableFlutter.fromAsset(atlasFile, skeletonFile, bundle: bundle),
ownsDrawable: true,
boundsProvider: boundsProvider,
position: position,
@ -137,15 +137,15 @@ class SimpleFlameExample extends FlameGame {
}
class DragonExample extends FlameGame {
late final Atlas cachedAtlas;
late final AtlasFlutter cachedAtlas;
late final SkeletonData cachedSkeletonData;
late final SpineComponent dragon;
@override
Future<void> onLoad() async {
cachedAtlas = await Atlas.fromAsset("assets/dragon.atlas");
cachedSkeletonData = await SkeletonData.fromAsset(cachedAtlas, "assets/dragon-ess.skel");
final drawable = SkeletonDrawable(cachedAtlas, cachedSkeletonData, false);
cachedAtlas = await AtlasFlutter.fromAsset("assets/dragon.atlas");
cachedSkeletonData = await SkeletonDataFlutter.fromAsset(cachedAtlas, "assets/dragon-ess.skel");
final drawable = SkeletonDrawableFlutter(cachedAtlas, cachedSkeletonData, false);
dragon = SpineComponent(
drawable,
scale: Vector2(0.4, 0.4),
@ -168,21 +168,21 @@ class DragonExample extends FlameGame {
class PreloadAndShareSpineDataExample extends FlameGame {
late final SkeletonData cachedSkeletonData;
late final Atlas cachedAtlas;
late final AtlasFlutter cachedAtlas;
late final List<SpineComponent> spineboys = [];
@override
Future<void> onLoad() async {
// Pre-load the atlas and skeleton data once.
cachedAtlas = await Atlas.fromAsset("assets/spineboy.atlas");
cachedSkeletonData = await SkeletonData.fromAsset(cachedAtlas, "assets/spineboy-pro.skel");
cachedAtlas = await AtlasFlutter.fromAsset("assets/spineboy.atlas");
cachedSkeletonData = await SkeletonDataFlutter.fromAsset(cachedAtlas, "assets/spineboy-pro.skel");
// Instantiate many spineboys from the pre-loaded data. Each SpineComponent
// gets their own SkeletonDrawable copy derived from the cached data. The
// SkeletonDrawable copies do not own the underlying skeleton data and atlas.
final rng = Random();
for (int i = 0; i < 100; i++) {
final drawable = SkeletonDrawable(cachedAtlas, cachedSkeletonData, false);
final drawable = SkeletonDrawableFlutter(cachedAtlas, cachedSkeletonData, false);
final scale = 0.1 + rng.nextDouble() * 0.2;
final position = Vector2(rng.nextDouble() * size.x, rng.nextDouble() * size.y);
final spineboy = SpineComponent(drawable, scale: Vector2(scale, scale), position: position);

View File

@ -48,17 +48,19 @@ class IkFollowingState extends State<IkFollowing> {
controller = SpineWidgetController(
onInitialized: (controller) {
// Set the walk animation on track 0, let it loop
controller.animationState.setAnimationByName(0, "walk", true);
controller.animationState.setAnimationByName(1, "aim", true);
controller.animationState.setAnimation(0, "walk", true);
controller.animationState.setAnimation(1, "aim", true);
},
onAfterUpdateWorldTransforms: (controller) {
final worldPosition = crossHairPosition;
if (worldPosition == null) return;
final bone = controller.skeleton.findBone("crosshair")!;
final parent = bone.getParent()!;
final position = parent.worldToLocal(worldPosition.dx, worldPosition.dy);
bone.setX(position.x);
bone.setY(position.y);
final parent = bone.parent;
if (parent != null) {
final position = parent.appliedPose.worldToLocal(worldPosition.dx, worldPosition.dy);
bone.appliedPose.x = position.x;
bone.appliedPose.y = position.y;
}
},
);
}

View File

@ -31,7 +31,7 @@ import 'package:spine_flutter/spine_flutter.dart';
import 'package:flutter/material.dart';
class PlayPauseAnimation extends StatefulWidget {
const PlayPauseAnimation({Key? key}) : super(key: key);
const PlayPauseAnimation({super.key});
@override
PlayPauseAnimationState createState() => PlayPauseAnimationState();
@ -45,7 +45,7 @@ class PlayPauseAnimationState extends State<PlayPauseAnimation> {
super.initState();
controller = SpineWidgetController(
onInitialized: (controller) {
controller.animationState.setAnimationByName(0, "flying", true);
controller.animationState.setAnimation(0, "flying", true);
},
);
}

View File

@ -48,8 +48,8 @@ class PhysicsState extends State<PhysicsTest> {
controller = SpineWidgetController(
onInitialized: (controller) {
controller.animationState.setAnimationByName(0, "eyeblink-long", true);
controller.animationState.setAnimationByName(1, "wings-and-feet", true);
controller.animationState.setAnimation(0, "eyeblink-long", true);
controller.animationState.setAnimation(1, "wings-and-feet", true);
},
onAfterUpdateWorldTransforms: (controller) {
if (lastMousePosition == null) {

View File

@ -5,5 +5,6 @@
import FlutterMacOS
import Foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
}

View File

@ -15,8 +15,8 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
spine_flutter: 75f9d54a630ac150d238210f9c211529c37c11ba
spine_flutter: 8469a2cfb87c5a7101a7c87dc7c14ee49699ea3b
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

View File

@ -1,7 +1,7 @@
import Cocoa
import FlutterMacOS
@NSApplicationMain
@main
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true

View File

@ -21,6 +21,16 @@ if [ ! -d "codegen/node_modules" ]; then
fi
fi
# Generating spine-c bindings
log_action "Generating spine-c bindings"
if LOG=$(cd ../spine-c && ./build.sh codegen 2>&1); then
log_ok
else
log_fail
log_error_output "$LOG"
exit 1
fi
# Copy spine-c and spine-cpp sources
log_action "Setting up source files"
if ./setup.sh > /dev/null 2>&1; then
@ -32,19 +42,21 @@ fi
# Run the codegen
log_action "Generating Dart bindings"
if npx tsx codegen/src/index.ts > /dev/null 2>&1; then
if LOG=$(npx tsx codegen/src/index.ts 2>&1); then
log_ok
else
log_fail
log_error_output "$LOG"
exit 1
fi
# Build test spine_flutter shared library
log_action "Building test library"
if (cd test && ./build.sh > /dev/null 2>&1); then
if LOG=$(cd test && ./build.sh 2>&1); then
log_ok
else
log_fail
log_error_output "$LOG"
exit 1
fi

View File

@ -1,3 +0,0 @@
// Relative import to be able to reuse the C sources.
// See the comment in ../{projectName}}.podspec for more information.
#include "../../src/spine-cpp-lite/spine-cpp-lite.cpp"

View File

@ -12,14 +12,9 @@ A new Flutter FFI plugin project.
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
# This will ensure the source files in Classes/ are included in the native
# builds of apps using this FFI plugin. Podspec does not support relative
# paths, so Classes contains a forwarder C file that relatively imports
# `../src/*` so that the C sources can be shared among all target platforms.
s.source = { :path => '.' }
s.source_files = 'Classes/**/*.{cpp}'
s.xcconfig = { 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/Classes/spine-cpp/include"' }
s.xcconfig = { 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/Classes/spine-cpp/include" "$(PODS_TARGET_SRCROOT)/Classes/spine-c/include"' }
s.dependency 'Flutter'
s.platform = :ios, '9.0'

File diff suppressed because it is too large Load Diff

View File

@ -80,10 +80,10 @@ class Animation {
SpineBindings.bindings.spine_animation_set_duration(_ptr, value);
}
void apply(Skeleton skeleton, double lastTime, double time, bool loop, ArrayEvent? pEvents, double alpha,
void apply(Skeleton skeleton, double lastTime, double time, bool loop, ArrayEvent? events, double alpha,
MixBlend blend, MixDirection direction, bool appliedPose) {
SpineBindings.bindings.spine_animation_apply(_ptr, skeleton.nativePtr.cast(), lastTime, time, loop,
pEvents?.nativePtr.cast() ?? Pointer.fromAddress(0), alpha, blend.value, direction.value, appliedPose);
events?.nativePtr.cast() ?? Pointer.fromAddress(0), alpha, blend.value, direction.value, appliedPose);
}
String get name {

File diff suppressed because it is too large Load Diff

View File

@ -52,10 +52,10 @@ abstract class Timeline {
return Rtti.fromPointer(result);
}
void apply(Skeleton skeleton, double lastTime, double time, ArrayEvent? pEvents, double alpha, MixBlend blend,
void apply(Skeleton skeleton, double lastTime, double time, ArrayEvent? events, double alpha, MixBlend blend,
MixDirection direction, bool appliedPose) {
SpineBindings.bindings.spine_timeline_apply(_ptr, skeleton.nativePtr.cast(), lastTime, time,
pEvents?.nativePtr.cast() ?? Pointer.fromAddress(0), alpha, blend.value, direction.value, appliedPose);
events?.nativePtr.cast() ?? Pointer.fromAddress(0), alpha, blend.value, direction.value, appliedPose);
}
int get frameEntries {

View File

@ -50,6 +50,7 @@ import 'generated/event.dart';
import 'generated/event_type.dart';
import 'generated/render_command.dart';
import 'generated/physics.dart';
import 'generated/bone_pose.dart';
// Export generated classes
export 'generated/api.dart';
@ -321,20 +322,24 @@ extension TrackEntryExtensions on TrackEntry {
/// Represents a bounding box with position and dimensions
class Bounds {
final double x;
final double y;
final double width;
final double height;
const Bounds({
double x;
double y;
double width;
double height;
Bounds({
required this.x,
required this.y,
required this.width,
required this.height,
});
@override
String toString() => 'Bounds(x: $x, y: $y, width: $width, height: $height)';
}
class Vector {
double x;
double y;
Vector({required this.x, required this.y});
}
/// Extension to add bounds property to Skeleton
@ -349,6 +354,33 @@ extension SkeletonExtensions on Skeleton {
height: spineBounds.height,
);
}
Vector getPosition() {
final position = SpineBindings.bindings.spine_skeleton_get_position_v(nativePtr.cast());
return Vector(x: position.x, y: position.y);
}
}
extension BonePoseExtensions on BonePose {
Vector worldToLocal(double worldX, double worldY) {
final result = SpineBindings.bindings.spine_bone_pose_world_to_local_v(nativePtr.cast(), worldX, worldY);
return Vector(x: result.x, y: result.y);
}
Vector localToWorld(double localX, double localY) {
final result = SpineBindings.bindings.spine_bone_pose_local_to_world_v(nativePtr.cast(), localX, localY);
return Vector(x: result.x, y: result.y);
}
Vector worldToParent(double worldX, double worldY) {
final result = SpineBindings.bindings.spine_bone_pose_world_to_parent_v(nativePtr.cast(), worldX, worldY);
return Vector(x: result.x, y: result.y);
}
Vector parentToWorld(double parentX, double parentY) {
final result = SpineBindings.bindings.spine_bone_pose_parent_to_world_v(nativePtr.cast(), parentX, parentY);
return Vector(x: result.x, y: result.y);
}
}
/// Convenient drawable that combines skeleton, animation state, and rendering
@ -419,7 +451,7 @@ class SkeletonDrawable {
// Apply animation state to skeleton
animationState.apply(skeleton);
// Update skeleton physics and world transforms
skeleton.update(delta);
skeleton.updateWorldTransform(Physics.update);

View File

@ -24,14 +24,13 @@ Future<void> initSpineFlutter({bool useStaticLinkage = false, bool enableMemoryD
}
/// Flutter wrapper for Atlas that manages texture loading and Paint creation
class AtlasFlutter {
class AtlasFlutter extends Atlas {
static FilterQuality filterQuality = FilterQuality.low;
final Atlas atlas;
final List<Image> atlasPages;
final List<Map<BlendMode, Paint>> atlasPagePaints;
bool _disposed = false;
AtlasFlutter._(this.atlas, this.atlasPages, this.atlasPagePaints);
AtlasFlutter._(super.ptr, this.atlasPages, this.atlasPagePaints) : super.fromPointer();
/// Internal method to load atlas and images
static Future<AtlasFlutter> _load(String atlasFileName, Future<Uint8List> Function(String name) loadFile) async {
@ -77,7 +76,7 @@ class AtlasFlutter {
paints.add(pagePaints);
}
return AtlasFlutter._(atlas, pages, paints);
return AtlasFlutter._(atlas.nativePtr.cast(), pages, paints);
}
/// Loads an [AtlasFlutter] from the file [atlasFileName] in the root bundle or the optionally provided [bundle].
@ -103,10 +102,11 @@ class AtlasFlutter {
}
/// Disposes all resources including the native atlas and images
@override
void dispose() {
if (_disposed) return;
_disposed = true;
atlas.dispose();
super.dispose();
for (final image in atlasPages) {
image.dispose();
}
@ -114,6 +114,64 @@ class AtlasFlutter {
}
}
/// Flutter wrapper for SkeletonData that provides convenient loading methods
class SkeletonDataFlutter extends SkeletonData {
SkeletonDataFlutter._(super.ptr) : super.fromPointer();
/// Loads a [SkeletonDataFlutter] from the file [skeletonFile] in the root bundle or the optionally provided [bundle].
/// Uses the provided [atlasFlutter] to resolve attachment images.
///
/// Throws an [Exception] in case the skeleton data could not be loaded.
static Future<SkeletonDataFlutter> fromAsset(AtlasFlutter atlas, String skeletonFile, {AssetBundle? bundle}) async {
bundle ??= rootBundle;
if (skeletonFile.endsWith(".json")) {
final jsonData = await bundle.loadString(skeletonFile);
final skeletonData = loadSkeletonDataJson(atlas, jsonData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
} else {
final binaryData = (await bundle.load(skeletonFile)).buffer.asUint8List();
final skeletonData = loadSkeletonDataBinary(atlas, binaryData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
}
}
/// Loads a [SkeletonDataFlutter] from the file [skeletonFile]. Uses the provided [atlasFlutter] to resolve attachment images.
///
/// Throws an [Exception] in case the skeleton data could not be loaded.
static Future<SkeletonDataFlutter> fromFile(AtlasFlutter atlasFlutter, String skeletonFile) async {
if (skeletonFile.endsWith(".json")) {
final jsonData = await File(skeletonFile).readAsString();
final skeletonData = loadSkeletonDataJson(atlasFlutter, jsonData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
} else {
final binaryData = await File(skeletonFile).readAsBytes();
final skeletonData = loadSkeletonDataBinary(atlasFlutter, binaryData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
}
}
/// Loads a [SkeletonDataFlutter] from the URL [skeletonURL]. Uses the provided [atlasFlutter] to resolve attachment images.
///
/// Throws an [Exception] in case the skeleton data could not be loaded.
static Future<SkeletonDataFlutter> fromHttp(AtlasFlutter atlasFlutter, String skeletonURL) async {
if (skeletonURL.endsWith(".json")) {
final response = await http.get(Uri.parse(skeletonURL));
if (response.statusCode != 200) {
throw Exception('Failed to load skeleton from $skeletonURL: ${response.statusCode}');
}
final skeletonData = loadSkeletonDataJson(atlasFlutter, response.body, path: skeletonURL);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
} else {
final response = await http.get(Uri.parse(skeletonURL));
if (response.statusCode != 200) {
throw Exception('Failed to load skeleton from $skeletonURL: ${response.statusCode}');
}
final skeletonData = loadSkeletonDataBinary(atlasFlutter, response.bodyBytes, path: skeletonURL);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
}
}
}
/// Extension to convert Spine BlendMode to Flutter BlendMode
extension BlendModeExtensions on BlendMode {
rendering.BlendMode toFlutterBlendMode() {
@ -184,7 +242,6 @@ class RenderCommandFlutter {
// See https://github.com/flutter/flutter/issues/127486
//
// We thus batch all meshes not only by atlas page and blend mode, but also vertex color.
// See spine_flutter.cpp, batch_commands().
//
// If the vertex color equals (1, 1, 1, 1), we do not store
// colors, which will trigger the fast path in Impeller. Otherwise we have to go the slow path, which
@ -218,13 +275,13 @@ class RenderCommandFlutter {
}
}
/// A SkeletonDrawable bundles loading, updating, and rendering an [Atlas], [Skeleton], and [AnimationState]
/// A SkeletonDrawable bundles loading, updating, and rendering an [AtlasFlutter], [Skeleton], and [AnimationState]
/// into a single easy to use class.
///
/// Use the [fromAsset], [fromFile], or [fromHttp] methods to construct a SkeletonDrawable. To have
/// multiple skeleton drawable instances share the same [Atlas] and [SkeletonData], use the constructor.
/// multiple skeleton drawable instances share the same [AtlasFlutter] and [SkeletonDataFlutter], use the constructor.
///
/// You can then directly access the [atlas], [skeletonData], [skeleton], [animationStateData], and [animationState]
/// You can then directly access the [atlasFlutter], [skeletonDataFlutter], [skeleton], [animationStateData], and [animationState]
/// to query and animate the skeleton. Use the [AnimationState] to queue animations on one or more tracks
/// via [AnimationState.setAnimation] or [AnimationState.addAnimation].
///
@ -235,27 +292,18 @@ class RenderCommandFlutter {
/// [renderToPng], or [renderToRawImageData], depending on your needs.
///
/// When the skeleton drawable is no longer needed, call the [dispose] method to release its resources. If
/// the skeleton drawable was constructed from a shared [Atlas] and [SkeletonData], make sure to dispose the
/// the skeleton drawable was constructed from a shared [AtlasFlutter] and [SkeletonDataFlutter], make sure to dispose the
/// atlas and skeleton data as well, if no skeleton drawable references them anymore.
class SkeletonDrawableFlutter {
class SkeletonDrawableFlutter extends SkeletonDrawable {
final AtlasFlutter atlasFlutter;
final SkeletonData skeletonData;
late final SkeletonDrawable _drawable;
late final Skeleton skeleton;
late final AnimationStateData animationStateData;
late final AnimationState animationState;
final bool _ownsAtlasAndSkeletonData;
bool _disposed = false;
/// Constructs a new skeleton drawable from the given (possibly shared) [Atlas] and [SkeletonData]. If
/// Constructs a new skeleton drawable from the given (possibly shared) [AtlasFlutter] and [SkeletonDataFlutter]. If
/// the atlas and skeleton data are not shared, the drawable can take ownership by passing true for [_ownsAtlasAndSkeletonData].
/// In that case a call to [dispose] will also dispose the atlas and skeleton data.
SkeletonDrawableFlutter(this.atlasFlutter, this.skeletonData, this._ownsAtlasAndSkeletonData) {
_drawable = SkeletonDrawable(skeletonData);
skeleton = _drawable.skeleton;
animationStateData = _drawable.animationStateData;
animationState = _drawable.animationState;
}
SkeletonDrawableFlutter(this.atlasFlutter, this.skeletonData, this._ownsAtlasAndSkeletonData) : super(skeletonData);
/// Constructs a new skeleton drawable from the [atlasFile] and [skeletonFile] from the root asset bundle
/// or the optionally provided [bundle].
@ -264,13 +312,8 @@ class SkeletonDrawableFlutter {
static Future<SkeletonDrawableFlutter> fromAsset(String atlasFile, String skeletonFile, {AssetBundle? bundle}) async {
bundle ??= rootBundle;
final atlasFlutter = await AtlasFlutter.fromAsset(atlasFile, bundle: bundle);
final skeletonData = await bundle.loadString(skeletonFile);
final skeleton = skeletonFile.endsWith('.json')
? loadSkeletonDataJson(atlasFlutter.atlas, skeletonData)
: throw Exception('Binary skeleton data loading from assets not yet implemented');
return SkeletonDrawableFlutter(atlasFlutter, skeleton, true);
final skeletonDataFlutter = await SkeletonDataFlutter.fromAsset(atlasFlutter, skeletonFile, bundle: bundle);
return SkeletonDrawableFlutter(atlasFlutter, skeletonDataFlutter, true);
}
/// Constructs a new skeleton drawable from the [atlasFile] and [skeletonFile].
@ -278,17 +321,8 @@ class SkeletonDrawableFlutter {
/// Throws an exception in case the data could not be loaded.
static Future<SkeletonDrawableFlutter> fromFile(String atlasFile, String skeletonFile) async {
final atlasFlutter = await AtlasFlutter.fromFile(atlasFile);
final SkeletonData skeleton;
if (skeletonFile.endsWith('.json')) {
final skeletonData = await File(skeletonFile).readAsString();
skeleton = loadSkeletonDataJson(atlasFlutter.atlas, skeletonData);
} else {
final skeletonData = await File(skeletonFile).readAsBytes();
skeleton = loadSkeletonDataBinary(atlasFlutter.atlas, skeletonData);
}
return SkeletonDrawableFlutter(atlasFlutter, skeleton, true);
final skeletonDataFlutter = await SkeletonDataFlutter.fromFile(atlasFlutter, skeletonFile);
return SkeletonDrawableFlutter(atlasFlutter, skeletonDataFlutter, true);
}
/// Constructs a new skeleton drawable from the [atlasUrl] and [skeletonUrl].
@ -296,45 +330,22 @@ class SkeletonDrawableFlutter {
/// Throws an exception in case the data could not be loaded.
static Future<SkeletonDrawableFlutter> fromHttp(String atlasUrl, String skeletonUrl) async {
final atlasFlutter = await AtlasFlutter.fromHttp(atlasUrl);
final SkeletonData skeleton;
if (skeletonUrl.endsWith('.json')) {
final skeletonResponse = await http.get(Uri.parse(skeletonUrl));
if (skeletonResponse.statusCode != 200) {
throw Exception('Failed to load skeleton from $skeletonUrl: ${skeletonResponse.statusCode}');
}
skeleton = loadSkeletonDataJson(atlasFlutter.atlas, skeletonResponse.body);
} else {
final skeletonResponse = await http.get(Uri.parse(skeletonUrl));
if (skeletonResponse.statusCode != 200) {
throw Exception('Failed to load skeleton from $skeletonUrl: ${skeletonResponse.statusCode}');
}
skeleton = loadSkeletonDataBinary(atlasFlutter.atlas, skeletonResponse.bodyBytes);
}
return SkeletonDrawableFlutter(atlasFlutter, skeleton, true);
}
/// Updates the [AnimationState] using the [delta] time given in seconds, applies the
/// animation state to the [Skeleton] and updates the world transforms of the skeleton
/// to calculate its current pose.
void update(double delta) {
if (_disposed) return;
_drawable.update(delta);
final skeletonDataFlutter = await SkeletonDataFlutter.fromHttp(atlasFlutter, skeletonUrl);
return SkeletonDrawableFlutter(atlasFlutter, skeletonDataFlutter, true);
}
/// Renders to current skeleton pose to a list of [RenderCommandFlutter] instances. The render commands
/// can be rendered via [Canvas.drawVertices].
List<RenderCommandFlutter> render() {
List<RenderCommandFlutter> renderFlutter() {
if (_disposed) return [];
var commands = <RenderCommandFlutter>[];
var nativeCmd = _drawable.render();
var nativeCmd = render();
while (nativeCmd != null) {
// Get page dimensions from atlas
final pageIndex = nativeCmd.texture?.address ?? 0;
final pages = atlasFlutter.atlas.pages;
final pages = atlasFlutter.pages;
final page = pages[pageIndex];
if (page != null) {
commands.add(RenderCommandFlutter._(nativeCmd, page.width.toDouble(), page.height.toDouble()));
@ -350,7 +361,7 @@ class SkeletonDrawableFlutter {
/// Renders the skeleton drawable's current pose to the given [canvas]. Does not perform any
/// scaling or fitting.
List<RenderCommandFlutter> renderToCanvas(Canvas canvas) {
var commands = render();
var commands = renderFlutter();
for (final cmd in commands) {
// Get the paint for this atlas page and blend mode
@ -425,10 +436,11 @@ class SkeletonDrawableFlutter {
/// Disposes the skeleton drawable's resources. If the skeleton drawable owns the atlas
/// and skeleton data, they are disposed as well. Must be called when the skeleton drawable
/// is no longer in use.
@override
void dispose() {
if (_disposed) return;
_disposed = true;
_drawable.dispose();
super.dispose();
if (_ownsAtlasAndSkeletonData) {
atlasFlutter.dispose();

View File

@ -26,6 +26,7 @@
/// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
/// THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
///
library;
import 'dart:math';
@ -34,7 +35,7 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'spine_dart.dart';
import 'spine_flutter.dart';
/// Controls how the skeleton of a [SpineWidget] is animated and rendered.
///
@ -54,7 +55,7 @@ import 'spine_dart.dart';
/// [SpineWidget] then renderes the skeleton's current pose, and finally calls the optional [onAfterPaint], which
/// can render additional objects on top of the skeleton.
///
/// The underlying [Atlas], [SkeletonData], [Skeleton], [AnimationStateData], [AnimationState], and [SkeletonDrawable]
/// The underlying [AtlasFlutter], [SkeletonData], [Skeleton], [AnimationStateData], [AnimationState], and [SkeletonDrawableFlutter]
/// can be accessed through their respective getters to inspect and/or modify the skeleton and its associated data. Accessing
/// this data is only allowed if the [SpineWidget] and its data have been initialized and have not been disposed yet.
///
@ -62,7 +63,7 @@ import 'spine_dart.dart';
/// and rendering the skeleton. The [resume] method resumes updating and rendering the skeleton. The [isPlaying] getter
/// reports the current state.
class SpineWidgetController {
SkeletonDrawable? _drawable;
SkeletonDrawableFlutter? _drawable;
double _offsetX = 0, _offsetY = 0, _scaleX = 1, _scaleY = 1;
bool _isPlaying = true;
_SpineRenderObject? _renderObject;
@ -70,7 +71,8 @@ class SpineWidgetController {
final void Function(SpineWidgetController controller)? onBeforeUpdateWorldTransforms;
final void Function(SpineWidgetController controller)? onAfterUpdateWorldTransforms;
final void Function(SpineWidgetController controller, Canvas canvas)? onBeforePaint;
final void Function(SpineWidgetController controller, Canvas canvas, List<RenderCommand> commands)? onAfterPaint;
final void Function(SpineWidgetController controller, Canvas canvas, List<RenderCommandFlutter> commands)?
onAfterPaint;
/// Constructs a new [SpineWidget] controller. See the class documentation of [SpineWidgetController] for information on
/// the optional arguments.
@ -82,16 +84,16 @@ class SpineWidgetController {
this.onAfterPaint,
});
void _initialize(SkeletonDrawable drawable) {
void _initialize(SkeletonDrawableFlutter drawable) {
var wasInitialized = _drawable != null;
_drawable = drawable;
if (!wasInitialized) onInitialized?.call(this);
}
/// The [Atlas] from which images to render the skeleton are sourced.
Atlas get atlas {
/// The [AtlasFlutter] from which images to render the skeleton are sourced.
AtlasFlutter get atlasFlutter {
if (_drawable == null) throw Exception("Controller is not initialized yet.");
return _drawable!.atlas;
return _drawable!.atlasFlutter;
}
/// The setup-pose data used by the skeleton.
@ -119,8 +121,8 @@ class SpineWidgetController {
return _drawable!.skeleton;
}
/// The [SkeletonDrawable]
SkeletonDrawable get drawable {
/// The [SkeletonDrawableFlutter]
SkeletonDrawableFlutter get drawable {
if (_drawable == null) throw Exception("Controller is not initialized yet.");
return _drawable!;
}
@ -170,7 +172,7 @@ enum _AssetType { asset, file, http, drawable }
abstract class BoundsProvider {
const BoundsProvider();
Bounds computeBounds(SkeletonDrawable drawable);
Bounds computeBounds(SkeletonDrawableFlutter drawable);
}
/// A [BoundsProvider] that calculates the bounding box of the skeleton based on the visible
@ -179,8 +181,10 @@ class SetupPoseBounds extends BoundsProvider {
const SetupPoseBounds();
@override
Bounds computeBounds(SkeletonDrawable drawable) {
return drawable.skeleton.getBounds();
Bounds computeBounds(SkeletonDrawableFlutter drawable) {
drawable.skeleton.setupPose();
drawable.skeleton.updateWorldTransform(Physics.none);
return drawable.skeleton.bounds;
}
}
@ -191,8 +195,8 @@ class RawBounds extends BoundsProvider {
RawBounds(this.x, this.y, this.width, this.height);
@override
Bounds computeBounds(SkeletonDrawable drawable) {
return Bounds(x, y, width, height);
Bounds computeBounds(SkeletonDrawableFlutter drawable) {
return Bounds(x: x, y: y, width: width, height: height);
}
}
@ -211,17 +215,17 @@ class SkinAndAnimationBounds extends BoundsProvider {
: skins = skins == null || skins.isEmpty ? ["default"] : skins;
@override
Bounds computeBounds(SkeletonDrawable drawable) {
Bounds computeBounds(SkeletonDrawableFlutter drawable) {
final data = drawable.skeletonData;
final oldSkin = drawable.skeleton.getSkin();
final oldSkin = drawable.skeleton.skin;
final customSkin = Skin("custom-skin");
for (final skinName in skins) {
final skin = data.findSkin(skinName);
if (skin == null) continue;
customSkin.addSkin(skin);
}
drawable.skeleton.setSkin(customSkin);
drawable.skeleton.setToSetupPose();
drawable.skeleton.setSkin2(customSkin);
drawable.skeleton.setupPose();
final animation = this.animation != null ? data.findAnimation(this.animation!) : null;
double minX = double.infinity;
@ -229,35 +233,37 @@ class SkinAndAnimationBounds extends BoundsProvider {
double maxX = double.negativeInfinity;
double maxY = double.negativeInfinity;
if (animation == null) {
final bounds = drawable.skeleton.getBounds();
drawable.skeleton.updateWorldTransform(Physics.none);
final bounds = drawable.skeleton.bounds;
minX = bounds.x;
minY = bounds.y;
maxX = minX + bounds.width;
maxY = minY + bounds.height;
} else {
drawable.animationState.setAnimation(0, animation, false);
final steps = max(animation.getDuration() / stepTime, 1.0).toInt();
drawable.animationState.setAnimation(0, animation.name, false);
final steps = max(animation.duration / stepTime, 1.0).toInt();
for (int i = 0; i < steps; i++) {
drawable.update(i > 0 ? stepTime : 0);
final bounds = drawable.skeleton.getBounds();
drawable.skeleton.updateWorldTransform(Physics.none);
final bounds = drawable.skeleton.bounds;
minX = min(minX, bounds.x);
minY = min(minY, bounds.y);
maxX = max(maxX, minX + bounds.width);
maxY = max(maxY, minY + bounds.height);
}
}
drawable.skeleton.setSkinByName("default");
drawable.skeleton.setSkin("default");
drawable.animationState.clearTracks();
if (oldSkin != null) drawable.skeleton.setSkin(oldSkin);
drawable.skeleton.setToSetupPose();
if (oldSkin != null) drawable.skeleton.setSkin2(oldSkin);
drawable.skeleton.setupPose();
drawable.update(0);
customSkin.dispose();
return Bounds(minX, minY, maxX - minX, maxY - minY);
return Bounds(x: minX, y: minY, width: maxX - minX, height: maxY - minY);
}
}
/// A [StatefulWidget] to display a Spine skeleton. The skeleton can be loaded from an asset bundle ([SpineWidget.fromAsset],
/// local files [SpineWidget.fromFile], URLs [SpineWidget.fromHttp], or a pre-loaded [SkeletonDrawable] ([SpineWidget.fromDrawable]).
/// local files [SpineWidget.fromFile], URLs [SpineWidget.fromHttp], or a pre-loaded [SkeletonDrawableFlutter] ([SpineWidget.fromDrawable]).
///
/// The skeleton displayed by a `SpineWidget` can be controlled via a [SpineWidgetController].
///
@ -268,7 +274,7 @@ class SpineWidget extends StatefulWidget {
final AssetBundle? _bundle;
final String? _skeletonFile;
final String? _atlasFile;
final SkeletonDrawable? _drawable;
final SkeletonDrawableFlutter? _drawable;
final SpineWidgetController _controller;
final BoxFit _fit;
final Alignment _alignment;
@ -361,7 +367,7 @@ class SpineWidget extends StatefulWidget {
_sizedByBounds = sizedByBounds ?? false,
_drawable = null;
/// Constructs a new [SpineWidget] from a [SkeletonDrawable].
/// Constructs a new [SpineWidget] from a [SkeletonDrawableFlutter].
///
/// After initialization is complete, the provided [_controller] is invoked as per the [SpineWidgetController] semantics, to allow
/// modifying how the skeleton inside the widget is animated and rendered.
@ -394,7 +400,7 @@ class SpineWidget extends StatefulWidget {
class _SpineWidgetState extends State<SpineWidget> {
late Bounds _computedBounds;
SkeletonDrawable? _drawable;
SkeletonDrawableFlutter? _drawable;
@override
void initState() {
@ -436,7 +442,7 @@ class _SpineWidgetState extends State<SpineWidget> {
}
}
void loadDrawable(SkeletonDrawable drawable) {
void loadDrawable(SkeletonDrawableFlutter drawable) {
_drawable = drawable;
_computedBounds = widget._boundsProvider.computeBounds(drawable);
widget._controller._initialize(drawable);
@ -446,13 +452,13 @@ class _SpineWidgetState extends State<SpineWidget> {
void loadFromAsset(AssetBundle? bundle, String atlasFile, String skeletonFile, _AssetType assetType) async {
switch (assetType) {
case _AssetType.asset:
loadDrawable(await SkeletonDrawable.fromAsset(atlasFile, skeletonFile, bundle: bundle));
loadDrawable(await SkeletonDrawableFlutter.fromAsset(atlasFile, skeletonFile, bundle: bundle));
break;
case _AssetType.file:
loadDrawable(await SkeletonDrawable.fromFile(atlasFile, skeletonFile));
loadDrawable(await SkeletonDrawableFlutter.fromFile(atlasFile, skeletonFile));
break;
case _AssetType.http:
loadDrawable(await SkeletonDrawable.fromHttp(atlasFile, skeletonFile));
loadDrawable(await SkeletonDrawableFlutter.fromHttp(atlasFile, skeletonFile));
break;
case _AssetType.drawable:
throw Exception("Drawable can not be loaded via loadFromAsset().");
@ -483,7 +489,7 @@ class _SpineWidgetState extends State<SpineWidget> {
}
class _SpineRenderObjectWidget extends LeafRenderObjectWidget {
final SkeletonDrawable _skeletonDrawable;
final SkeletonDrawableFlutter _skeletonDrawable;
final SpineWidgetController _controller;
final BoxFit _fit;
final Alignment _alignment;
@ -515,7 +521,7 @@ class _SpineRenderObjectWidget extends LeafRenderObjectWidget {
}
class _SpineRenderObject extends RenderBox {
SkeletonDrawable _skeletonDrawable;
SkeletonDrawableFlutter _skeletonDrawable;
final SpineWidgetController _controller;
double _deltaTime = 0;
final Stopwatch _stopwatch = Stopwatch();
@ -535,7 +541,7 @@ class _SpineRenderObject extends RenderBox {
this._sizedByBounds,
);
set skeletonDrawable(SkeletonDrawable skeletonDrawable) {
set skeletonDrawable(SkeletonDrawableFlutter skeletonDrawable) {
if (_skeletonDrawable == skeletonDrawable) return;
_skeletonDrawable = skeletonDrawable;
@ -721,7 +727,11 @@ class _SpineRenderObject extends RenderBox {
if (_firstUpdated) {
_controller.onBeforePaint?.call(_controller, canvas);
final commands = _skeletonDrawable.renderToCanvas(canvas);
final commands = _skeletonDrawable.renderFlutter();
for (final cmd in commands) {
final paint = _skeletonDrawable.atlasFlutter.atlasPagePaints[cmd.atlasPageIndex][cmd.blendMode]!;
canvas.drawVertices(cmd.vertices, rendering.BlendMode.modulate, paint);
}
_controller.onAfterPaint?.call(_controller, canvas, commands);
}

View File

@ -1,3 +0,0 @@
// Relative import to be able to reuse the C sources.
// See the comment in ../{projectName}}.podspec for more information.
#include "../../src/spine-cpp-lite/spine-cpp-lite.cpp"

View File

@ -12,14 +12,9 @@ A new Flutter FFI plugin project.
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
# This will ensure the source files in Classes/ are included in the native
# builds of apps using this FFI plugin. Podspec does not support relative
# paths, so Classes contains a forwarder C file that relatively imports
# `../src/*` so that the C sources can be shared among all target platforms.
s.source = { :path => '.' }
s.source_files = 'Classes/**/*.{cpp}'
s.xcconfig = { 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/Classes/spine-cpp/include"' }
s.source_files = 'Classes/spine-c/src/**/*.{cpp,h}', 'Classes/spine-cpp/src/**/*.{cpp,h}'
s.xcconfig = { 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/Classes/spine-cpp/include" "$(PODS_TARGET_SRCROOT)/Classes/spine-c/include"' }
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.11'

View File

@ -44,5 +44,3 @@ flutter:
assets:
- lib/assets/libspine_flutter.js
- lib/assets/libspine_flutter.wasm
- src/spine-cpp-lite/spine-cpp-lite.cpp
- src/spine-cpp-lite/spine-cpp-lite.h

View File

@ -16,12 +16,12 @@ void main() async {
// Create skeleton drawable
final drawable = SkeletonDrawable(skeletonData);
print('SkeletonDrawable created successfully');
// Test skeleton bounds
print('\nTesting skeleton bounds:');
final bounds = drawable.skeleton.bounds;
print(' Initial bounds: $bounds');
// Set skeleton to pose and update bounds
drawable.skeleton.setupPose();
drawable.skeleton.updateWorldTransform(Physics.none);
@ -76,13 +76,13 @@ void main() async {
for (final event in events) {
print(' $event');
}
// Test bounds after animation updates
print('\nTesting bounds after animation:');
drawable.skeleton.updateWorldTransform(Physics.none);
final boundsAfterAnimation = drawable.skeleton.bounds;
print(' Bounds after animation: $boundsAfterAnimation');
// Test with different animations that might have different bounds
print('\nTesting bounds with jump animation:');
drawable.animationState.setAnimation(0, 'jump', false);