@octane-xplat/lottie 0.0.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/dist/native/cx.mobile.js +11 -0
- package/dist/native/escape-props.js +39 -0
- package/dist/native/index.js +182 -0
- package/dist/native/layout-child.js +38 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/vendor/ui-lottie/index.android.js +286 -0
- package/dist/native/vendor/ui-lottie/index.ios.js +214 -0
- package/dist/native/vendor/ui-lottie/src/lottie/index.common.js +50 -0
- package/dist/native/vendor/ui-lottie/src/lottie/utils.js +6 -0
- package/dist/web/escape-props.web.js +17 -0
- package/dist/web/index.js +332 -0
- package/dist/web/layout-child.web.js +47 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +81 -10
- package/platforms/android/include.gradle +7 -0
- package/platforms/android/java/com/nativescript/lottie/LottieAnimationView.java +92 -0
- package/platforms/android/lottie.aar +0 -0
- package/platforms/android/native-api-usage.json +16 -0
- package/platforms/ios/Podfile +5 -0
- package/src/Lottie.tsrx +161 -0
- package/src/Lottie.web.tsrx +135 -0
- package/src/cx.mobile.ts +8 -0
- package/src/escape-props.ts +65 -0
- package/src/escape-props.web.ts +25 -0
- package/src/index.ts +2 -0
- package/src/index.web.ts +2 -0
- package/src/layout-child.ts +57 -0
- package/src/layout-child.web.ts +108 -0
- package/src/native-elements.d.ts +8 -0
- package/src/props.ts +128 -0
- package/src/references.d.ts +3 -0
- package/src/vendor/ui-lottie/LICENSE.md +21 -0
- package/src/vendor/ui-lottie/src/lottie/index.android.ts +447 -0
- package/src/vendor/ui-lottie/src/lottie/index.common.ts +84 -0
- package/src/vendor/ui-lottie/src/lottie/index.d.ts +133 -0
- package/src/vendor/ui-lottie/src/lottie/index.ios.ts +345 -0
- package/src/vendor/ui-lottie/src/lottie/typings/android.d.ts +4149 -0
- package/src/vendor/ui-lottie/src/lottie/typings/android.nativescript.d.ts +12 -0
- package/src/vendor/ui-lottie/src/lottie/typings/dotlottie.ios.d.ts +14 -0
- package/src/vendor/ui-lottie/src/lottie/typings/ios.d.ts +384 -0
- package/src/vendor/ui-lottie/src/lottie/utils.ts +9 -0
- package/types/generated/.tsrx-typegen-manifest.json +18 -0
- package/types/generated/Lottie.d.ts +7 -0
- package/types/generated/Lottie.web.d.ts +7 -0
- package/types/generated/cx.mobile.d.ts +6 -0
- package/types/generated/escape-props.d.ts +15 -0
- package/types/generated/escape-props.web.d.ts +5 -0
- package/types/generated/index.d.ts +2 -0
- package/types/generated/index.web.d.ts +2 -0
- package/types/generated/layout-child.d.ts +6 -0
- package/types/generated/layout-child.web.d.ts +15 -0
- package/types/generated/props.d.ts +106 -0
- package/types/generated/vendor/ui-lottie/src/lottie/index.common.d.ts +30 -0
- package/types/generated/vendor/ui-lottie/src/lottie/index.ios.d.ts +35 -0
- package/types/generated/vendor/ui-lottie/src/lottie/utils.d.ts +1 -0
- package/index.d.ts +0 -1
- package/index.js +0 -2
package/package.json
CHANGED
|
@@ -1,18 +1,89 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/lottie",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Lottie animation leaf for Octane xplat — lottie-web on web + vendored @nativescript-community/ui-lottie (fork fixes) on native",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "https://github.com/octane-xplat/octane-xplat
|
|
7
|
+
"url": "https://github.com/octane-xplat/octane-xplat",
|
|
8
8
|
"directory": "packages/lottie"
|
|
9
9
|
},
|
|
10
|
-
"homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/lottie",
|
|
11
|
-
"main": "./index.js",
|
|
12
|
-
"types": "./index.d.ts",
|
|
13
10
|
"files": [
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
"dist",
|
|
12
|
+
"types",
|
|
13
|
+
"platforms",
|
|
14
|
+
"src/index.ts",
|
|
15
|
+
"src/index.web.ts",
|
|
16
|
+
"src/Lottie.tsrx",
|
|
17
|
+
"src/Lottie.web.tsrx",
|
|
18
|
+
"src/props.ts",
|
|
19
|
+
"src/native-elements.d.ts",
|
|
20
|
+
"src/references.d.ts",
|
|
21
|
+
"src/cx.mobile.ts",
|
|
22
|
+
"src/escape-props.ts",
|
|
23
|
+
"src/escape-props.web.ts",
|
|
24
|
+
"src/layout-child.ts",
|
|
25
|
+
"src/layout-child.web.ts",
|
|
26
|
+
"src/vendor/ui-lottie/LICENSE.md",
|
|
27
|
+
"src/vendor/ui-lottie/src/lottie/index.android.ts",
|
|
28
|
+
"src/vendor/ui-lottie/src/lottie/index.common.ts",
|
|
29
|
+
"src/vendor/ui-lottie/src/lottie/index.d.ts",
|
|
30
|
+
"src/vendor/ui-lottie/src/lottie/index.ios.ts",
|
|
31
|
+
"src/vendor/ui-lottie/src/lottie/utils.ts",
|
|
32
|
+
"src/vendor/ui-lottie/src/lottie/typings"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"web": {
|
|
38
|
+
"types": "./types/generated/index.web.d.ts",
|
|
39
|
+
"default": "./dist/web/index.js"
|
|
40
|
+
},
|
|
41
|
+
"native": {
|
|
42
|
+
"types": "./types/generated/index.d.ts",
|
|
43
|
+
"default": "./dist/native/index.js"
|
|
44
|
+
},
|
|
45
|
+
"types": "./types/generated/index.d.ts",
|
|
46
|
+
"default": "./dist/native/index.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"lottie-web": "5.13.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@nativescript-community/octane": "0.2.1",
|
|
57
|
+
"@nativescript/core": "9.1.2",
|
|
58
|
+
"@nativescript/types": "9.0.0",
|
|
59
|
+
"@octanejs/vite-plugin": "0.1.61",
|
|
60
|
+
"@tsrx/typescript-plugin": "0.4.11",
|
|
61
|
+
"octane": "0.6.3",
|
|
62
|
+
"tsrx-typegen": "0.2.1",
|
|
63
|
+
"typescript": "5.9.3",
|
|
64
|
+
"vite": "8.3.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
68
|
+
"@nativescript/core": ">=9.1.0 <10",
|
|
69
|
+
"octane": ">=0.6.3 <1"
|
|
70
|
+
},
|
|
71
|
+
"peerDependenciesMeta": {
|
|
72
|
+
"@nativescript-community/octane": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"@nativescript/core": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"nativescript": {
|
|
80
|
+
"platforms": {
|
|
81
|
+
"android": "6.0.0",
|
|
82
|
+
"ios": "6.0.1"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"scripts": {
|
|
86
|
+
"build": "vite build && vite build --mode native && tsrx-typegen --target octane && node ../../scripts/check-native-dist.mjs",
|
|
87
|
+
"test:packed": "node tests/packed-consumer.mjs"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
|
|
2
|
+
dependencies {
|
|
3
|
+
def androidXAppCompatVersion = project.hasProperty("androidXAppCompat") ? project.androidXAppCompat : "1.3.1"
|
|
4
|
+
implementation "androidx.appcompat:appcompat:$androidXAppCompatVersion"
|
|
5
|
+
def lottieVersion = project.hasProperty("lottieVersion") ? project.lottieVersion : "5.2.0"
|
|
6
|
+
implementation "com.airbnb.android:lottie:$lottieVersion"
|
|
7
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
package com.nativescript.lottie;
|
|
2
|
+
|
|
3
|
+
import androidx.core.view.ViewCompat;
|
|
4
|
+
import android.view.View.MeasureSpec;
|
|
5
|
+
import android.graphics.drawable.Drawable;
|
|
6
|
+
|
|
7
|
+
public class LottieAnimationView extends com.airbnb.lottie.LottieAnimationView {
|
|
8
|
+
private double scaleW = 1;
|
|
9
|
+
private double scaleH = 1;
|
|
10
|
+
|
|
11
|
+
public LottieAnimationView(android.content.Context context) {
|
|
12
|
+
super(context);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Override
|
|
16
|
+
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
|
17
|
+
|
|
18
|
+
int width = MeasureSpec.getSize(widthMeasureSpec);
|
|
19
|
+
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
|
|
20
|
+
|
|
21
|
+
int height = MeasureSpec.getSize(heightMeasureSpec);
|
|
22
|
+
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
|
|
23
|
+
|
|
24
|
+
Drawable drawable = this.getDrawable();
|
|
25
|
+
int measureWidth;
|
|
26
|
+
int measureHeight;
|
|
27
|
+
if (drawable != null) {
|
|
28
|
+
measureWidth = drawable.getIntrinsicWidth();
|
|
29
|
+
measureHeight = drawable.getIntrinsicHeight();
|
|
30
|
+
} else {
|
|
31
|
+
measureWidth = 0;
|
|
32
|
+
measureHeight = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
boolean finiteWidth = widthMode != MeasureSpec.UNSPECIFIED;
|
|
36
|
+
boolean finiteHeight = heightMode != MeasureSpec.UNSPECIFIED;
|
|
37
|
+
|
|
38
|
+
if (measureWidth != 0 && measureHeight != 0 && (finiteWidth || finiteHeight)) {
|
|
39
|
+
this.computeScaleFactor(width, height, finiteWidth, finiteHeight, measureWidth, measureHeight);
|
|
40
|
+
int resultW = (int) Math.round(measureWidth * this.scaleW);
|
|
41
|
+
int resultH = (int) Math.round(measureHeight * this.scaleH);
|
|
42
|
+
|
|
43
|
+
measureWidth = finiteWidth ? Math.min(resultW, width) : resultW;
|
|
44
|
+
measureHeight = finiteHeight ? Math.min(resultH, height) : resultH;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
measureWidth += this.getPaddingLeft() + this.getPaddingRight();
|
|
48
|
+
measureHeight += this.getPaddingTop() + this.getPaddingBottom();
|
|
49
|
+
|
|
50
|
+
measureWidth = Math.max(measureWidth, getSuggestedMinimumWidth());
|
|
51
|
+
measureHeight = Math.max(measureHeight, getSuggestedMinimumHeight());
|
|
52
|
+
|
|
53
|
+
int widthSizeAndState = ViewCompat.resolveSizeAndState(measureWidth, widthMeasureSpec, 0);
|
|
54
|
+
int heightSizeAndState = ViewCompat.resolveSizeAndState(measureHeight, heightMeasureSpec, 0);
|
|
55
|
+
|
|
56
|
+
this.setMeasuredDimension(widthSizeAndState, heightSizeAndState);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private void computeScaleFactor(int measureWidth, int measureHeight, boolean widthIsFinite, boolean heightIsFinite, double nativeWidth, double nativeHeight) {
|
|
60
|
+
|
|
61
|
+
this.scaleW = 1;
|
|
62
|
+
this.scaleH = 1;
|
|
63
|
+
|
|
64
|
+
ScaleType scale = this.getScaleType();
|
|
65
|
+
if ((scale == ScaleType.CENTER_CROP || scale == ScaleType.FIT_CENTER || scale == ScaleType.FIT_XY) &&
|
|
66
|
+
(widthIsFinite || heightIsFinite)) {
|
|
67
|
+
|
|
68
|
+
this.scaleW = (nativeWidth > 0) ? measureWidth / nativeWidth : 0d;
|
|
69
|
+
this.scaleH = (nativeHeight > 0) ? measureHeight / nativeHeight : 0d;
|
|
70
|
+
|
|
71
|
+
if (!widthIsFinite) {
|
|
72
|
+
this.scaleW = scaleH;
|
|
73
|
+
} else if (!heightIsFinite) {
|
|
74
|
+
this.scaleH = scaleW;
|
|
75
|
+
} else {
|
|
76
|
+
// No infinite dimensions.
|
|
77
|
+
switch (scale) {
|
|
78
|
+
case FIT_CENTER:
|
|
79
|
+
this.scaleH = this.scaleW < this.scaleH ? this.scaleW : this.scaleH;
|
|
80
|
+
this.scaleW = this.scaleH;
|
|
81
|
+
break;
|
|
82
|
+
case CENTER_CROP:
|
|
83
|
+
this.scaleH = this.scaleW > this.scaleH ? this.scaleW : this.scaleH;
|
|
84
|
+
this.scaleW = this.scaleH;
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"uses": [
|
|
3
|
+
"com.airbnb.lottie:LottieAnimationView",
|
|
4
|
+
"com.airbnb.lottie.model:KeyPath",
|
|
5
|
+
"com.airbnb.lottie.value:LottieValueCallback",
|
|
6
|
+
"com.airbnb.lottie:LottieProperty",
|
|
7
|
+
"com.airbnb.lottie:SimpleColorFilter",
|
|
8
|
+
"com.airbnb.lottie:RenderMode",
|
|
9
|
+
"android.animation:Animator",
|
|
10
|
+
"android.animation:Animator.AnimatorListener",
|
|
11
|
+
"com.airbnb.lottie:LottieResult",
|
|
12
|
+
"com.airbnb.lottie:LottieOnCompositionLoadedListener",
|
|
13
|
+
"com.airbnb.lottie:LottieComposition",
|
|
14
|
+
"com.airbnb.lottie:LottieCompositionFactory"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Pinned to the 4.4.1 branch HEAD — a branch reference resolves a moving
|
|
2
|
+
# target on every fresh pod install. The fork carries the
|
|
3
|
+
# CompatibleAnimation* ObjC shims + CompatibleLottieLogger this plugin needs.
|
|
4
|
+
pod 'lottie-ios', :git => 'https://github.com/farfromrefug/lottie-ios.git', :commit => 'fd75f8aaa47d9479fc43cf91dd1da557952f6c0c'
|
|
5
|
+
# pod 'lottie-ios', :path => '/Volumes/dev/ios/lottie-ios'
|
package/src/Lottie.tsrx
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'octane';
|
|
3
|
+
import { registerElement } from '@nativescript-community/octane';
|
|
4
|
+
import { isAndroid } from '@nativescript/core';
|
|
5
|
+
import { LottieView } from './vendor/ui-lottie/src/lottie';
|
|
6
|
+
import type { LottieHandle, LottieProps } from './props';
|
|
7
|
+
import { cx } from './cx.mobile';
|
|
8
|
+
import { applyEscapeProps, nativeAccessibilityRole, nativeAccessibilityState } from './escape-props';
|
|
9
|
+
import { layoutChildProps } from './layout-child';
|
|
10
|
+
|
|
11
|
+
registerElement('xplatlottie', LottieView);
|
|
12
|
+
|
|
13
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
14
|
+
|
|
15
|
+
const STRETCH: Record<string, string> = {
|
|
16
|
+
contain: 'aspectFit',
|
|
17
|
+
cover: 'aspectFill',
|
|
18
|
+
fill: 'fill',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Duration in ms on both platforms — the plugin's `duration` reports
|
|
22
|
+
* seconds on iOS and ms on Android. */
|
|
23
|
+
function durationMs(v: LottieView): number {
|
|
24
|
+
const d = v.duration ?? 0;
|
|
25
|
+
return isAndroid ? d : Math.max(0, d) * 1000;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function currentProgress(v: LottieView): number {
|
|
29
|
+
// the native type is platform-divergent (LottieAnimationView vs
|
|
30
|
+
// CompatibleAnimationView) — runtime branch is the narrowing.
|
|
31
|
+
const nv = v.nativeViewProtected as any;
|
|
32
|
+
return isAndroid ? (nv?.getProgress?.() ?? 0) : (nv?.currentProgress ?? 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stop = pause + reset playhead. iOS `stop()` resets to the start;
|
|
36
|
+
* Android's `cancelAnimation()` ends the animator and the next
|
|
37
|
+
* `playAnimation()` restarts from 0. */
|
|
38
|
+
function stopView(v: LottieView) {
|
|
39
|
+
if (isAndroid) v.cancelAnimation();
|
|
40
|
+
else v.nativeViewProtected?.stop();
|
|
41
|
+
|
|
42
|
+
v.progress = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lottie — vendored ui-lottie surface (see src/plugin). The plugin
|
|
46
|
+
* doesn't diff props, so src/play-state/speed/progress land imperatively
|
|
47
|
+
* in effects. `async` stays forced on: it keeps the load path uniform
|
|
48
|
+
* (one code path for inline/file/URL/.lottie) and avoids main-thread
|
|
49
|
+
* parse jank; autoPlay is re-applied when the composition lands. */
|
|
50
|
+
export function Lottie(props: LottieProps) @{
|
|
51
|
+
const host = useRef<LottieView | null>(null);
|
|
52
|
+
const appliedSrc = useRef<string | undefined>(undefined);
|
|
53
|
+
const loadedRef = useRef(false);
|
|
54
|
+
|
|
55
|
+
const bindHost = useCallback((v: LottieView) => {
|
|
56
|
+
host.current = v;
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const wantPlay = () => props.playing ?? props.autoPlay ?? true;
|
|
60
|
+
|
|
61
|
+
const onReady = () => {
|
|
62
|
+
const v = host.current;
|
|
63
|
+
if (!v || loadedRef.current) return;
|
|
64
|
+
loadedRef.current = true;
|
|
65
|
+
if (props.speed !== undefined) v.speed = props.speed;
|
|
66
|
+
if (props.progress !== undefined) v.progress = clamp01(props.progress);
|
|
67
|
+
props.onLoaded?.({ duration: durationMs(v) });
|
|
68
|
+
if (wantPlay() && !v.isAnimating()) v.playAnimation();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Mount wiring — events first so a synchronous src assignment can't
|
|
72
|
+
// race past them.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const v = host.current;
|
|
75
|
+
if (!v) return;
|
|
76
|
+
applyEscapeProps(v, props);
|
|
77
|
+
v.stretch = STRETCH[props.fit ?? 'contain'] as any;
|
|
78
|
+
v.async = true;
|
|
79
|
+
|
|
80
|
+
v.on(LottieView.compositionLoadedEvent, () => onReady());
|
|
81
|
+
v.on(LottieView.loadFailedEvent, (e: any) => {
|
|
82
|
+
loadedRef.current = false;
|
|
83
|
+
props.onError?.({ message: e?.error ? String(e.error) : 'load failed' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The vendored fix reports a single `false` on cancel — only a
|
|
87
|
+
// `true` finish reaches onEnded.
|
|
88
|
+
v.completionBlock = (finished: boolean) => {
|
|
89
|
+
if (finished) props.onEnded?.();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
props.bind?.(handle(v));
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
// src — applied imperatively after the listeners exist.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const v = host.current;
|
|
98
|
+
if (!v) return;
|
|
99
|
+
const src = props.data ? JSON.stringify(props.data) : props.src;
|
|
100
|
+
if (src === appliedSrc.current) return;
|
|
101
|
+
appliedSrc.current = src;
|
|
102
|
+
loadedRef.current = false;
|
|
103
|
+
v.src = src ?? '';
|
|
104
|
+
}, [props.src, props.data]);
|
|
105
|
+
|
|
106
|
+
// Controlled `playing` — play state is a method call, not a prop.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const v = host.current;
|
|
109
|
+
if (!v || props.playing === undefined) return;
|
|
110
|
+
if (props.playing) {
|
|
111
|
+
if (loadedRef.current) v.resumeAnimation();
|
|
112
|
+
// not loaded yet — onReady picks it up via wantPlay()
|
|
113
|
+
} else {
|
|
114
|
+
v.pauseAnimation();
|
|
115
|
+
}
|
|
116
|
+
}, [props.playing]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const v = host.current;
|
|
120
|
+
if (!v || props.progress === undefined || !loadedRef.current) return;
|
|
121
|
+
v.progress = clamp01(props.progress);
|
|
122
|
+
}, [props.progress]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const v = host.current;
|
|
126
|
+
if (!v || props.speed === undefined || !loadedRef.current) return;
|
|
127
|
+
v.speed = props.speed;
|
|
128
|
+
}, [props.speed]);
|
|
129
|
+
|
|
130
|
+
const handle = (v: LottieView): LottieHandle => ({
|
|
131
|
+
play: () => v.resumeAnimation(),
|
|
132
|
+
pause: () => v.pauseAnimation(),
|
|
133
|
+
stop: () => stopView(v),
|
|
134
|
+
seekTo: (p: number) => {
|
|
135
|
+
v.progress = clamp01(p);
|
|
136
|
+
},
|
|
137
|
+
setSpeed: (n: number) => {
|
|
138
|
+
v.speed = n;
|
|
139
|
+
},
|
|
140
|
+
progress: () => currentProgress(v),
|
|
141
|
+
duration: () => durationMs(v),
|
|
142
|
+
isPlaying: () => v.isAnimating(),
|
|
143
|
+
native: v,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
<xplatlottie
|
|
147
|
+
ref={bindHost}
|
|
148
|
+
id={props.id}
|
|
149
|
+
className={cx(props.className)}
|
|
150
|
+
style={props.style}
|
|
151
|
+
{...layoutChildProps(props)}
|
|
152
|
+
loop={props.loop ?? false}
|
|
153
|
+
accessible={props.accessible}
|
|
154
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
155
|
+
accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
|
|
156
|
+
accessibilityHint={props.accessibilityHint}
|
|
157
|
+
accessibilityValue={props.accessibilityValue}
|
|
158
|
+
accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
|
|
159
|
+
accessibilityLiveRegion={props.accessibilityLiveRegion as any}
|
|
160
|
+
/>
|
|
161
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import lottie, { type AnimationItem } from 'lottie-web';
|
|
2
|
+
import { useEffect, useRef } from 'octane';
|
|
3
|
+
import type { LottieHandle, LottieProps } from './props';
|
|
4
|
+
import { applyEscapeProps } from './escape-props.web';
|
|
5
|
+
import { layoutChildProps } from './layout-child.web';
|
|
6
|
+
|
|
7
|
+
const PRESERVE: Record<string, string> = {
|
|
8
|
+
contain: 'xMidYMid meet',
|
|
9
|
+
cover: 'xMidYMid slice',
|
|
10
|
+
fill: 'none',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
14
|
+
|
|
15
|
+
/** Lottie — lottie-web's svg renderer in a plain container div. Everything
|
|
16
|
+
* is imperative: `src`/`data` rebuild the AnimationItem, the rest are
|
|
17
|
+
* method calls applied in effects. Progress is normalized 0..1
|
|
18
|
+
* (lottie-web speaks frames); durations arrive in seconds and are
|
|
19
|
+
* surfaced as ms. */
|
|
20
|
+
export function Lottie(props: LottieProps) @{
|
|
21
|
+
const el = useRef<HTMLDivElement | null>(null);
|
|
22
|
+
const anim = useRef<AnimationItem | null>(null);
|
|
23
|
+
const ready = useRef(false);
|
|
24
|
+
|
|
25
|
+
const wantPlay = () => props.playing ?? props.autoPlay ?? true;
|
|
26
|
+
|
|
27
|
+
const build = (host: HTMLDivElement) => {
|
|
28
|
+
anim.current?.destroy();
|
|
29
|
+
anim.current = null;
|
|
30
|
+
ready.current = false;
|
|
31
|
+
|
|
32
|
+
const animationData = props.data ?? (props.src?.startsWith('{') ? JSON.parse(props.src) : undefined);
|
|
33
|
+
const path = animationData ? undefined : props.src;
|
|
34
|
+
if (!animationData && !path) return;
|
|
35
|
+
|
|
36
|
+
const a = lottie.loadAnimation({
|
|
37
|
+
container: host,
|
|
38
|
+
renderer: 'svg',
|
|
39
|
+
loop: props.loop ?? false,
|
|
40
|
+
autoplay: false, // leaf owns play state — uniform with native
|
|
41
|
+
animationData,
|
|
42
|
+
path,
|
|
43
|
+
rendererSettings: { preserveAspectRatio: PRESERVE[props.fit ?? 'contain'] },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
anim.current = a;
|
|
47
|
+
|
|
48
|
+
a.addEventListener('DOMLoaded', () => {
|
|
49
|
+
ready.current = true;
|
|
50
|
+
if (props.speed !== undefined) a.setSpeed(props.speed);
|
|
51
|
+
if (props.progress !== undefined) seekTo(props.progress);
|
|
52
|
+
props.onLoaded?.({ duration: a.getDuration() * 1000 });
|
|
53
|
+
if (wantPlay()) a.play();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
a.addEventListener('complete', () => props.onEnded?.());
|
|
57
|
+
a.addEventListener('data_failed', () => props.onError?.({ message: 'animation data failed to load' }));
|
|
58
|
+
a.addEventListener('error', () => props.onError?.({ message: 'animation failed' }));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const seekTo = (p: number) => {
|
|
62
|
+
const a = anim.current;
|
|
63
|
+
if (!a) return;
|
|
64
|
+
const frame = clamp01(p) * a.totalFrames;
|
|
65
|
+
// goToAndStop parks the playhead — keep playing state instead.
|
|
66
|
+
if (a.isPaused) a.goToAndStop(frame, true);
|
|
67
|
+
else a.goToAndPlay(frame, true);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Build once the container exists; rebuild on src/data/fit changes.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const host = el.current;
|
|
73
|
+
if (!host) return;
|
|
74
|
+
build(host);
|
|
75
|
+
return () => {
|
|
76
|
+
anim.current?.destroy();
|
|
77
|
+
anim.current = null;
|
|
78
|
+
ready.current = false;
|
|
79
|
+
};
|
|
80
|
+
}, [props.src, props.data, props.fit]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (props.loop !== undefined && anim.current) {
|
|
84
|
+
anim.current.loop = props.loop;
|
|
85
|
+
}
|
|
86
|
+
}, [props.loop]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (props.playing === undefined || !anim.current || !ready.current) return;
|
|
90
|
+
if (props.playing) anim.current.play();
|
|
91
|
+
else anim.current.pause();
|
|
92
|
+
}, [props.playing]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (props.progress === undefined) return;
|
|
96
|
+
seekTo(props.progress);
|
|
97
|
+
}, [props.progress]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (props.speed !== undefined) anim.current?.setSpeed(props.speed);
|
|
101
|
+
}, [props.speed]);
|
|
102
|
+
|
|
103
|
+
const handle = (a: AnimationItem): LottieHandle => ({
|
|
104
|
+
play: () => a.play(),
|
|
105
|
+
pause: () => a.pause(),
|
|
106
|
+
stop: () => a.stop(),
|
|
107
|
+
seekTo,
|
|
108
|
+
setSpeed: (n: number) => a.setSpeed(n),
|
|
109
|
+
progress: () => (a.totalFrames ? a.currentRawFrame / a.totalFrames : 0),
|
|
110
|
+
duration: () => a.getDuration() * 1000,
|
|
111
|
+
isPlaying: () => !a.isPaused,
|
|
112
|
+
native: a,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
<div
|
|
116
|
+
ref={(v: HTMLDivElement | null) => {
|
|
117
|
+
el.current = v;
|
|
118
|
+
if (!v) return;
|
|
119
|
+
applyEscapeProps(v, props);
|
|
120
|
+
if (anim.current) props.bind?.(handle(anim.current));
|
|
121
|
+
}}
|
|
122
|
+
id={props.id}
|
|
123
|
+
className={props.className}
|
|
124
|
+
{...layoutChildProps(props, props.style)}
|
|
125
|
+
role={props.accessibilityRole ?? 'img'}
|
|
126
|
+
aria-label={props.accessibilityLabel}
|
|
127
|
+
aria-hidden={props.accessible === false ? true : undefined}
|
|
128
|
+
aria-description={props.accessibilityHint}
|
|
129
|
+
aria-valuetext={props.accessibilityValue}
|
|
130
|
+
aria-disabled={props.accessibilityState?.disabled}
|
|
131
|
+
aria-selected={props.accessibilityState?.selected}
|
|
132
|
+
aria-checked={props.accessibilityState?.checked}
|
|
133
|
+
aria-live={props.accessibilityLiveRegion === 'none' ? 'off' : props.accessibilityLiveRegion}
|
|
134
|
+
/>
|
|
135
|
+
}
|
package/src/cx.mobile.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** NS `className` splits on ' ' only, and the universal driver applies the
|
|
2
|
+
* prop via `String(value)` — an array would arrive comma-joined and match
|
|
3
|
+
* no class at all. Native leaves normalize through cx() before handing
|
|
4
|
+
* className to an intrinsic. (The DOM renderer already flattens arrays,
|
|
5
|
+
* so web leaves don't need this.) */
|
|
6
|
+
export function cx(...parts: any[]): string {
|
|
7
|
+
return parts.flat(Infinity).filter(Boolean).join(' ')
|
|
8
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isAndroid, isIOS } from '@nativescript/core'
|
|
2
|
+
import type { Role } from './props'
|
|
3
|
+
|
|
4
|
+
/** Apply only the active device's escape bag after the primitive's own props. */
|
|
5
|
+
export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
|
|
6
|
+
if (!view) {
|
|
7
|
+
return
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const bag = isIOS ? props.ios : isAndroid ? props.android : undefined
|
|
11
|
+
if (bag) {
|
|
12
|
+
Object.assign(view, bag)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const NATIVE_ACCESSIBILITY_ROLES: Record<Role, string> = {
|
|
17
|
+
button: 'button',
|
|
18
|
+
link: 'link',
|
|
19
|
+
search: 'search',
|
|
20
|
+
image: 'image',
|
|
21
|
+
heading: 'header',
|
|
22
|
+
adjustable: 'adjustable',
|
|
23
|
+
summary: 'summary',
|
|
24
|
+
text: 'text',
|
|
25
|
+
none: 'none',
|
|
26
|
+
progressbar: 'progressBar',
|
|
27
|
+
checkbox: 'checkbox',
|
|
28
|
+
switch: 'switch',
|
|
29
|
+
radio: 'radioButton',
|
|
30
|
+
spinbutton: 'spinButton',
|
|
31
|
+
// NativeScript has no tab role; a tab is still an actionable button and
|
|
32
|
+
// its selected state is carried separately by accessibilityState.
|
|
33
|
+
tab: 'button',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Translate the shared/ARIA spelling to NativeScript's narrower role enum. */
|
|
37
|
+
export function nativeAccessibilityRole(role?: Role): string | undefined {
|
|
38
|
+
return role ? NATIVE_ACCESSIBILITY_ROLES[role] : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** NativeScript's AccessibilityState is a single enum value, unlike the
|
|
42
|
+
* web's independent ARIA booleans. Priority follows the strongest state. */
|
|
43
|
+
export function nativeAccessibilityState(state?: {
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
selected?: boolean
|
|
46
|
+
checked?: boolean
|
|
47
|
+
}): string | undefined {
|
|
48
|
+
if (state?.disabled) {
|
|
49
|
+
return 'disabled'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (state?.selected) {
|
|
53
|
+
return 'selected'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (state?.checked === true) {
|
|
57
|
+
return 'checked'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (state?.checked === false) {
|
|
61
|
+
return 'unchecked'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Apply DOM escape props after the primitive's own props. Dash-named keys
|
|
2
|
+
* are attributes; other keys use the DOM property where available. */
|
|
3
|
+
export function applyEscapeProps(element: any, props: { web?: any }): void {
|
|
4
|
+
if (!element || !props.web) {
|
|
5
|
+
return
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
for (const [key, value] of Object.entries(props.web)) {
|
|
9
|
+
if (key.includes('-')) {
|
|
10
|
+
if (value == null || value === false) {
|
|
11
|
+
element.removeAttribute(key)
|
|
12
|
+
} else {
|
|
13
|
+
element.setAttribute(key, value === true ? '' : String(value))
|
|
14
|
+
}
|
|
15
|
+
} else if (key in element) {
|
|
16
|
+
try {
|
|
17
|
+
element[key] = value
|
|
18
|
+
} catch {
|
|
19
|
+
element.setAttribute(key, String(value))
|
|
20
|
+
}
|
|
21
|
+
} else if (value != null) {
|
|
22
|
+
element.setAttribute(key, String(value))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
package/src/index.web.ts
ADDED