react-native-smooth-clip-view 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +170 -0
  3. package/SmoothClipView.podspec +24 -0
  4. package/android/build.gradle +63 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +93 -0
  7. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +190 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +104 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +21 -0
  10. package/ios/SmoothClipGeometry.h +41 -0
  11. package/ios/SmoothClipView.h +9 -0
  12. package/ios/SmoothClipView.mm +224 -0
  13. package/lib/module/SmoothClipView.js +35 -0
  14. package/lib/module/SmoothClipView.js.map +1 -0
  15. package/lib/module/SmoothClipView.web.js +38 -0
  16. package/lib/module/SmoothClipView.web.js.map +1 -0
  17. package/lib/module/SmoothClipViewNativeComponent.ts +34 -0
  18. package/lib/module/geometry.js +41 -0
  19. package/lib/module/geometry.js.map +1 -0
  20. package/lib/module/index.js +5 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/index.web.js +5 -0
  23. package/lib/module/index.web.js.map +1 -0
  24. package/lib/module/package.json +1 -0
  25. package/lib/module/webClipPath.js +25 -0
  26. package/lib/module/webClipPath.js.map +1 -0
  27. package/lib/typescript/package.json +1 -0
  28. package/lib/typescript/src/SmoothClipView.d.ts +12 -0
  29. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -0
  30. package/lib/typescript/src/SmoothClipView.web.d.ts +12 -0
  31. package/lib/typescript/src/SmoothClipView.web.d.ts.map +1 -0
  32. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +15 -0
  33. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -0
  34. package/lib/typescript/src/geometry.d.ts +19 -0
  35. package/lib/typescript/src/geometry.d.ts.map +1 -0
  36. package/lib/typescript/src/index.d.ts +5 -0
  37. package/lib/typescript/src/index.d.ts.map +1 -0
  38. package/lib/typescript/src/index.web.d.ts +5 -0
  39. package/lib/typescript/src/index.web.d.ts.map +1 -0
  40. package/lib/typescript/src/webClipPath.d.ts +7 -0
  41. package/lib/typescript/src/webClipPath.d.ts.map +1 -0
  42. package/package.json +201 -0
  43. package/src/SmoothClipView.tsx +72 -0
  44. package/src/SmoothClipView.web.tsx +62 -0
  45. package/src/SmoothClipViewNativeComponent.ts +34 -0
  46. package/src/geometry.ts +83 -0
  47. package/src/index.ts +4 -0
  48. package/src/index.web.ts +4 -0
  49. package/src/webClipPath.ts +30 -0
