[flutter] Added AtlasFlutter.fromMemroy, SkeletonDataFlutter.fromMemory, SkeletonDrawableFlutter.fromMemory, and SpineWidget.fromMemory. See CHANGELOG.md for details and #2939

This commit is contained in:
Mario Zechner 2025-11-06 12:36:53 +01:00
parent 332a001192
commit 5658eec015
8 changed files with 353 additions and 43 deletions

View File

@ -399,6 +399,10 @@
### Flutter
- **Additions**
- Added `fromMemory` methods to `AtlasFlutter`, `SkeletonDataFlutter`, `SkeletonDrawableFlutter`, and `SpineWidget` for loading Spine data from custom sources (memory, encrypted storage, databases, custom caching, etc.)
- Added example `load_from_memory.dart` demonstrating how to load all assets into memory and use the `fromMemory` API
- **Breaking changes**
- Updated to use the new auto-generated Dart runtime with all the Dart API changes above

View File

@ -1,3 +1,11 @@
# 4.3.1
## Flutter
- **Additions**
- Added `fromMemory` methods to `AtlasFlutter`, `SkeletonDataFlutter`, `SkeletonDrawableFlutter`, and `SpineWidget` for loading Spine data from custom sources (memory, encrypted storage, databases, custom caching, etc.)
- Added example `load_from_memory.dart` demonstrating how to load all assets into memory and use the `fromMemory` API
# 4.3.0
## Dart

View File

@ -0,0 +1,162 @@
//
// Spine Runtimes License Agreement
// Last updated April 5, 2025. Replaces all prior versions.
//
// Copyright (c) 2013-2025, Esoteric Software LLC
//
// Integration of the Spine Runtimes into software or otherwise creating
// derivative works of the Spine Runtimes is permitted under the terms and
// conditions of Section 2 of the Spine Editor License Agreement:
// http://esotericsoftware.com/spine-editor-license
//
// Otherwise, it is permitted to integrate the Spine Runtimes into software
// or otherwise create derivative works of the Spine Runtimes (collectively,
// "Products"), provided that each user of the Products must obtain their own
// Spine Editor license and redistribution of the Products in any form must
// include this license and copyright notice.
//
// THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
// BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (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.
//
import 'dart:typed_data';
import 'package:spine_flutter/spine_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// This example demonstrates loading Spine skeleton data from memory using the
/// fromMemory methods. This is useful when you want to:
/// - Load data from custom storage (e.g., encrypted assets, databases)
/// - Implement custom caching strategies
/// - Download and cache assets at runtime
/// - Pre-process assets before loading
///
/// The example loads all files (atlas, skeleton, images) into memory first,
/// then uses the fromMemory API to create a SpineWidget.
class LoadFromMemory extends StatefulWidget {
const LoadFromMemory({super.key});
@override
State<LoadFromMemory> createState() => _LoadFromMemoryState();
}
class _LoadFromMemoryState extends State<LoadFromMemory> {
// In-memory cache of all loaded files
final Map<String, Uint8List> _fileCache = {};
bool _isLoading = true;
String _loadingStatus = 'Loading assets into memory...';
@override
void initState() {
super.initState();
_loadAssetsIntoMemory();
}
Future<void> _loadAssetsIntoMemory() async {
try {
// Step 1: Load atlas file into memory
setState(() => _loadingStatus = 'Loading atlas file...');
final atlasBytes = await rootBundle.load('assets/spineboy.atlas');
_fileCache['assets/spineboy.atlas'] = atlasBytes.buffer.asUint8List();
// Step 2: Load skeleton file into memory
setState(() => _loadingStatus = 'Loading skeleton file...');
final skelBytes = await rootBundle.load('assets/spineboy-pro.skel');
_fileCache['assets/spineboy-pro.skel'] = skelBytes.buffer.asUint8List();
// Step 3: Load image file(s) into memory
setState(() => _loadingStatus = 'Loading image files...');
final imageBytes = await rootBundle.load('assets/spineboy.png');
_fileCache['assets/spineboy.png'] = imageBytes.buffer.asUint8List();
// All files loaded into memory!
setState(() {
_loadingStatus = 'All assets loaded into memory (${_fileCache.length} files, ${_getTotalSize()} bytes)';
_isLoading = false;
});
} catch (e) {
setState(() {
_loadingStatus = 'Error loading assets: $e';
_isLoading = false;
});
}
}
int _getTotalSize() {
return _fileCache.values.fold(0, (sum, bytes) => sum + bytes.length);
}
// Custom file loader that returns data from our in-memory cache
Future<Uint8List> _loadFromCache(String filename) async {
final data = _fileCache[filename];
if (data == null) {
throw Exception('File not found in cache: $filename');
}
return data;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Load From Memory')),
body: _isLoading
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(_loadingStatus),
],
),
)
: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.shade50,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Files in Memory Cache:',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._fileCache.entries.map((entry) => Text(
' ${entry.key}: ${entry.value.length} bytes',
style: Theme.of(context).textTheme.bodySmall,
)),
const SizedBox(height: 8),
Text(
'Total: ${_getTotalSize()} bytes',
style: Theme.of(context).textTheme.titleSmall,
),
],
),
),
Expanded(
child: SpineWidget.fromMemory(
'assets/spineboy.atlas',
'assets/spineboy-pro.skel',
_loadFromCache,
SpineWidgetController(
onInitialized: (controller) {
controller.animationState.setAnimation(0, 'walk', true);
},
),
),
),
],
),
);
}
}

