diff --git a/.rive_head b/.rive_head index 0073b63..501869b 100644 --- a/.rive_head +++ b/.rive_head @@ -1 +1 @@ -9007b7f92943caf8ae4ba90ba198bfabca5f2b60 +06e959ad21ead2df3d477652f1c208a7ea6e959d diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5a4e24f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.0.1", + "configurations": [ + { + "name": "Rive Flutter example", + "program": "example/lib/main.dart", + "request": "launch", + "type": "dart", + "env": { + + } + } + ] +} \ No newline at end of file diff --git a/example/assets/asset.riv b/example/assets/asset.riv new file mode 100644 index 0000000..fa634bc Binary files /dev/null and b/example/assets/asset.riv differ diff --git a/example/assets/embedded_text.riv b/example/assets/embedded_text.riv new file mode 100644 index 0000000..eaa3519 Binary files /dev/null and b/example/assets/embedded_text.riv differ diff --git a/example/assets/fonts/Inter-Regular.ttf b/example/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..7f9caf3 Binary files /dev/null and b/example/assets/fonts/Inter-Regular.ttf differ diff --git a/example/assets/sampleimage.riv b/example/assets/sampleimage.riv new file mode 100644 index 0000000..e0db652 Binary files /dev/null and b/example/assets/sampleimage.riv differ diff --git a/example/assets/sampletext.riv b/example/assets/sampletext.riv new file mode 100644 index 0000000..32dd3f0 Binary files /dev/null and b/example/assets/sampletext.riv differ diff --git a/example/lib/custom_asset_loading.dart b/example/lib/custom_asset_loading.dart new file mode 100644 index 0000000..c5239cb --- /dev/null +++ b/example/lib/custom_asset_loading.dart @@ -0,0 +1,95 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:rive/rive.dart'; +import 'package:http/http.dart' as http; + +/// An example showing how to load image or font assets dynamically +class CustomAssetLoading extends StatefulWidget { + const CustomAssetLoading({Key? key}) : super(key: key); + + @override + State createState() => _CustomAssetLoadingState(); +} + +class _CustomAssetLoadingState extends State { + var _index = 0; + void next() { + setState(() { + _index += 1; + }); + } + + void previous() { + setState(() { + _index -= 1; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Custom Asset Loading'), + ), + body: Center( + child: Row( + children: [ + GestureDetector( + onTap: previous, + child: const Icon(Icons.arrow_back), + ), + Expanded( + child: (_index % 2 == 0) + ? RiveAnimation.asset( + 'assets/asset.riv', + fit: BoxFit.cover, + importEmbeddedAssets: false, + assetLoader: CallbackAssetLoader( + (asset) async { + final res = await http.get( + Uri.parse('https://picsum.photos/1000/1000')); + await asset + .decode(Uint8List.view(res.bodyBytes.buffer)); + return true; + }, + ), + ) + : RiveAnimation.asset( + 'assets/sampletext.riv', + fit: BoxFit.cover, + importEmbeddedAssets: false, + assetLoader: CallbackAssetLoader( + (asset) async { + final urls = [ + 'https://cdn.rive.app/runtime/flutter/IndieFlower-Regular.ttf', + 'https://cdn.rive.app/runtime/flutter/comic-neue.ttf', + 'https://cdn.rive.app/runtime/flutter/inter.ttf', + 'https://cdn.rive.app/runtime/flutter/inter-tight.ttf', + 'https://cdn.rive.app/runtime/flutter/josefin-sans.ttf', + 'https://cdn.rive.app/runtime/flutter/send-flowers.ttf', + ]; + + final res = await http.get(Uri.parse( + urls[Random().nextInt( + urls.length, + )], + )); + await asset + .decode(Uint8List.view(res.bodyBytes.buffer)); + return true; + }, + ), + ), + ), + GestureDetector( + onTap: next, + child: const Icon(Icons.arrow_forward), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 008afe0..3d9219d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:rive_example/custom_asset_loading.dart'; + import 'package:rive_example/carousel.dart'; import 'package:rive_example/custom_controller.dart'; import 'package:rive_example/example_state_machine.dart'; @@ -51,6 +53,7 @@ class _RiveExampleAppState extends State { const _Page('State Machine with Listener', StateMachineListener()), const _Page('Skinning Demo', SkinningDemo()), const _Page('Animation Carousel', AnimationCarousel()), + const _Page('Custom Asset Loading', CustomAssetLoading()), ]; @override diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index a881b2d..f6e2d31 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -15,8 +15,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - rive_common: fab8476ce8352bf54152a913f393a8696d3dc98c + rive_common: acedcab7802c0ece4b0d838b71d7deb637e1309a PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.12.0 +COCOAPODS: 1.12.1 diff --git a/example/pubspec.yaml b/example/pubspec.yaml index dc0d40b..40dce17 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: sdk: flutter rive: path: ../ + http: dev_dependencies: flutter_test: @@ -24,3 +25,4 @@ flutter: assets: - assets/ + - assets/fonts/ diff --git a/lib/rive.dart b/lib/rive.dart index 68eb600..2aaa865 100644 --- a/lib/rive.dart +++ b/lib/rive.dart @@ -1,5 +1,7 @@ library rive; +export 'package:rive/src/asset.dart'; +export 'package:rive/src/asset_loader.dart'; export 'package:rive/src/controllers/one_shot_controller.dart'; export 'package:rive/src/controllers/simple_controller.dart'; export 'package:rive/src/controllers/state_machine_controller.dart'; diff --git a/lib/src/asset.dart b/lib/src/asset.dart new file mode 100644 index 0000000..93b71cb --- /dev/null +++ b/lib/src/asset.dart @@ -0,0 +1,54 @@ +import 'package:rive/src/rive_core/assets/file_asset.dart'; + +export 'package:rive/src/generated/artboard_base.dart'; + +/// TODO: do we prefer this, or do we want to wrap our FileAssets +/// into a custom asset class. +extension FileAssetExtension on FileAsset { + Extension get extension => _getExtension(fileExtension); + Type get type => _getType(fileExtension); +} + +Extension _getExtension(String extension) { + switch (extension) { + case 'png': + return Extension.png; + case 'jpeg': + return Extension.jpeg; + case 'webp': + return Extension.webp; + case 'otf': + return Extension.otf; + case 'ttf': + return Extension.ttf; + } + return Extension.unknown; +} + +Type _getType(String extension) { + switch (extension) { + case 'png': + case 'jpeg': + case 'webp': + return Type.image; + case 'otf': + case 'ttf': + return Type.font; + } + return Type.unknown; +} + +enum Extension { + otf, + ttf, + jpeg, + png, + webp, + unknown, +} + +enum Type { + font, + image, + unknown, +} diff --git a/lib/src/asset_loader.dart b/lib/src/asset_loader.dart new file mode 100644 index 0000000..eb7c26f --- /dev/null +++ b/lib/src/asset_loader.dart @@ -0,0 +1,101 @@ +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:rive/src/asset.dart'; +import 'package:rive/src/rive_core/assets/file_asset.dart'; +import 'package:rive/src/utilities/utilities.dart'; + +import 'core/importers/file_asset_importer.dart'; + +class CDNAssetLoader extends FileAssetLoader { + final String baseUrl; + CDNAssetLoader(this.baseUrl); + + @override + bool isCompatible(FileAsset asset) => asset.cdnUuid.isNotEmpty; + + @override + Future load(FileAsset asset) async { + // do we have a url builder, dart seems to suck a bit for this. + var url = baseUrl; + if (!baseUrl.endsWith('/')) { + url += '/'; + } + url += formatUuid( + uuidVariant2(asset.cdnUuid), + ); + + final res = await http.get(Uri.parse(url)); + await asset.decode( + Uint8List.view(res.bodyBytes.buffer), + ); + return true; + } +} + +class LocalAssetLoader extends FileAssetLoader { + final String fontPath; + final String imagePath; + final AssetBundle _assetBundle; + + LocalAssetLoader({ + required this.fontPath, + required this.imagePath, + AssetBundle? assetBundle, + }) : _assetBundle = assetBundle ?? rootBundle; + + @override + Future load(FileAsset asset) async { + String? assetPath; + switch (asset.type) { + case Type.unknown: + return false; + case Type.image: + assetPath = imagePath + asset.name; + break; + case Type.font: + assetPath = fontPath + asset.name; + break; + } + + final bytes = await _assetBundle.load(assetPath); + await asset.decode(Uint8List.view(bytes.buffer)); + return true; + } +} + +class CallbackAssetLoader extends FileAssetLoader { + Future Function(FileAsset) callback; + + CallbackAssetLoader(this.callback); + + @override + Future load(FileAsset asset) async { + return callback(asset); + } +} + +// Just a thought, can load assets from a few sources +// maybe pointless tbh, if people have a path here, they should sort it out... +// might be helpful users have a few different setups & simple want to +// be able to copy pasta their asset loading setup. +class FallbackAssetLoader extends FileAssetLoader { + // by default, unless the data was inline we check locally & then on our cdn + final List fileAssetLoaders; + + FallbackAssetLoader(this.fileAssetLoaders); + + @override + Future load(FileAsset asset) async { + for (var i = 0; i < fileAssetLoaders.length; i++) { + final resolver = fileAssetLoaders[i]; + if (!resolver.isCompatible(asset)) { + continue; + } + final success = await resolver.load(asset); + if (success) { + return true; + } + } + return false; + } +} diff --git a/lib/src/core/importers/file_asset_importer.dart b/lib/src/core/importers/file_asset_importer.dart index 91e0cbb..d90205f 100644 --- a/lib/src/core/importers/file_asset_importer.dart +++ b/lib/src/core/importers/file_asset_importer.dart @@ -2,31 +2,52 @@ import 'package:rive/src/core/core.dart'; import 'package:rive/src/rive_core/assets/file_asset.dart'; import 'package:rive/src/rive_core/assets/file_asset_contents.dart'; -/// A helper for resolving Rive file assets (like images) that are provided out -/// of band with regards to the .riv file itself. +/// QUESTION: renamed this to loader & importer +/// we use the "importer" names elsewhere +/// +/// but IMO, the function names are pretty telling, +/// now the loader has "loadContents" & the resolver has "resolve" +/// before this was backwards. +/// ( +/// also as part of our import process, we import components, +/// some components have additional *resolvers* added to the stack, which +/// get ... resolved... and when components mention assets, those assets get +/// loaded +/// ) + // ignore: one_member_abstracts -abstract class FileAssetResolver { - Future loadContents(FileAsset asset); +abstract class FileAssetLoader { + Future load(FileAsset asset); + bool isCompatible(FileAsset asset) => true; } +// this should be the resolver. class FileAssetImporter extends ImportStackObject { - final FileAssetResolver? assetResolver; + final FileAssetLoader? assetLoader; final FileAsset fileAsset; + final bool importEmbeddedAssets; - FileAssetImporter(this.fileAsset, this.assetResolver); + FileAssetImporter( + this.fileAsset, + this.assetLoader, { + this.importEmbeddedAssets = true, + }); - bool _loadedContents = false; + bool _contentsResolved = false; - void loadContents(FileAssetContents contents) { - _loadedContents = true; - fileAsset.decode(contents.bytes); + // awkward name + void resolveContents(FileAssetContents contents) { + if (importEmbeddedAssets) { + _contentsResolved = true; + fileAsset.decode(contents.bytes); + } } @override bool resolve() { - if (!_loadedContents) { + if (!_contentsResolved) { // try to get them out of band - assetResolver?.loadContents(fileAsset).then(fileAsset.decode); + assetLoader?.load(fileAsset); } return super.resolve(); } diff --git a/lib/src/generated/assets/file_asset_base.dart b/lib/src/generated/assets/file_asset_base.dart index c10a767..09ac6bd 100644 --- a/lib/src/generated/assets/file_asset_base.dart +++ b/lib/src/generated/assets/file_asset_base.dart @@ -1,6 +1,7 @@ /// Core automatically generated lib/src/generated/assets/file_asset_base.dart. /// Do not modify manually. +import 'package:rive/src/core/core.dart'; import 'package:rive/src/rive_core/assets/asset.dart'; abstract class FileAssetBase extends Asset { @@ -34,9 +35,34 @@ abstract class FileAssetBase extends Asset { void assetIdChanged(int from, int to); + /// -------------------------------------------------------------------------- + /// CdnUuid field with key 359. + static final Uint8List cdnUuidInitialValue = Uint8List(0); + Uint8List _cdnUuid = cdnUuidInitialValue; + static const int cdnUuidPropertyKey = 359; + + /// The cdn uuid if it exists + Uint8List get cdnUuid => _cdnUuid; + + /// Change the [_cdnUuid] field value. + /// [cdnUuidChanged] will be invoked only if the field's value has changed. + set cdnUuid(Uint8List value) { + if (listEquals(_cdnUuid, value)) { + return; + } + Uint8List from = _cdnUuid; + _cdnUuid = value; + if (hasValidated) { + cdnUuidChanged(from, value); + } + } + + void cdnUuidChanged(Uint8List from, Uint8List to); + @override void copy(covariant FileAssetBase source) { super.copy(source); _assetId = source._assetId; + _cdnUuid = source._cdnUuid; } } diff --git a/lib/src/generated/rive_core_context.dart b/lib/src/generated/rive_core_context.dart index 739021e..a591292 100644 --- a/lib/src/generated/rive_core_context.dart +++ b/lib/src/generated/rive_core_context.dart @@ -1494,6 +1494,11 @@ class RiveCoreContext { object.assetId = value; } break; + case FileAssetBase.cdnUuidPropertyKey: + if (object is FileAssetBase && value is Uint8List) { + object.cdnUuid = value; + } + break; case DrawableAssetBase.heightPropertyKey: if (object is DrawableAssetBase && value is double) { object.height = value; @@ -1757,6 +1762,7 @@ class RiveCoreContext { case GradientStopBase.colorValuePropertyKey: return colorType; case MeshBase.triangleIndexBytesPropertyKey: + case FileAssetBase.cdnUuidPropertyKey: case FileAssetContentsBase.bytesPropertyKey: return bytesType; default: @@ -2260,6 +2266,8 @@ class RiveCoreContext { switch (propertyKey) { case MeshBase.triangleIndexBytesPropertyKey: return (object as MeshBase).triangleIndexBytes; + case FileAssetBase.cdnUuidPropertyKey: + return (object as FileAssetBase).cdnUuid; case FileAssetContentsBase.bytesPropertyKey: return (object as FileAssetContentsBase).bytes; } @@ -3453,6 +3461,11 @@ class RiveCoreContext { object.triangleIndexBytes = value; } break; + case FileAssetBase.cdnUuidPropertyKey: + if (object is FileAssetBase) { + object.cdnUuid = value; + } + break; case FileAssetContentsBase.bytesPropertyKey: if (object is FileAssetContentsBase) { object.bytes = value; diff --git a/lib/src/rive_core/assets/file_asset.dart b/lib/src/rive_core/assets/file_asset.dart index 47f6d10..74d9e4b 100644 --- a/lib/src/rive_core/assets/file_asset.dart +++ b/lib/src/rive_core/assets/file_asset.dart @@ -8,6 +8,9 @@ abstract class FileAsset extends FileAssetBase { @override void assetIdChanged(int from, int to) {} + @override + void cdnUuidChanged(Uint8List from, Uint8List to) {} + Future decode(Uint8List bytes); @override diff --git a/lib/src/rive_core/assets/file_asset_contents.dart b/lib/src/rive_core/assets/file_asset_contents.dart index fcddbd6..21d1b48 100644 --- a/lib/src/rive_core/assets/file_asset_contents.dart +++ b/lib/src/rive_core/assets/file_asset_contents.dart @@ -22,12 +22,11 @@ class FileAssetContents extends FileAssetContentsBase { @override bool import(ImportStack stack) { - var fileAssetImporter = - stack.latest(FileAssetBase.typeKey); - if (fileAssetImporter == null) { + var resolver = stack.latest(FileAssetBase.typeKey); + if (resolver == null) { return false; } - fileAssetImporter.loadContents(this); + resolver.resolveContents(this); return super.import(stack); } diff --git a/lib/src/rive_core/text/text.dart b/lib/src/rive_core/text/text.dart index 419f7cb..fd97c5c 100644 --- a/lib/src/rive_core/text/text.dart +++ b/lib/src/rive_core/text/text.dart @@ -172,7 +172,6 @@ class Text extends TextBase with TextStyleContainer { _syncRuns(); } - final Size _size = Size.zero; static const double paragraphSpacing = 20; diff --git a/lib/src/rive_core/text/text_style.dart b/lib/src/rive_core/text/text_style.dart index 51cc451..f12685d 100644 --- a/lib/src/rive_core/text/text_style.dart +++ b/lib/src/rive_core/text/text_style.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'dart:ui' as ui; +import 'package:rive/src/core/core.dart'; import 'package:rive/src/generated/text/text_style_base.dart'; import 'package:rive/src/rive_core/artboard.dart'; import 'package:rive/src/rive_core/assets/file_asset.dart'; @@ -142,6 +143,12 @@ class TextStyle extends TextStyleBase } } + @override + void copy(covariant TextStyle source) { + super.copy(source); + asset = source.asset; + } + @override void fontAssetIdChanged(int from, int to) { asset = context.resolve(to); @@ -149,12 +156,6 @@ class TextStyle extends TextStyleBase void _fontDecoded() => _markShapeDirty(); - @override - void onAddedDirty() { - super.onAddedDirty(); - asset = context.resolve(fontAssetId); - } - @override void onDirty(int mask) { super.onDirty(mask); @@ -296,4 +297,12 @@ class TextStyle extends TextStyleBase } } } + + @override + bool import(ImportStack stack) { + if (!registerWithImporter(stack)) { + return false; + } + return super.import(stack); + } } diff --git a/lib/src/rive_file.dart b/lib/src/rive_file.dart index c164553..4396704 100644 --- a/lib/src/rive_file.dart +++ b/lib/src/rive_file.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; +import 'package:rive/src/asset_loader.dart'; import 'package:rive/src/core/core.dart'; import 'package:rive/src/core/field_types/core_field_type.dart'; import 'package:rive/src/generated/animation/animation_state_base.dart'; @@ -10,6 +11,7 @@ import 'package:rive/src/generated/animation/any_state_base.dart'; import 'package:rive/src/generated/animation/blend_state_transition_base.dart'; import 'package:rive/src/generated/animation/entry_state_base.dart'; import 'package:rive/src/generated/animation/exit_state_base.dart'; +import 'package:rive/src/generated/assets/font_asset_base.dart'; import 'package:rive/src/generated/nested_artboard_base.dart'; import 'package:rive/src/local_file_io.dart' if (dart.library.html) 'package:rive/src/local_file_web.dart'; @@ -26,12 +28,14 @@ import 'package:rive/src/rive_core/animation/state_machine_listener.dart'; import 'package:rive/src/rive_core/animation/state_transition.dart'; import 'package:rive/src/rive_core/artboard.dart'; import 'package:rive/src/rive_core/assets/file_asset.dart'; +import 'package:rive/src/rive_core/assets/file_asset_contents.dart'; import 'package:rive/src/rive_core/assets/image_asset.dart'; import 'package:rive/src/rive_core/backboard.dart'; import 'package:rive/src/rive_core/component.dart'; import 'package:rive/src/rive_core/runtime/exceptions/rive_format_error_exception.dart'; import 'package:rive/src/rive_core/runtime/runtime_header.dart'; import 'package:rive/src/runtime_nested_artboard.dart'; + import 'package:rive_common/utilities.dart'; Core? _readRuntimeObject( @@ -85,13 +89,14 @@ class RiveFile { Backboard _backboard = Backboard.unknown; final _artboards = []; - final FileAssetResolver? _assetResolver; + final FileAssetLoader? _assetLoader; RiveFile._( BinaryReader reader, this.header, - this._assetResolver, - ) { + this._assetLoader, { + bool importEmbeddedAssets = true, + }) { /// Property fields table of contents final propertyToField = HashMap(); @@ -125,6 +130,12 @@ class RiveFile { } continue; } + // two options, either tell the fileAssetImporter, + // or simply skip the object. i think we should skip the object. + if (!importEmbeddedAssets && object is FileAssetContentsBase) { + // suppress importing embedded assets + continue; + } ImportStackObject? stackObject; var stackType = object.coreType; @@ -187,7 +198,13 @@ class RiveFile { break; } case ImageAssetBase.typeKey: - stackObject = FileAssetImporter(object as FileAsset, _assetResolver); + case FontAssetBase.typeKey: + // all these stack objects are resolvers. they get resolved. + stackObject = FileAssetImporter( + object as FileAsset, + _assetLoader, + importEmbeddedAssets: importEmbeddedAssets, + ); stackType = FileAssetBase.typeKey; break; default: @@ -250,42 +267,80 @@ class RiveFile { /// [RiveUnsupportedVersionException] if the version is not supported. factory RiveFile.import( ByteData bytes, { - FileAssetResolver? assetResolver, + FileAssetLoader? assetLoader, + bool cdn = true, + bool importEmbeddedAssets = true, }) { var reader = BinaryReader(bytes); - return RiveFile._(reader, RuntimeHeader.read(reader), assetResolver); + return RiveFile._( + reader, + RuntimeHeader.read(reader), + FallbackAssetLoader( + [ + if (assetLoader != null) assetLoader, + if (cdn) CDNAssetLoader('https://public.rive.app/cdn/uuid'), + ], + ), + importEmbeddedAssets: importEmbeddedAssets, + ); } - /// Imports a Rive file from an asset bundle. Provide [basePath] if any nested - /// Rive asset isn't in the same path as the [bundleKey]. - static Future asset(String bundleKey, {String? basePath}) async { - final bytes = await rootBundle.load(bundleKey); - if (basePath == null) { - int index = bundleKey.lastIndexOf('/'); - if (index != -1) { - basePath = bundleKey.substring(0, index + 1); - } else { - // ignore: parameter_assignments - basePath = ''; - } - } - return RiveFile.import(bytes, assetResolver: _LocalAssetResolver(basePath)); + /// Imports a Rive file from an asset bundle. + /// By default we will look for out of bound assets next to the [bundleKey] & check + /// Rive's CDN for content. + static Future asset( + String bundleKey, { + FileAssetLoader? assetLoader, + bool cdn = true, + bool importEmbeddedAssets = true, + AssetBundle? bundle, + }) async { + final bytes = await (bundle ?? rootBundle).load( + bundleKey, + ); + + return RiveFile.import( + bytes, + assetLoader: assetLoader, + cdn: cdn, + importEmbeddedAssets: importEmbeddedAssets, + ); } /// Imports a Rive file from a URL over HTTP. Provide an [assetResolver] if /// your file contains images that needed to be loaded with separate network /// requests. - static Future network(String url, - {FileAssetResolver? assetResolver, Map? headers}) async { + static Future network( + String url, { + FileAssetLoader? assetLoader, + Map? headers, + bool cdn = true, + bool importEmbeddedAssets = true, + }) async { final res = await http.get(Uri.parse(url), headers: headers); final bytes = ByteData.view(res.bodyBytes.buffer); - return RiveFile.import(bytes, assetResolver: assetResolver); + return RiveFile.import( + bytes, + assetLoader: assetLoader, + cdn: cdn, + importEmbeddedAssets: importEmbeddedAssets, + ); } /// Imports a Rive file from local folder - static Future file(String path) async { + static Future file( + String path, { + FileAssetLoader? assetLoader, + bool cdn = true, + bool importEmbeddedAssets = true, + }) async { final bytes = await localFileBytes(path); - return RiveFile.import(ByteData.view(bytes!.buffer)); + return RiveFile.import( + ByteData.view(bytes!.buffer), + assetLoader: assetLoader, + importEmbeddedAssets: importEmbeddedAssets, + cdn: cdn, + ); } /// Returns all artboards in the file @@ -299,25 +354,3 @@ class RiveFile { Artboard? artboardByName(String name) => _artboards.firstWhereOrNull((a) => a.name == name); } - -/// Resolves a Rive asset from the network provided a [baseUrl]. -class NetworkAssetResolver extends FileAssetResolver { - final String baseUrl; - NetworkAssetResolver(this.baseUrl); - - @override - Future loadContents(FileAsset asset) async { - final res = await http.get(Uri.parse(baseUrl + asset.uniqueFilename)); - return Uint8List.view(res.bodyBytes.buffer); - } -} - -class _LocalAssetResolver extends FileAssetResolver { - String basePath; - _LocalAssetResolver(this.basePath); - @override - Future loadContents(FileAsset asset) async { - final bytes = await rootBundle.load(basePath + asset.uniqueFilename); - return Uint8List.view(bytes.buffer); - } -} diff --git a/lib/src/utilities/utilities.dart b/lib/src/utilities/utilities.dart index 5003232..eb1a7fe 100644 --- a/lib/src/utilities/utilities.dart +++ b/lib/src/utilities/utilities.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + /// Szudzik's function for hashing two ints together int szudzik(int a, int b) { // a and b must be >= 0 @@ -5,3 +7,58 @@ int szudzik(int a, int b) { int y = b.abs(); return x >= y ? x * x + x + y : x + y * y; } + +String byteToHex(int byte) { + return byte.toRadixString(16).padLeft(2, '0'); +} + +/// Adapted from: +/// https://github.com/daegalus/dart-uuid/blob/main/lib/parsing.dart +/// +/// Unparses a [buffer] of bytes and outputs a proper UUID string. +/// +/// Throws a [RangeError] exception if the [buffer] is not large enough to +/// hold the bytes. +String formatUuid(Uint8List buffer) { + if (buffer.length < 16) { + throw RangeError('buffer too small: need 16: length=${buffer.length}'); + } + var i = 0; + return '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}' + '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}' + '-' + '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}' + '-' + '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}' + '-' + '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}' + '-' + '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}' + '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}' + '${byteToHex(buffer[i++])}${byteToHex(buffer[i++])}'; +} + +Uint8List uuidVariant2(Uint8List uuidBuffer) { + return Uint8List.fromList([ + uuidBuffer[3], + uuidBuffer[2], + uuidBuffer[1], + uuidBuffer[0], + // - + uuidBuffer[5], + uuidBuffer[4], + // - + uuidBuffer[7], + uuidBuffer[6], + // - + uuidBuffer[9], + uuidBuffer[8], + // - + uuidBuffer[15], + uuidBuffer[14], + uuidBuffer[13], + uuidBuffer[12], + uuidBuffer[11], + uuidBuffer[10], + ]); +} diff --git a/lib/src/widgets/rive_animation.dart b/lib/src/widgets/rive_animation.dart index 7cc0477..2e1acd6 100644 --- a/lib/src/widgets/rive_animation.dart +++ b/lib/src/widgets/rive_animation.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:rive/rive.dart'; +import 'package:rive/src/core/importers/file_asset_importer.dart'; import 'package:rive_common/math.dart'; /// Specifies whether a source is from an asset bundle or http @@ -58,6 +59,14 @@ class RiveAnimation extends StatefulWidget { /// Headers for network requests final Map? headers; + /// Setting to tell our rive file that it should load embedded assets + /// disable this customize assets even when embedded + final bool? importEmbeddedAssets; + + /// Specify an assetLoader explicitley, leave this blank to let rive + /// chose how to load an asset itself. + final FileAssetLoader? assetLoader; + /// Creates a new [RiveAnimation] from an asset bundle. /// /// *Example:* @@ -75,6 +84,8 @@ class RiveAnimation extends StatefulWidget { this.antialiasing = true, this.controllers = const [], this.onInit, + this.importEmbeddedAssets = true, + this.assetLoader, Key? key, }) : name = asset, file = null, @@ -100,6 +111,8 @@ class RiveAnimation extends StatefulWidget { this.controllers = const [], this.onInit, this.headers, + this.importEmbeddedAssets = true, + this.assetLoader, Key? key, }) : name = url, file = null, @@ -123,7 +136,9 @@ class RiveAnimation extends StatefulWidget { this.antialiasing = true, this.controllers = const [], this.onInit, + this.importEmbeddedAssets = true, Key? key, + this.assetLoader, }) : name = path, file = null, headers = null, @@ -152,6 +167,8 @@ class RiveAnimation extends StatefulWidget { Key? key, }) : name = null, headers = null, + importEmbeddedAssets = null, + assetLoader = null, src = _Source.direct, super(key: key); @@ -187,13 +204,28 @@ class RiveAnimationState extends State { Future _loadRiveFile() { switch (widget.src) { case _Source.asset: - return RiveFile.asset(widget.name!); + return RiveFile.asset( + widget.name!, + importEmbeddedAssets: widget.importEmbeddedAssets!, + assetLoader: widget.assetLoader, + ); case _Source.network: - return RiveFile.network(widget.name!, headers: widget.headers); + return RiveFile.network( + widget.name!, + headers: widget.headers, + importEmbeddedAssets: widget.importEmbeddedAssets!, + assetLoader: widget.assetLoader, + ); case _Source.file: - return RiveFile.file(widget.name!); + return RiveFile.file( + widget.name!, + importEmbeddedAssets: widget.importEmbeddedAssets!, + assetLoader: widget.assetLoader, + ); case _Source.direct: - return Future.value(widget.file!); + return Future.value( + widget.file!, + ); } } diff --git a/test/asset_test.dart b/test/asset_test.dart new file mode 100644 index 0000000..b43f087 --- /dev/null +++ b/test/asset_test.dart @@ -0,0 +1,220 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rive/rive.dart'; +import 'package:rive/src/asset_loader.dart'; +import 'package:rive/src/rive_core/assets/image_asset.dart'; + +import 'mocks/mocks.dart'; +import 'src/network.dart'; +import 'src/utils.dart'; + +const assetName = 'CleanShot 2023-06-08 at 08.51.19@2x.png'; + +class MockAssetBundle extends Mock implements AssetBundle {} + +void main() { + setUpAll(() { + registerFallbackValue(ArtboardFake()); + registerFallbackValue(Uri()); + registerFallbackValue(Stream.value([])); + }); + group("Test loading rive file with embedded asset.", () { + testWidgets('Default load does not hit any url', + (WidgetTester tester) async { + final mockHttpClient = getMockHttpClient(); + + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/sample_image.riv'); + RiveFile.import( + riveBytes, + ); + }, createHttpClient: (_) => mockHttpClient); + + verifyNever(() => mockHttpClient.openUrl(any(), any())); + }); + + testWidgets('Disabling embedded assets also does not hit a url', + (WidgetTester tester) async { + final mockHttpClient = getMockHttpClient(); + + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/sample_image.riv'); + + RiveFile.import( + riveBytes, + importEmbeddedAssets: false, + ); + }, createHttpClient: (_) => mockHttpClient); + + // by default we try to make a network request + verifyNever(() => mockHttpClient.openUrl(any(), any())); + }); + + testWidgets('Disabling cdn also does not hit a url', + (WidgetTester tester) async { + final mockHttpClient = getMockHttpClient(); + + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/sample_image.riv'); + runZonedGuarded(() { + RiveFile.import( + riveBytes, + importEmbeddedAssets: false, + cdn: false, + ); + }, (error, stack) { + // importing assets throws rn when we do not end up loading assets.. + // could suppress this too.. + // could ignore them & info log our load attempts. + + // could have a future people can await to get any issues + // could have a future people can await to get logs... + }); + }, createHttpClient: (_) => mockHttpClient); + + // network disabled + verifyNever(() => mockHttpClient.openUrl(any(), any())); + // by default we try to check for assets + }); + testWidgets('test importing rive file, make sure we get a good callback', + (WidgetTester tester) async { + // lets just return an image + final riveBytes = loadFile('assets/sample_image.riv'); + final imageBytes = loadFile('assets/file.png'); + final parameters = []; + RiveFile.import(riveBytes, importEmbeddedAssets: false, + assetLoader: CallbackAssetLoader( + (asset) async { + parameters.add(asset); + await asset.decode(Uint8List.sublistView( + imageBytes, + )); + return true; + }, + )); + + final asset = parameters.first; + + expect(asset is ImageAsset, true); + final fileAsset = asset as ImageAsset; + expect(fileAsset.extension, Extension.png); + expect(fileAsset.type, Type.image); + expect(fileAsset.name, assetName); + expect(fileAsset.assetId, 42981); + expect(fileAsset.id, -1); + }); + }); + group("Test loading rive file with cdn asset.", () { + late MockHttpClient mockHttpClient; + setUp(() { + mockHttpClient = getMockHttpClient(); + final imageBytes = loadFile('assets/file.png'); + prepMockRequest(mockHttpClient, Uint8List.sublistView(imageBytes)); + }); + + testWidgets('Default load will his the cdn', (WidgetTester tester) async { + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/cdn_image.riv'); + RiveFile.import( + riveBytes, + ); + }, createHttpClient: (_) => mockHttpClient); + + verify(() => mockHttpClient.openUrl( + any(), + // ok, hardcoded for the cdn_image.riv file. + Uri.parse( + 'https://public.rive.app/cdn/uuid/b86dc1e6-35f7-4490-96fc-89ebdf848473'), + )).called(1); + }); + + testWidgets('Disabling embedded assets also hits a url', + (WidgetTester tester) async { + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/cdn_image.riv'); + runZonedGuarded(() { + RiveFile.import( + riveBytes, + importEmbeddedAssets: false, + ); + }, (error, stack) { + print('what?'); + }); + }, createHttpClient: (_) => mockHttpClient); + + // by default we try to make a network request + verify(() => mockHttpClient.openUrl(any(), any())).called(1); + }); + + testWidgets('Disabling cdn will mean no url hit', + (WidgetTester tester) async { + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/cdn_image.riv'); + + RiveFile.import( + riveBytes, + importEmbeddedAssets: false, + cdn: false, + ); + }, createHttpClient: (_) => mockHttpClient); + + // network disabled + verifyNever(() => mockHttpClient.openUrl(any(), any())); + // by default we try to check for assets + }); + testWidgets( + 'If we provide a callback, we are hit first, and success means no cdn hit', + (WidgetTester tester) async { + // lets just return an image + final imageBytes = loadFile('assets/file.png'); + final parameters = []; + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/cdn_image.riv'); + + RiveFile.import(riveBytes, importEmbeddedAssets: false, + assetLoader: CallbackAssetLoader( + (asset) async { + parameters.add(asset); + await asset.decode(Uint8List.sublistView( + imageBytes, + )); + return true; + }, + )); + }, createHttpClient: (_) => mockHttpClient); + + final asset = parameters.first; + + expect(asset is ImageAsset, true); + verifyNever(() => mockHttpClient.openUrl(any(), any())); + }); + + testWidgets( + 'If we provide a callback, we are hit first, a failure means we hit cdn', + (WidgetTester tester) async { + // lets just return an image + final parameters = []; + await HttpOverrides.runZoned(() async { + final riveBytes = loadFile('assets/cdn_image.riv'); + + RiveFile.import(riveBytes, importEmbeddedAssets: false, + assetLoader: CallbackAssetLoader( + (asset) async { + parameters.add(asset); + return false; + }, + )); + }, createHttpClient: (_) => mockHttpClient); + + final asset = parameters.first; + + expect(asset is ImageAsset, true); + verify(() => mockHttpClient.openUrl(any(), any())).called(1); + }); + }); +} diff --git a/test/assets/cdn_image.riv b/test/assets/cdn_image.riv new file mode 100644 index 0000000..ed6de3d Binary files /dev/null and b/test/assets/cdn_image.riv differ diff --git a/test/assets/file.png b/test/assets/file.png new file mode 100644 index 0000000..6a1856e Binary files /dev/null and b/test/assets/file.png differ diff --git a/test/assets/sample_image.riv b/test/assets/sample_image.riv new file mode 100644 index 0000000..e0db652 Binary files /dev/null and b/test/assets/sample_image.riv differ diff --git a/test/rive_network_test.dart b/test/rive_network_test.dart index fa72ad4..15d2b56 100644 --- a/test/rive_network_test.dart +++ b/test/rive_network_test.dart @@ -6,16 +6,9 @@ import 'package:mocktail/mocktail.dart'; import 'package:rive/rive.dart'; import 'mocks/mocks.dart'; +import 'src/network.dart'; import 'src/utils.dart'; -class MockHttpClient extends Mock implements HttpClient {} - -class MockHttpClientRequest extends Mock implements HttpClientRequest {} - -class MockHttpClientResponse extends Mock implements HttpClientResponse {} - -class MockHttpHeaders extends Mock implements HttpHeaders {} - void main() { late MockHttpClient mockHttpClient; late MockHttpClientRequest request; @@ -26,25 +19,9 @@ void main() { // Build our app and trigger a frame. final riveBytes = loadFile('assets/rive-flutter-test-asset.riv'); final body = riveBytes.buffer.asUint8List(); - mockHttpClient = MockHttpClient(); - request = MockHttpClientRequest(); + mockHttpClient = getMockHttpClient(); - when(() => request.headers).thenReturn(MockHttpHeaders()); - - when(() => mockHttpClient.openUrl(any(), any())).thenAnswer((invocation) { - final response = MockHttpClientResponse(); - when(request.close).thenAnswer((_) => Future.value(response)); - when(() => request.addStream(any())).thenAnswer((_) async => null); - when(() => response.headers).thenReturn(MockHttpHeaders()); - when(() => response.handleError(any(), test: any(named: 'test'))) - .thenAnswer((_) => Stream.value(body)); - when(() => response.statusCode).thenReturn(200); - when(() => response.reasonPhrase).thenReturn('OK'); - when(() => response.contentLength).thenReturn(body.length); - when(() => response.isRedirect).thenReturn(false); - when(() => response.persistentConnection).thenReturn(false); - return Future.value(request); - }); + request = prepMockRequest(mockHttpClient, body); }); testWidgets('Using the network, calls the http client without headers', diff --git a/test/src/network.dart b/test/src/network.dart new file mode 100644 index 0000000..0d87aa3 --- /dev/null +++ b/test/src/network.dart @@ -0,0 +1,39 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockHttpClient extends Mock implements HttpClient {} + +class MockHttpClientRequest extends Mock implements HttpClientRequest {} + +class MockHttpClientResponse extends Mock implements HttpClientResponse {} + +class MockHttpHeaders extends Mock implements HttpHeaders {} + +MockHttpClient getMockHttpClient() => MockHttpClient(); +MockHttpClientRequest prepMockRequest( + MockHttpClient httpClient, + Uint8List body, +) { + MockHttpClientRequest request = MockHttpClientRequest(); + + when(() => request.headers).thenReturn(MockHttpHeaders()); + + when(() => httpClient.openUrl(any(), any())).thenAnswer((invocation) { + final response = MockHttpClientResponse(); + when(request.close).thenAnswer((_) => Future.value(response)); + when(() => request.addStream(any())).thenAnswer((_) async => null); + when(() => response.headers).thenReturn(MockHttpHeaders()); + when(() => response.handleError(any(), test: any(named: 'test'))) + .thenAnswer((_) => Stream.value(body)); + when(() => response.statusCode).thenReturn(200); + when(() => response.reasonPhrase).thenReturn('OK'); + when(() => response.contentLength).thenReturn(body.length); + when(() => response.isRedirect).thenReturn(false); + when(() => response.persistentConnection).thenReturn(false); + return Future.value(request); + }); + return request; +}