package/package.json ADDED
@@ -0,0 +1,201 @@
1
+ {
2
+ "name": "react-native-smooth-clip-view",
3
+ "version": "0.1.0",
4
+ "description": "Layout-free animated rounded clipping for React Native Fabric",
5
+ "main": "./lib/module/index.js",
6
+ "module": "./lib/module/index.js",
7
+ "types": "./lib/typescript/src/index.d.ts",
8
+ "react-native": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/typescript/src/index.d.ts",
12
+ "react-native": "./src/index.ts",
13
+ "react-native-smooth-clip-view-source": "./src/index.ts",
14
+ "default": "./lib/module/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "lib",
21
+ "android",
22
+ "ios",
23
+ "SmoothClipView.podspec",
24
+ "README.md",
25
+ "LICENSE",
26
+ "!ios/build",
27
+ "!ios/tests",
28
+ "!android/build",
29
+ "!android/src/test",
30
+ "!**/__tests__",
31
+ "!**/__fixtures__",
32
+ "!**/__mocks__",
33
+ "!**/.*"
34
+ ],
35
+ "scripts": {
36
+ "example": "yarn workspace react-native-smooth-clip-view-example",
37
+ "clean": "del-cli lib",
38
+ "prepare": "bob build",
39
+ "typecheck": "tsc",
40
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
41
+ "test": "jest",
42
+ "test:android": "cd example/android && ./gradlew :react-native-smooth-clip-view:testDebugUnitTest",
43
+ "pack:check": "npm pack --dry-run --json",
44
+ "release": "release-it"
45
+ },
46
+ "keywords": [
47
+ "react-native",
48
+ "reanimated",
49
+ "animation",
50
+ "clipping",
51
+ "fabric",
52
+ "ios",
53
+ "android",
54
+ "web"
55
+ ],
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/kbrattli/react-native-smooth-clip-view.git"
59
+ },
60
+ "author": "kbrattli <kbrattli@live.no> (https://github.com/kbrattli)",
61
+ "license": "MIT",
62
+ "bugs": {
63
+ "url": "https://github.com/kbrattli/react-native-smooth-clip-view/issues"
64
+ },
65
+ "homepage": "https://github.com/kbrattli/react-native-smooth-clip-view#readme",
66
+ "publishConfig": {
67
+ "access": "public",
68
+ "registry": "https://registry.npmjs.org/"
69
+ },
70
+ "devDependencies": {
71
+ "@babel/core": "^7.29.7",
72
+ "@commitlint/config-conventional": "^21.0.2",
73
+ "@eslint/compat": "^2.1.0",
74
+ "@eslint/eslintrc": "^3.3.5",
75
+ "@eslint/js": "^10.0.1",
76
+ "@jest/globals": "^29.7.0",
77
+ "@react-native/babel-preset": "0.86.0",
78
+ "@react-native/eslint-config": "0.86.0",
79
+ "@react-native/jest-preset": "0.86.0",
80
+ "@react-native/metro-config": "0.86.0",
81
+ "@release-it/conventional-changelog": "^11.0.1",
82
+ "@types/react": "^19.2.0",
83
+ "commitlint": "^21.0.2",
84
+ "del-cli": "^7.0.0",
85
+ "eslint": "^9.39.4",
86
+ "eslint-config-prettier": "^10.1.8",
87
+ "eslint-plugin-ft-flow": "^3.0.11",
88
+ "eslint-plugin-prettier": "^5.5.6",
89
+ "hermes-eslint": "^0.37.0",
90
+ "jest": "^29.7.0",
91
+ "lefthook": "^2.1.9",
92
+ "prettier": "^3.8.3",
93
+ "react": "19.2.3",
94
+ "react-native": "0.86.0",
95
+ "react-native-builder-bob": "^0.43.0",
96
+ "react-native-reanimated": "4.5.0",
97
+ "react-native-worklets": "0.10.0",
98
+ "release-it": "^20.2.0",
99
+ "turbo": "^2.9.16",
100
+ "typescript": "^6.0.3"
101
+ },
102
+ "peerDependencies": {
103
+ "react": "*",
104
+ "react-native": ">=0.85.0",
105
+ "react-native-reanimated": ">=4.3.0"
106
+ },
107
+ "workspaces": [
108
+ "example"
109
+ ],
110
+ "packageManager": "yarn@4.11.0",
111
+ "react-native-builder-bob": {
112
+ "source": "src",
113
+ "output": "lib",
114
+ "targets": [
115
+ [
116
+ "module",
117
+ {
118
+ "esm": true
119
+ }
120
+ ],
121
+ [
122
+ "typescript",
123
+ {
124
+ "project": "tsconfig.build.json"
125
+ }
126
+ ]
127
+ ]
128
+ },
129
+ "codegenConfig": {
130
+ "name": "SmoothClipViewSpec",
131
+ "type": "components",
132
+ "jsSrcsDir": "src",
133
+ "android": {
134
+ "javaPackageName": "com.smoothclipview"
135
+ },
136
+ "ios": {
137
+ "components": {
138
+ "SmoothClipView": {
139
+ "className": "SmoothClipView"
140
+ }
141
+ }
142
+ }
143
+ },
144
+ "prettier": {
145
+ "quoteProps": "consistent",
146
+ "singleQuote": true,
147
+ "tabWidth": 2,
148
+ "trailingComma": "es5",
149
+ "useTabs": false
150
+ },
151
+ "jest": {
152
+ "preset": "@react-native/jest-preset",
153
+ "watchman": false,
154
+ "testEnvironmentOptions": {
155
+ "customExportConditions": [
156
+ "require",
157
+ "react-native",
158
+ "react-native-smooth-clip-view-source"
159
+ ]
160
+ },
161
+ "modulePathIgnorePatterns": [
162
+ "<rootDir>/example/node_modules",
163
+ "<rootDir>/lib/"
164
+ ]
165
+ },
166
+ "commitlint": {
167
+ "extends": [
168
+ "@commitlint/config-conventional"
169
+ ]
170
+ },
171
+ "release-it": {
172
+ "git": {
173
+ "commitMessage": "chore: release ${version}",
174
+ "tagName": "v${version}"
175
+ },
176
+ "npm": {
177
+ "publish": true
178
+ },
179
+ "github": {
180
+ "release": true
181
+ },
182
+ "plugins": {
183
+ "@release-it/conventional-changelog": {
184
+ "preset": {
185
+ "name": "angular"
186
+ }
187
+ }
188
+ }
189
+ },
190
+ "create-react-native-library": {
191
+ "type": "fabric-view",
192
+ "languages": "kotlin-objc",
193
+ "tools": [
194
+ "eslint",
195
+ "jest",
196
+ "lefthook",
197
+ "release-it"
198
+ ],
199
+ "version": "0.63.0"
200
+ }
201
+ }
@@ -0,0 +1,72 @@
1
+ import type React from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { type ViewProps } from 'react-native';
4
+ import Animated, {
5
+ dispatchCommand,
6
+ useAnimatedReaction,
7
+ useAnimatedRef,
8
+ type SharedValue,
9
+ } from 'react-native-reanimated';
10
+ import NativeSmoothClipView, {
11
+ type NativeProps,
12
+ } from './SmoothClipViewNativeComponent';
13
+ import {
14
+ clipGeometryEquals,
15
+ isFiniteClipGeometry,
16
+ type ClipGeometry,
17
+ } from './geometry';
18
+
19
+ const AnimatedNativeSmoothClipView =
20
+ Animated.createAnimatedComponent(NativeSmoothClipView);
21
+
22
+ export type SmoothClipViewProps = ViewProps & {
23
+ initialClip: ClipGeometry;
24
+ animatedClip: SharedValue<ClipGeometry>;
25
+ children?: ReactNode;
26
+ };
27
+
28
+ export function SmoothClipView({
29
+ initialClip,
30
+ animatedClip,
31
+ children,
32
+ ...viewProps
33
+ }: SmoothClipViewProps) {
34
+ const nativeRef =
35
+ useAnimatedRef<React.ComponentRef<typeof NativeSmoothClipView>>();
36
+
37
+ useAnimatedReaction(
38
+ () => animatedClip.value,
39
+ (clip, previousClip) => {
40
+ if (
41
+ !isFiniteClipGeometry(clip) ||
42
+ clipGeometryEquals(previousClip, clip)
43
+ ) {
44
+ return;
45
+ }
46
+
47
+ dispatchCommand(nativeRef, 'setClipGeometry', [
48
+ clip.x,
49
+ clip.y,
50
+ clip.width,
51
+ clip.height,
52
+ clip.radius,
53
+ ]);
54
+ },
55
+ [nativeRef, animatedClip]
56
+ );
57
+
58
+ const nativeProps: NativeProps = {
59
+ ...viewProps,
60
+ initialClipX: initialClip.x,
61
+ initialClipY: initialClip.y,
62
+ initialClipWidth: initialClip.width,
63
+ initialClipHeight: initialClip.height,
64
+ initialClipRadius: initialClip.radius,
65
+ };
66
+
67
+ return (
68
+ <AnimatedNativeSmoothClipView ref={nativeRef} {...nativeProps}>
69
+ {children}
70
+ </AnimatedNativeSmoothClipView>
71
+ );
72
+ }
@@ -0,0 +1,62 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type ViewProps, type ViewStyle } from 'react-native';
3
+ import Animated, {
4
+ useAnimatedReaction,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ type SharedValue,
8
+ } from 'react-native-reanimated';
9
+ import {
10
+ clipGeometryEquals,
11
+ isFiniteClipGeometry,
12
+ type ClipGeometry,
13
+ } from './geometry';
14
+ import { createWebClipPath } from './webClipPath';
15
+
16
+ export type SmoothClipViewProps = ViewProps & {
17
+ initialClip: ClipGeometry;
18
+ animatedClip: SharedValue<ClipGeometry>;
19
+ children?: ReactNode;
20
+ };
21
+
22
+ /** Build-safe fallback. Web performance is intentionally outside the POC. */
23
+ export function SmoothClipView({
24
+ initialClip,
25
+ animatedClip,
26
+ children,
27
+ style,
28
+ ...viewProps
29
+ }: SmoothClipViewProps) {
30
+ const acceptedClip = useSharedValue(initialClip);
31
+ const initialClipStyle = {
32
+ clipPath: createWebClipPath(initialClip),
33
+ overflow: 'hidden',
34
+ } as unknown as ViewStyle;
35
+
36
+ useAnimatedReaction(
37
+ () => animatedClip.value,
38
+ (clip, previousClip) => {
39
+ if (
40
+ isFiniteClipGeometry(clip) &&
41
+ !clipGeometryEquals(previousClip, clip)
42
+ ) {
43
+ acceptedClip.value = clip;
44
+ }
45
+ },
46
+ [animatedClip, acceptedClip]
47
+ );
48
+
49
+ const clipStyle = useAnimatedStyle(() => {
50
+ const clip = acceptedClip.value;
51
+ return {
52
+ clipPath: createWebClipPath(clip),
53
+ overflow: 'hidden',
54
+ };
55
+ });
56
+
57
+ return (
58
+ <Animated.View {...viewProps} style={[style, initialClipStyle, clipStyle]}>
59
+ {children}
60
+ </Animated.View>
61
+ );
62
+ }
@@ -0,0 +1,34 @@
1
+ import {
2
+ codegenNativeCommands,
3
+ codegenNativeComponent,
4
+ type CodegenTypes,
5
+ type HostComponent,
6
+ type ViewProps,
7
+ } from 'react-native';
8
+
9
+ export interface NativeProps extends ViewProps {
10
+ initialClipX: CodegenTypes.Double;
11
+ initialClipY: CodegenTypes.Double;
12
+ initialClipWidth: CodegenTypes.Double;
13
+ initialClipHeight: CodegenTypes.Double;
14
+ initialClipRadius: CodegenTypes.Double;
15
+ }
16
+
17
+ interface NativeCommands {
18
+ setClipGeometry: (
19
+ viewRef: React.ElementRef<HostComponent<NativeProps>>,
20
+ x: CodegenTypes.Double,
21
+ y: CodegenTypes.Double,
22
+ width: CodegenTypes.Double,
23
+ height: CodegenTypes.Double,
24
+ radius: CodegenTypes.Double
25
+ ) => void;
26
+ }
27
+
28
+ export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
29
+ supportedCommands: ['setClipGeometry'],
30
+ });
31
+
32
+ export default codegenNativeComponent<NativeProps>(
33
+ 'SmoothClipView'
34
+ ) as HostComponent<NativeProps>;
@@ -0,0 +1,83 @@
1
+ export type ClipGeometry = Readonly<{
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ radius: number;
7
+ }>;
8
+
9
+ export type ClipBounds = Readonly<{
10
+ width: number;
11
+ height: number;
12
+ }>;
13
+
14
+ export function isFiniteClipGeometry(geometry: ClipGeometry): boolean {
15
+ 'worklet';
16
+ return (
17
+ Number.isFinite(geometry.x) &&
18
+ Number.isFinite(geometry.y) &&
19
+ Number.isFinite(geometry.width) &&
20
+ Number.isFinite(geometry.height) &&
21
+ Number.isFinite(geometry.radius)
22
+ );
23
+ }
24
+
25
+ export function clipGeometryEquals(
26
+ first: ClipGeometry | null,
27
+ second: ClipGeometry
28
+ ): boolean {
29
+ 'worklet';
30
+ return (
31
+ first !== null &&
32
+ first.x === second.x &&
33
+ first.y === second.y &&
34
+ first.width === second.width &&
35
+ first.height === second.height &&
36
+ first.radius === second.radius
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Mirrors the native geometry contract for tests and non-native fallbacks.
42
+ * Native remains authoritative because its bounds are the actual rendered size.
43
+ */
44
+ export function normalizeClipGeometry(
45
+ geometry: ClipGeometry,
46
+ bounds: ClipBounds
47
+ ): ClipGeometry | null {
48
+ 'worklet';
49
+ const values = [
50
+ geometry.x,
51
+ geometry.y,
52
+ geometry.width,
53
+ geometry.height,
54
+ geometry.radius,
55
+ bounds.width,
56
+ bounds.height,
57
+ ];
58
+
59
+ if (!values.every(Number.isFinite)) return null;
60
+
61
+ const boundsWidth = Math.max(0, bounds.width);
62
+ const boundsHeight = Math.max(0, bounds.height);
63
+ const requestedWidth = Math.max(0, geometry.width);
64
+ const requestedHeight = Math.max(0, geometry.height);
65
+
66
+ const left = Math.min(boundsWidth, Math.max(0, geometry.x));
67
+ const top = Math.min(boundsHeight, Math.max(0, geometry.y));
68
+ const right = Math.min(boundsWidth, Math.max(0, geometry.x + requestedWidth));
69
+ const bottom = Math.min(
70
+ boundsHeight,
71
+ Math.max(0, geometry.y + requestedHeight)
72
+ );
73
+ const width = Math.max(0, right - left);
74
+ const height = Math.max(0, bottom - top);
75
+
76
+ return {
77
+ x: left,
78
+ y: top,
79
+ width,
80
+ height,
81
+ radius: Math.min(Math.max(0, geometry.radius), Math.min(width, height) / 2),
82
+ };
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { SmoothClipView } from './SmoothClipView';
2
+ export type { SmoothClipViewProps } from './SmoothClipView';
3
+ export { normalizeClipGeometry } from './geometry';
4
+ export type { ClipBounds, ClipGeometry } from './geometry';
@@ -0,0 +1,4 @@
1
+ export { SmoothClipView } from './SmoothClipView.web';
2
+ export type { SmoothClipViewProps } from './SmoothClipView.web';
3
+ export { normalizeClipGeometry } from './geometry';
4
+ export type { ClipBounds, ClipGeometry } from './geometry';
@@ -0,0 +1,30 @@
1
+ import type { ClipGeometry } from './geometry';
2
+
3
+ function px(value: number): string {
4
+ 'worklet';
5
+ return `${value}px`;
6
+ }
7
+
8
+ /**
9
+ * Creates a fixed-host CSS clip. Percentage/right/bottom anchored descendants
10
+ * continue resolving against the host because this never changes its layout.
11
+ */
12
+ export function createWebClipPath(geometry: ClipGeometry): string {
13
+ 'worklet';
14
+ const width = Math.max(0, geometry.width);
15
+ const height = Math.max(0, geometry.height);
16
+ const left = Math.max(0, geometry.x);
17
+ const top = Math.max(0, geometry.y);
18
+ const rightEdge = Math.max(0, geometry.x + width);
19
+ const bottomEdge = Math.max(0, geometry.y + height);
20
+ const radius = Math.min(
21
+ Math.max(0, geometry.radius),
22
+ Math.min(width, height) / 2
23
+ );
24
+
25
+ return `inset(${px(top)} max(0px, calc(100% - ${px(
26
+ rightEdge
27
+ )})) max(0px, calc(100% - ${px(bottomEdge)})) ${px(
28
+ left
29
+ )} round ${px(radius)})`;
30
+ }