View File

@ -35,6 +35,7 @@ import 'animation_state_events.dart';
import 'dress_up.dart';
import 'flame_example.dart';
import 'ik_following.dart';
import 'load_from_memory.dart';
import 'pause_play_animation.dart';
import 'physics.dart';
import 'simple_animation.dart';
@ -100,6 +101,13 @@ class ExampleSelector extends StatelessWidget {
},
),
spacer,
ElevatedButton(
child: const Text('Load From Memory'),
onPressed: () {
Navigator.push(context, MaterialPageRoute<void>(builder: (context) => const LoadFromMemory()));
},
),
spacer,
ElevatedButton(
child: const Text('Flame: Simple Example'),
onPressed: () {

View File

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

View File

@ -33,8 +33,37 @@ class AtlasFlutter extends Atlas {
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 {
/// Loads an [AtlasFlutter] using a custom file loading function.
///
/// This is the most flexible loading method that allows loading atlas data and images from any source
/// (memory, custom storage, network with caching, etc.).
///
/// Parameters:
/// - [atlasFileName]: The path/name of the atlas file. This is passed to [loadFile] to load the atlas data.
/// - [loadFile]: A function that takes a filename and returns the file data as [Uint8List].
/// This function will be called once for the atlas file, and once for each atlas page image.
/// The image paths are relative to the atlas file's directory (as specified in the atlas file).
///
/// Example - Loading from memory:
/// ```dart
/// final atlasData = Uint8List.fromList([...]); // Your atlas file data
/// final page1Data = Uint8List.fromList([...]); // Your first page image data
/// final page2Data = Uint8List.fromList([...]); // Your second page image data
///
/// final atlas = await AtlasFlutter.fromMemory(
/// 'character.atlas',
/// (filename) async {
/// if (filename == 'character.atlas') return atlasData;
/// if (filename == 'character.png') return page1Data;
/// if (filename == 'character2.png') return page2Data;
/// throw Exception('Unknown file: $filename');
/// },
/// );
/// ```
///
/// Note: The [loadFile] function receives the full relative path for images
/// (e.g., "directory/page.png" if the atlas file specifies that path).
static Future<AtlasFlutter> fromMemory(String atlasFileName, Future<Uint8List> Function(String name) loadFile) async {
// Load atlas data
final atlasBytes = await loadFile(atlasFileName);
final atlasData = convert.utf8.decode(atlasBytes);
@ -83,7 +112,7 @@ class AtlasFlutter extends Atlas {
/// Loads an [AtlasFlutter] from the file [atlasFileName] in the root bundle or the optionally provided [bundle].
static Future<AtlasFlutter> fromAsset(String atlasFileName, {AssetBundle? bundle}) async {
bundle ??= rootBundle;
return _load(atlasFileName, (file) async => (await bundle!.load(file)).buffer.asUint8List());
return fromMemory(atlasFileName, (file) async => (await bundle!.load(file)).buffer.asUint8List());
}
/// Loads an [AtlasFlutter] from the file [atlasFileName].
@ -91,12 +120,12 @@ class AtlasFlutter extends Atlas {
if (kIsWeb) {
throw UnsupportedError('File operations are not supported on web. Use fromAsset or fromHttp instead.');
}
return _load(atlasFileName, (file) => File(file).readAsBytes());
return fromMemory(atlasFileName, (file) => File(file).readAsBytes());
}
/// Loads an [AtlasFlutter] from the URL [atlasURL].
static Future<AtlasFlutter> fromHttp(String atlasURL) async {
return _load(atlasURL, (file) async {
return fromMemory(atlasURL, (file) async {
final response = await http.get(Uri.parse(file));
if (response.statusCode != 200) {
throw Exception('Failed to load $file: ${response.statusCode}');
@ -122,21 +151,54 @@ class AtlasFlutter extends Atlas {
class SkeletonDataFlutter extends SkeletonData {
SkeletonDataFlutter._(super.ptr) : super.fromPointer();
/// Loads a [SkeletonDataFlutter] using a custom file loading function.
///
/// This is the most flexible loading method that allows loading skeleton data from any source
/// (memory, custom storage, network with caching, etc.).
///
/// Parameters:
/// - [atlas]: The [AtlasFlutter] to use for resolving attachment images.
/// - [skeletonFile]: The path/name of the skeleton file. This is passed to [loadFile] to load the skeleton data.
/// - [loadFile]: A function that takes a filename and returns the file data as [Uint8List].
///
/// Example - Loading from memory:
/// ```dart
/// final skeletonData = Uint8List.fromList([...]); // Your skeleton file data
///
/// final skeleton = await SkeletonDataFlutter.fromMemory(
/// atlas,
/// 'character.json',
/// (filename) async {
/// if (filename == 'character.json') return skeletonData;
/// throw Exception('Unknown file: $filename');
/// },
/// );
/// ```
///
/// Throws an [Exception] in case the skeleton data could not be loaded.
static Future<SkeletonDataFlutter> fromMemory(
AtlasFlutter atlas,
String skeletonFile,
Future<Uint8List> Function(String name) loadFile,
) async {
final fileData = await loadFile(skeletonFile);
if (skeletonFile.endsWith(".json")) {
final jsonData = convert.utf8.decode(fileData);
final skeletonData = loadSkeletonDataJson(atlas, jsonData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
} else {
final skeletonData = loadSkeletonDataBinary(atlas, fileData, path: skeletonFile);
return SkeletonDataFlutter._(skeletonData.nativePtr.cast());
}
}
/// 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());
}
return fromMemory(atlas, skeletonFile, (file) async => (await bundle!.load(file)).buffer.asUint8List());
}
/// Loads a [SkeletonDataFlutter] from the file [skeletonFile]. Uses the provided [atlasFlutter] to resolve attachment images.
@ -146,36 +208,20 @@ class SkeletonDataFlutter extends SkeletonData {
if (kIsWeb) {
throw UnsupportedError('File operations are not supported on web. Use fromAsset or fromHttp instead.');
}
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());
}
return fromMemory(atlasFlutter, skeletonFile, (file) => File(file).readAsBytes());
}
/// 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));
return fromMemory(atlasFlutter, skeletonURL, (file) async {
final response = await http.get(Uri.parse(file));
if (response.statusCode != 200) {
throw Exception('Failed to load skeleton from $skeletonURL: ${response.statusCode}');
throw Exception('Failed to load $file: ${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());
}
return response.bodyBytes;
});
}
}
@ -312,6 +358,46 @@ class SkeletonDrawableFlutter extends SkeletonDrawable {
/// In that case a call to [dispose] will also dispose the atlas and skeleton data.
SkeletonDrawableFlutter(this.atlasFlutter, this.skeletonData, this._ownsAtlasAndSkeletonData) : super(skeletonData);
/// Constructs a new skeleton drawable using a custom file loading function.
///
/// This is the most flexible loading method that allows loading atlas and skeleton data from any source
/// (memory, custom storage, network with caching, etc.).
///
/// Parameters:
/// - [atlasFile]: The path/name of the atlas file. This is passed to [loadFile] to load the atlas data.
/// - [skeletonFile]: The path/name of the skeleton file. This is passed to [loadFile] to load the skeleton data.
/// - [loadFile]: A function that takes a filename and returns the file data as [Uint8List].
/// This function will be called for the atlas file, skeleton file, and each atlas page image.
///
/// Example - Loading from memory:
/// ```dart
/// final atlasData = Uint8List.fromList([...]); // Your atlas file data
/// final skeletonData = Uint8List.fromList([...]); // Your skeleton file data
/// final imageData = Uint8List.fromList([...]); // Your image file data
///
/// final drawable = await SkeletonDrawableFlutter.fromMemory(
/// 'character.atlas',
/// 'character.json',
/// (filename) async {
/// if (filename == 'character.atlas') return atlasData;
/// if (filename == 'character.json') return skeletonData;
/// if (filename == 'character.png') return imageData;
/// throw Exception('Unknown file: $filename');
/// },
/// );
/// ```
///
/// Throws an exception in case the data could not be loaded.
static Future<SkeletonDrawableFlutter> fromMemory(
String atlasFile,
String skeletonFile,
Future<Uint8List> Function(String name) loadFile,
) async {
final atlasFlutter = await AtlasFlutter.fromMemory(atlasFile, loadFile);
final skeletonDataFlutter = await SkeletonDataFlutter.fromMemory(atlasFlutter, skeletonFile, loadFile);
return SkeletonDrawableFlutter(atlasFlutter, skeletonDataFlutter, true);
}
/// Constructs a new skeleton drawable from the [atlasFile] and [skeletonFile] from the root asset bundle
/// or the optionally provided [bundle].
///

View File

@ -28,6 +28,7 @@
//
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/rendering.dart' as rendering;
import 'package:flutter/scheduler.dart';
@ -164,7 +165,7 @@ class SpineWidgetController {
}
}
enum _AssetType { asset, file, http, drawable }
enum _AssetType { asset, file, http, memory, drawable }
/// Base class for bounds providers. A bounds provider calculates the axis aligned bounding box
/// used to scale and fit a skeleton inside the bounds of a [SpineWidget].
@ -262,7 +263,8 @@ class SkinAndAnimationBounds extends BoundsProvider {
}
/// 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 [SkeletonDrawableFlutter] ([SpineWidget.fromDrawable]).
/// local files [SpineWidget.fromFile], URLs [SpineWidget.fromHttp], memory ([SpineWidget.fromMemory]), or a pre-loaded
/// [SkeletonDrawableFlutter] ([SpineWidget.fromDrawable]).
///
/// The skeleton displayed by a `SpineWidget` can be controlled via a [SpineWidgetController].
///
@ -274,6 +276,7 @@ class SpineWidget extends StatefulWidget {
final String? _skeletonFile;
final String? _atlasFile;
final SkeletonDrawableFlutter? _drawable;
final Future<Uint8List> Function(String)? _loadFile;
final SpineWidgetController _controller;
final BoxFit _fit;
final Alignment _alignment;
@ -308,6 +311,7 @@ class SpineWidget extends StatefulWidget {
_boundsProvider = boundsProvider ?? const SetupPoseBounds(),
_sizedByBounds = sizedByBounds ?? false,
_drawable = null,
_loadFile = null,
_bundle = bundle ?? rootBundle;
/// Constructs a new [SpineWidget] from files. The [_atlasFile] specifies the `.atlas` file to be loaded for the images used to render
@ -336,7 +340,8 @@ class SpineWidget extends StatefulWidget {
_alignment = alignment ?? Alignment.center,
_boundsProvider = boundsProvider ?? const SetupPoseBounds(),
_sizedByBounds = sizedByBounds ?? false,
_drawable = null;
_drawable = null,
_loadFile = null;
/// Constructs a new [SpineWidget] from HTTP URLs. The [_atlasFile] specifies the `.atlas` file to be loaded for the images used to render
/// the skeleton. The [_skeletonFile] specifies either a Skeleton `.json` or `.skel` file containing the skeleton data.
@ -359,6 +364,38 @@ class SpineWidget extends StatefulWidget {
bool? sizedByBounds,
super.key,
}) : _assetType = _AssetType.http,
_bundle = null,
_fit = fit ?? BoxFit.contain,
_alignment = alignment ?? Alignment.center,
_boundsProvider = boundsProvider ?? const SetupPoseBounds(),
_sizedByBounds = sizedByBounds ?? false,
_drawable = null,
_loadFile = null;
/// Constructs a new [SpineWidget] using a custom file loading function.
///
/// This is the most flexible loading method that allows loading skeleton data from any source
/// (memory, custom storage, network with caching, etc.).
///
/// 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.
///
/// The skeleton is fitted and aligned inside the widget as per the [fit] and [alignment] arguments. For this purpose, the skeleton
/// bounds must be computed via a [BoundsProvider]. By default, [BoxFit.contain], [Alignment.center], and a [SetupPoseBounds] provider
/// are used.
///
/// The widget can optionally by sized by the bounds provided by the [BoundsProvider] by passing `true` for [sizedByBounds].
const SpineWidget.fromMemory(
this._atlasFile,
this._skeletonFile,
this._loadFile,
this._controller, {
BoxFit? fit,
Alignment? alignment,
BoundsProvider? boundsProvider,
bool? sizedByBounds,
super.key,
}) : _assetType = _AssetType.memory,
_bundle = null,
_fit = fit ?? BoxFit.contain,
_alignment = alignment ?? Alignment.center,
@ -391,7 +428,8 @@ class SpineWidget extends StatefulWidget {
_boundsProvider = boundsProvider ?? const SetupPoseBounds(),
_sizedByBounds = sizedByBounds ?? false,
_skeletonFile = null,
_atlasFile = null;
_atlasFile = null,
_loadFile = null;
@override
State<SpineWidget> createState() => _SpineWidgetState();
@ -459,6 +497,9 @@ class _SpineWidgetState extends State<SpineWidget> {
case _AssetType.http:
loadDrawable(await SkeletonDrawableFlutter.fromHttp(atlasFile, skeletonFile));
break;
case _AssetType.memory:
loadDrawable(await SkeletonDrawableFlutter.fromMemory(atlasFile, skeletonFile, widget._loadFile!));
break;
case _AssetType.drawable:
throw Exception("Drawable can not be loaded via loadFromAsset().");
}

View File

@ -1,6 +1,6 @@
name: spine_flutter
description: The official Spine Flutter Runtime to load, display and interact with Spine animations.
version: 4.3.0
version: 4.3.1
homepage: https://esotericsoftware.com
repository: https://github.com/esotericsoftware/spine-runtimes
issue_tracker: https://github.com/esotericsoftware/spine-runtimes/issues