Font dart runtime

There are a number of questions I'd like to resolve before considering to merge this. also a few thigns to clean up no doubt

# Questions before merging
To customize loading out of band assets, we expect our users to implement
```
abstract class FileAssetLoader {
  Future<bool> load(FileAsset asset);
  bool isCompatible(FileAsset asset) => true;
}
```

1. is this a good interface (i've changed `loadContents`, to `load`)? (if we like this we should change this in cpp too)
2. `FileAssetLoader` is a mouth-full, would `AssetLoader` be better?
3. We are passing `FileAssets` (our core objects) to users with some slight api extensions, is that good? should we just wrap this in an Asset class (I had this before, its not a lot of work to get it back)

things sorted
- [x] cdn "loading" vs url loading
    - just sticking with cdn, users can customize for url
- [x] asset class for consumers of our runtime.
    - i've avoided this one for now, just extending our FileAsset
- [x] Importer/Resolver/Loader. I flipped some names around, mostly because I want our end users to provide an `AssetLoader`, not a resolver.

things to sort out down the line, i'm declaring them out of scope for this pr, fft disagree:
- Fallback font, I see we have a fallback font file hardcoded in the runtime. should investigate if we can include an asset for people like this, or if we need to have users set this if they want to use fallback fonts.
- Image Placement, we should strip width/height from runtimes on ImageAssets
- What do we want to do about asset loading / decoding errors
- TextStyle has both assetId & fontAssetId it gets assetId from the file asset referencer, its nbd,

Diffs=
06e959ad2 Font dart runtime (#5411)

Co-authored-by: Maxwell Talbot <talbot.maxwell@gmail.com>
This commit is contained in:
mjtalbot
2023-07-06 08:49:14 +00:00
parent ba5f9afbac
commit 5def5d7b1c
30 changed files with 805 additions and 103 deletions

View File

@ -1 +1 @@
9007b7f92943caf8ae4ba90ba198bfabca5f2b60
06e959ad21ead2df3d477652f1c208a7ea6e959d

17
.vscode/launch.json vendored Normal file
View File

@ -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": {
}
}
]
}

BIN
example/assets/asset.riv Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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<CustomAssetLoading> createState() => _CustomAssetLoadingState();
}
class _CustomAssetLoadingState extends State<CustomAssetLoading> {
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),
),
],
),
),
);
}
}

View File

@ -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<RiveExampleApp> {
const _Page('State Machine with Listener', StateMachineListener()),
const _Page('Skinning Demo', SkinningDemo()),
const _Page('Animation Carousel', AnimationCarousel()),
const _Page('Custom Asset Loading', CustomAssetLoading()),
];
@override

View File

@ -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

View File

@ -13,6 +13,7 @@ dependencies:
sdk: flutter
rive:
path: ../
http:
dev_dependencies:
flutter_test:
@ -24,3 +25,4 @@ flutter:
assets:
- assets/
- assets/fonts/

View File

@ -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';

54
lib/src/asset.dart Normal file
View File

@ -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,
}

101
lib/src/asset_loader.dart Normal file
View File

@ -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<bool> 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<bool> 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<bool> Function(FileAsset) callback;
CallbackAssetLoader(this.callback);
@override
Future<bool> 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<FileAssetLoader> fileAssetLoaders;
FallbackAssetLoader(this.fileAssetLoaders);
@override
Future<bool> 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;
}
}

View File

@ -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<Uint8List> loadContents(FileAsset asset);
abstract class FileAssetLoader {
Future<bool> 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();
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -8,6 +8,9 @@ abstract class FileAsset extends FileAssetBase {
@override
void assetIdChanged(int from, int to) {}
@override
void cdnUuidChanged(Uint8List from, Uint8List to) {}
Future<void> decode(Uint8List bytes);
@override

View File

@ -22,12 +22,11 @@ class FileAssetContents extends FileAssetContentsBase {
@override
bool import(ImportStack stack) {
var fileAssetImporter =
stack.latest<FileAssetImporter>(FileAssetBase.typeKey);
if (fileAssetImporter == null) {
var resolver = stack.latest<FileAssetImporter>(FileAssetBase.typeKey);
if (resolver == null) {
return false;
}
fileAssetImporter.loadContents(this);
resolver.resolveContents(this);
return super.import(stack);
}

View File

@ -172,7 +172,6 @@ class Text extends TextBase with TextStyleContainer {
_syncRuns();
}
final Size _size = Size.zero;
static const double paragraphSpacing = 20;

View File

@ -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);
}
}

View File

@ -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<CoreContext>? _readRuntimeObject(
@ -85,13 +89,14 @@ class RiveFile {
Backboard _backboard = Backboard.unknown;
final _artboards = <Artboard>[];
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<int, CoreFieldType>();
@ -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<RiveFile> 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<RiveFile> 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<RiveFile> network(String url,
{FileAssetResolver? assetResolver, Map<String, String>? headers}) async {
static Future<RiveFile> network(
String url, {
FileAssetLoader? assetLoader,
Map<String, String>? 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<RiveFile> file(String path) async {
static Future<RiveFile> 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<Uint8List> 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<Uint8List> loadContents(FileAsset asset) async {
final bytes = await rootBundle.load(basePath + asset.uniqueFilename);
return Uint8List.view(bytes.buffer);
}
}

View File

@ -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],
]);
}

View File

@ -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<String, String>? 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<RiveAnimation> {
Future<RiveFile> _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!,
);
}
}

220
test/asset_test.dart Normal file
View File

@ -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(<int>[]));
});
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);
});
});
}

BIN
test/assets/cdn_image.riv Normal file

Binary file not shown.

BIN
test/assets/file.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

View File

@ -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',

39
test/src/network.dart Normal file
View File

@ -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;
}