react-native-figma-shadow 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +152 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +76 -0
- package/android/gradle.properties +3 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/figmashadow/FigmaShadowView.kt +224 -0
- package/android/src/main/java/com/figmashadow/FigmaShadowViewManager.kt +71 -0
- package/android/src/main/java/com/figmashadow/FigmaShadowViewPackage.kt +30 -0
- package/android/src/main/jni/cpp-adapter.cpp +77 -0
- package/cpp/figmashadow/Color.cpp +225 -0
- package/cpp/figmashadow/Color.h +14 -0
- package/cpp/figmashadow/FigmaShadow.cpp +114 -0
- package/cpp/figmashadow/FigmaShadow.h +30 -0
- package/cpp/figmashadow/Parser.cpp +150 -0
- package/cpp/figmashadow/Parser.h +16 -0
- package/cpp/figmashadow/Rasterizer.cpp +276 -0
- package/cpp/figmashadow/Rasterizer.h +21 -0
- package/cpp/figmashadow/Types.h +66 -0
- package/docs/ARCHITECTURE.md +67 -0
- package/docs/images/fs_drop.png +0 -0
- package/docs/images/fs_inset.png +0 -0
- package/docs/images/fs_offset.png +0 -0
- package/ios/FigmaShadowView.h +9 -0
- package/ios/FigmaShadowView.mm +207 -0
- package/lib/commonjs/FigmaShadowViewNativeComponent.js +10 -0
- package/lib/commonjs/FigmaShadowViewNativeComponent.js.map +1 -0
- package/lib/commonjs/index.js +106 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/parseShadow.js +145 -0
- package/lib/commonjs/parseShadow.js.map +1 -0
- package/lib/module/FigmaShadowViewNativeComponent.js +5 -0
- package/lib/module/FigmaShadowViewNativeComponent.js.map +1 -0
- package/lib/module/index.js +88 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/parseShadow.js +139 -0
- package/lib/module/parseShadow.js.map +1 -0
- package/lib/typescript/src/FigmaShadowViewNativeComponent.d.ts +24 -0
- package/lib/typescript/src/FigmaShadowViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +40 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/parseShadow.d.ts +22 -0
- package/lib/typescript/src/parseShadow.d.ts.map +1 -0
- package/package.json +122 -0
- package/react-native-figma-shadow.podspec +25 -0
- package/react-native.config.js +19 -0
- package/src/FigmaShadowViewNativeComponent.ts +30 -0
- package/src/index.tsx +134 -0
- package/src/parseShadow.ts +134 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RaoMK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# react-native-figma-shadow
|
|
2
|
+
|
|
3
|
+
> **Status: alpha (`0.1.0-alpha.x`).** The C++ core is tested and the package
|
|
4
|
+
> builds, but the native iOS/Android layers have not yet been run through a full
|
|
5
|
+
> app build. Install with `react-native-figma-shadow@alpha` and expect rough
|
|
6
|
+
> edges. Feedback welcome.
|
|
7
|
+
|
|
8
|
+
Paste a CSS `box-shadow` — the exact string Figma's **Copy as CSS** produces, or
|
|
9
|
+
what you already use on the web — and get a **pixel-identical** shadow on iOS and
|
|
10
|
+
Android.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Shadow } from 'react-native-figma-shadow';
|
|
14
|
+
|
|
15
|
+
<Shadow shadow="0px 4px 20px 0px rgba(0, 0, 0, 0.15)" borderRadius={16}>
|
|
16
|
+
<Card />
|
|
17
|
+
</Shadow>;
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **One renderer, both platforms.** The shadow is rasterized by a shared C++
|
|
21
|
+
core (an analytic Gaussian convolution of a rounded rectangle). iOS and Android
|
|
22
|
+
run the same code and produce the same bytes — no `CALayer` blur vs `elevation`
|
|
23
|
+
mismatch.
|
|
24
|
+
- **Zero peer dependencies.** No Skia, no `react-native-svg`. Adds roughly
|
|
25
|
+
50–100 KB to your app download.
|
|
26
|
+
- **New Architecture native component** (Fabric). Requires React Native 0.76+
|
|
27
|
+
with the New Architecture enabled.
|
|
28
|
+
- **Real CSS semantics:** multiple comma-separated shadows, `inset`, spread,
|
|
29
|
+
negative spread, and any CSS color (`#rgb`, `#rrggbbaa`, `rgb()/rgba()`,
|
|
30
|
+
`hsl()/hsla()`, named colors).
|
|
31
|
+
|
|
32
|
+
| `0 0 20px rgba(0,0,0,.5)` | `8px 10px 16px rgba(0,0,0,.6)` | `inset 0 8px 12px rgba(0,0,0,.7)` |
|
|
33
|
+
| :---: | :---: | :---: |
|
|
34
|
+
|  |  |  |
|
|
35
|
+
|
|
36
|
+
<sub>Rendered by the shared C++ core — byte-for-byte identical output feeds both the iOS `CALayer` and the Android `Canvas`.</sub>
|
|
37
|
+
|
|
38
|
+
## Why the numbers finally match
|
|
39
|
+
|
|
40
|
+
"Blur" means different things across systems:
|
|
41
|
+
|
|
42
|
+
| System | `blur` value is… |
|
|
43
|
+
| ------------------------- | --------------------------- |
|
|
44
|
+
| Figma / CSS `box-shadow` | ≈ 2× the Gaussian σ |
|
|
45
|
+
| iOS `shadowRadius` | ≈ the Gaussian σ |
|
|
46
|
+
| Android `elevation` | not configurable |
|
|
47
|
+
|
|
48
|
+
This library normalizes everything to the CSS/Figma definition (`σ = blur / 2`),
|
|
49
|
+
so the number you paste is the number you get.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm install react-native-figma-shadow
|
|
55
|
+
# or: yarn add react-native-figma-shadow (Yarn 1 or Yarn 3+/Berry)
|
|
56
|
+
cd ios && pod install
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The C++ core is compiled into your app automatically by the New Architecture
|
|
60
|
+
build (CocoaPods on iOS, CMake/`externalNativeBuild` on Android). There is no
|
|
61
|
+
extra setup step, no postinstall script, and no peer dependency beyond
|
|
62
|
+
`react` / `react-native` themselves.
|
|
63
|
+
|
|
64
|
+
### Yarn 3+ (Berry)
|
|
65
|
+
|
|
66
|
+
The package ships a modern `exports` map and installs cleanly under Yarn Berry.
|
|
67
|
+
As with every React Native native module, use the **`node-modules` linker** —
|
|
68
|
+
Metro, CocoaPods, and Gradle autolinking all expect a real `node_modules` tree,
|
|
69
|
+
so Yarn PnP is not supported by the RN toolchain:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
# .yarnrc.yml
|
|
73
|
+
nodeLinker: node-modules
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
|
|
78
|
+
### `<Shadow>`
|
|
79
|
+
|
|
80
|
+
| Prop | Type | Notes |
|
|
81
|
+
| -------------------------------------------------------------------------------- | --------- | ----------------------------------------------------------- |
|
|
82
|
+
| `shadow` | `string` | A CSS `box-shadow` value. `box-shadow:` prefix and trailing `;` are tolerated. |
|
|
83
|
+
| `borderRadius` | `number` | Uniform corner radius of the shadow shape. Match your child. |
|
|
84
|
+
| `borderTopLeftRadius` / `borderTopRightRadius` / `borderBottomRightRadius` / `borderBottomLeftRadius` | `number` | Per-corner override. |
|
|
85
|
+
| `backgroundColor` | `string` | Fill painted inside the content box, **below** an inset shadow and below children (use this instead of a background on the child for CSS-correct inset shadows). |
|
|
86
|
+
| `highQuality` | `boolean` | Opt into the slower exact rounded-corner quadrature. Default `false`. |
|
|
87
|
+
| `style` | `ViewStyle` | Applied to the outer layout box. |
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
// multiple shadows — comma-separated, straight from Figma / CSS
|
|
91
|
+
<Shadow
|
|
92
|
+
shadow="0 2px 4px rgba(0,0,0,.10), 0 12px 32px rgba(0,0,0,.14)"
|
|
93
|
+
borderRadius={16}
|
|
94
|
+
>
|
|
95
|
+
<Card />
|
|
96
|
+
</Shadow>
|
|
97
|
+
|
|
98
|
+
// inner shadow (Figma "Inner shadow")
|
|
99
|
+
<Shadow shadow="inset 0 2px 8px rgba(0,0,0,0.25)" borderRadius={12} backgroundColor="#fff">
|
|
100
|
+
<TextInput />
|
|
101
|
+
</Shadow>
|
|
102
|
+
|
|
103
|
+
// per-corner radius
|
|
104
|
+
<Shadow
|
|
105
|
+
shadow="0 8px 24px rgba(0,0,0,.12)"
|
|
106
|
+
borderTopLeftRadius={16}
|
|
107
|
+
borderTopRightRadius={16}
|
|
108
|
+
>
|
|
109
|
+
<Sheet />
|
|
110
|
+
</Shadow>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Helpers
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { parseBoxShadow, computeBleed } from 'react-native-figma-shadow';
|
|
117
|
+
|
|
118
|
+
parseBoxShadow('0 4px 20px rgba(0,0,0,.15)');
|
|
119
|
+
// [{ offsetX: 0, offsetY: 4, blur: 20, spread: 0, inset: false }]
|
|
120
|
+
|
|
121
|
+
computeBleed(parseBoxShadow('0 4px 20px rgba(0,0,0,.15)'));
|
|
122
|
+
// { left: 30, top: 30, right: 30, bottom: 34 }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## How it works
|
|
126
|
+
|
|
127
|
+
1. `<Shadow>` parses the value on the JS side only to compute the **bleed** — how
|
|
128
|
+
far the shadow extends past the element box — and reserves that space with
|
|
129
|
+
negative margins so layout stays neutral.
|
|
130
|
+
2. The native Fabric view (`FigmaShadowView`) hands the raw `box-shadow` string,
|
|
131
|
+
the content size, corner radii, bleed and pixel ratio to the shared C++
|
|
132
|
+
`render()`.
|
|
133
|
+
3. C++ parses the declaration, rasterizes every layer into one premultiplied
|
|
134
|
+
RGBA8888 bitmap, and memoizes it (identical requests — e.g. every row of a
|
|
135
|
+
list — reuse one bitmap).
|
|
136
|
+
4. iOS sets the bitmap as a `CALayer`'s `contents`; Android blits it in
|
|
137
|
+
`dispatchDraw` behind the children. Neither platform does any blur math.
|
|
138
|
+
|
|
139
|
+
## Limitations
|
|
140
|
+
|
|
141
|
+
- **Shape shadows only.** The shadow follows the `borderRadius` you give it, not
|
|
142
|
+
the actual alpha of arbitrary content (text, transparent PNGs). `drop-shadow`
|
|
143
|
+
that hugs content alpha is not in this version.
|
|
144
|
+
- **New Architecture required** (RN 0.76+). No legacy-architecture fallback.
|
|
145
|
+
- First render of a given shadow happens on a background thread and appears a
|
|
146
|
+
frame or two later; after that it is cached.
|
|
147
|
+
- The `backgroundColor` fill uses the top-left radius for all four corners when
|
|
148
|
+
per-corner radii differ (the shadow itself is always exact).
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.13)
|
|
2
|
+
project(FigmaShadow)
|
|
3
|
+
|
|
4
|
+
set(CMAKE_CXX_STANDARD 17)
|
|
5
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
6
|
+
set(CMAKE_VERBOSE_MAKEFILE ON)
|
|
7
|
+
|
|
8
|
+
set(SHARED_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../cpp")
|
|
9
|
+
|
|
10
|
+
file(GLOB CORE_SOURCES CONFIGURE_DEPENDS "${SHARED_CORE_DIR}/figmashadow/*.cpp")
|
|
11
|
+
|
|
12
|
+
add_library(react-native-figma-shadow
|
|
13
|
+
SHARED
|
|
14
|
+
${CORE_SOURCES}
|
|
15
|
+
"${CMAKE_CURRENT_SOURCE_DIR}/src/main/jni/cpp-adapter.cpp"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
target_include_directories(react-native-figma-shadow
|
|
19
|
+
PUBLIC
|
|
20
|
+
"${SHARED_CORE_DIR}"
|
|
21
|
+
"${SHARED_CORE_DIR}/figmashadow"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
find_library(log-lib log)
|
|
25
|
+
find_library(jnigraphics-lib jnigraphics)
|
|
26
|
+
|
|
27
|
+
target_link_libraries(react-native-figma-shadow
|
|
28
|
+
${log-lib}
|
|
29
|
+
${jnigraphics-lib}
|
|
30
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
repositories {
|
|
3
|
+
google()
|
|
4
|
+
mavenCentral()
|
|
5
|
+
}
|
|
6
|
+
dependencies {
|
|
7
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
8
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24"
|
|
9
|
+
classpath "com.facebook.react:react-native-gradle-plugin"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
def getExtOrDefault = { name, fallback ->
|
|
14
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : fallback
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def reactNativeArchitectures() {
|
|
18
|
+
def value = rootProject.getProperties().get("reactNativeArchitectures")
|
|
19
|
+
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
apply plugin: "com.android.library"
|
|
23
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
24
|
+
apply plugin: "com.facebook.react"
|
|
25
|
+
|
|
26
|
+
android {
|
|
27
|
+
namespace "com.figmashadow"
|
|
28
|
+
|
|
29
|
+
compileSdkVersion getExtOrDefault("compileSdkVersion", 35)
|
|
30
|
+
|
|
31
|
+
defaultConfig {
|
|
32
|
+
minSdkVersion getExtOrDefault("minSdkVersion", 24)
|
|
33
|
+
targetSdkVersion getExtOrDefault("targetSdkVersion", 34)
|
|
34
|
+
|
|
35
|
+
externalNativeBuild {
|
|
36
|
+
cmake {
|
|
37
|
+
cppFlags "-O2", "-frtti", "-fexceptions", "-Wall"
|
|
38
|
+
arguments "-DANDROID_STL=c++_shared"
|
|
39
|
+
abiFilters(*reactNativeArchitectures())
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
externalNativeBuild {
|
|
45
|
+
cmake {
|
|
46
|
+
path "CMakeLists.txt"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
buildTypes {
|
|
51
|
+
release {
|
|
52
|
+
minifyEnabled false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
compileOptions {
|
|
57
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
58
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
repositories {
|
|
63
|
+
google()
|
|
64
|
+
mavenCentral()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
dependencies {
|
|
68
|
+
implementation "com.facebook.react:react-android"
|
|
69
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.24"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
react {
|
|
73
|
+
jsRootDir = file("../src/")
|
|
74
|
+
libraryName = "RNFigmaShadowSpec"
|
|
75
|
+
codegenJavaPackageName = "com.figmashadow"
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
package com.figmashadow
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Paint
|
|
7
|
+
import android.graphics.Path
|
|
8
|
+
import android.graphics.RectF
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.Looper
|
|
11
|
+
import android.util.DisplayMetrics
|
|
12
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
13
|
+
import java.util.concurrent.Executors
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Container that paints a CSS-style shadow behind its children. All shadow
|
|
17
|
+
* geometry is produced by the shared C++ core (see [nativeRenderShadow]); this
|
|
18
|
+
* view only blits the resulting bitmap.
|
|
19
|
+
*/
|
|
20
|
+
class FigmaShadowView(context: Context) : ReactViewGroup(context) {
|
|
21
|
+
|
|
22
|
+
private val density: Float =
|
|
23
|
+
context.resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT
|
|
24
|
+
|
|
25
|
+
private var shadowSpec: String = ""
|
|
26
|
+
private var backgroundSpec: String? = null
|
|
27
|
+
private var radiusTopLeft = 0f
|
|
28
|
+
private var radiusTopRight = 0f
|
|
29
|
+
private var radiusBottomRight = 0f
|
|
30
|
+
private var radiusBottomLeft = 0f
|
|
31
|
+
private var bleedLeft = 0f
|
|
32
|
+
private var bleedTop = 0f
|
|
33
|
+
private var bleedRight = 0f
|
|
34
|
+
private var bleedBottom = 0f
|
|
35
|
+
private var highQuality = false
|
|
36
|
+
|
|
37
|
+
private var shadowBitmap: Bitmap? = null
|
|
38
|
+
private var dirty = true
|
|
39
|
+
private var renderGeneration = 0
|
|
40
|
+
private var renderInFlight = -1
|
|
41
|
+
|
|
42
|
+
private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
|
43
|
+
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
44
|
+
private var hasFill = false
|
|
45
|
+
private val fillPath = Path()
|
|
46
|
+
private val fillRect = RectF()
|
|
47
|
+
private val fillRadii = FloatArray(8)
|
|
48
|
+
|
|
49
|
+
init {
|
|
50
|
+
setWillNotDraw(false)
|
|
51
|
+
// The shadow is drawn outside the padded content box.
|
|
52
|
+
clipChildren = false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun setShadowSpec(value: String?) {
|
|
56
|
+
val next = value ?: ""
|
|
57
|
+
if (next != shadowSpec) {
|
|
58
|
+
shadowSpec = next
|
|
59
|
+
dirty = true
|
|
60
|
+
invalidate()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fun setFillColor(value: String?) {
|
|
65
|
+
if (value != backgroundSpec) {
|
|
66
|
+
backgroundSpec = value
|
|
67
|
+
resolveFill()
|
|
68
|
+
invalidate()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fun setHighQuality(value: Boolean) {
|
|
73
|
+
if (value != highQuality) {
|
|
74
|
+
highQuality = value
|
|
75
|
+
dirty = true
|
|
76
|
+
invalidate()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private inline fun updateRadius(current: Float, next: Float, apply: (Float) -> Unit) {
|
|
81
|
+
if (current != next) {
|
|
82
|
+
apply(next)
|
|
83
|
+
dirty = true
|
|
84
|
+
invalidate()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fun setRadiusTopLeft(v: Float) = updateRadius(radiusTopLeft, v) { radiusTopLeft = it }
|
|
89
|
+
fun setRadiusTopRight(v: Float) = updateRadius(radiusTopRight, v) { radiusTopRight = it }
|
|
90
|
+
fun setRadiusBottomRight(v: Float) = updateRadius(radiusBottomRight, v) { radiusBottomRight = it }
|
|
91
|
+
fun setRadiusBottomLeft(v: Float) = updateRadius(radiusBottomLeft, v) { radiusBottomLeft = it }
|
|
92
|
+
|
|
93
|
+
fun setBleedLeft(v: Float) = updateRadius(bleedLeft, v) { bleedLeft = it }
|
|
94
|
+
fun setBleedTop(v: Float) = updateRadius(bleedTop, v) { bleedTop = it }
|
|
95
|
+
fun setBleedRight(v: Float) = updateRadius(bleedRight, v) { bleedRight = it }
|
|
96
|
+
fun setBleedBottom(v: Float) = updateRadius(bleedBottom, v) { bleedBottom = it }
|
|
97
|
+
|
|
98
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
99
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
100
|
+
dirty = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun contentWidthPx(): Float = width - (bleedLeft + bleedRight) * density
|
|
104
|
+
private fun contentHeightPx(): Float = height - (bleedTop + bleedBottom) * density
|
|
105
|
+
|
|
106
|
+
/** Kicks off an off-thread raster; the current bitmap keeps showing until it lands. */
|
|
107
|
+
private fun rebuildShadow() {
|
|
108
|
+
dirty = false
|
|
109
|
+
|
|
110
|
+
val contentW = contentWidthPx() / density
|
|
111
|
+
val contentH = contentHeightPx() / density
|
|
112
|
+
if (contentW <= 0f || contentH <= 0f || shadowSpec.isEmpty()) {
|
|
113
|
+
shadowBitmap = null
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
val generation = ++renderGeneration
|
|
118
|
+
if (generation == renderInFlight) return
|
|
119
|
+
renderInFlight = generation
|
|
120
|
+
|
|
121
|
+
val spec = shadowSpec
|
|
122
|
+
val rtl = radiusTopLeft
|
|
123
|
+
val rtr = radiusTopRight
|
|
124
|
+
val rbr = radiusBottomRight
|
|
125
|
+
val rbl = radiusBottomLeft
|
|
126
|
+
val bl = bleedLeft
|
|
127
|
+
val bt = bleedTop
|
|
128
|
+
val br = bleedRight
|
|
129
|
+
val bb = bleedBottom
|
|
130
|
+
val hq = highQuality
|
|
131
|
+
|
|
132
|
+
executor.execute {
|
|
133
|
+
val bitmap = nativeRenderShadow(
|
|
134
|
+
contentW, contentH, rtl, rtr, rbr, rbl, spec, bl, bt, br, bb, density, hq,
|
|
135
|
+
)
|
|
136
|
+
mainHandler.post {
|
|
137
|
+
renderInFlight = -1
|
|
138
|
+
if (generation == renderGeneration) {
|
|
139
|
+
shadowBitmap = bitmap
|
|
140
|
+
invalidate()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fun resolveFill() {
|
|
147
|
+
val spec = backgroundSpec
|
|
148
|
+
val rgba = FloatArray(4)
|
|
149
|
+
hasFill = spec != null && nativeParseColor(spec, rgba) && rgba[3] > 0f
|
|
150
|
+
if (hasFill) {
|
|
151
|
+
fillPaint.setARGB(
|
|
152
|
+
(rgba[3] * 255).toInt(),
|
|
153
|
+
(rgba[0] * 255).toInt(),
|
|
154
|
+
(rgba[1] * 255).toInt(),
|
|
155
|
+
(rgba[2] * 255).toInt(),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun updateFillPath() {
|
|
161
|
+
val left = bleedLeft * density
|
|
162
|
+
val top = bleedTop * density
|
|
163
|
+
fillRect.set(left, top, left + contentWidthPx(), top + contentHeightPx())
|
|
164
|
+
val tl = radiusTopLeft * density
|
|
165
|
+
val tr = radiusTopRight * density
|
|
166
|
+
val br = radiusBottomRight * density
|
|
167
|
+
val bl = radiusBottomLeft * density
|
|
168
|
+
fillRadii[0] = tl; fillRadii[1] = tl
|
|
169
|
+
fillRadii[2] = tr; fillRadii[3] = tr
|
|
170
|
+
fillRadii[4] = br; fillRadii[5] = br
|
|
171
|
+
fillRadii[6] = bl; fillRadii[7] = bl
|
|
172
|
+
fillPath.reset()
|
|
173
|
+
fillPath.addRoundRect(fillRect, fillRadii, Path.Direction.CW)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
override fun dispatchDraw(canvas: Canvas) {
|
|
177
|
+
if (dirty) rebuildShadow()
|
|
178
|
+
|
|
179
|
+
if (hasFill) {
|
|
180
|
+
updateFillPath()
|
|
181
|
+
canvas.drawPath(fillPath, fillPaint)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
shadowBitmap?.let { bmp ->
|
|
185
|
+
canvas.drawBitmap(bmp, null, RectF(0f, 0f, width.toFloat(), height.toFloat()), bitmapPaint)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
super.dispatchDraw(canvas)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
companion object {
|
|
192
|
+
init {
|
|
193
|
+
System.loadLibrary("react-native-figma-shadow")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private val executor = Executors.newSingleThreadExecutor { r ->
|
|
197
|
+
Thread(r, "figma-shadow-raster").apply { priority = Thread.NORM_PRIORITY - 1 }
|
|
198
|
+
}
|
|
199
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
200
|
+
|
|
201
|
+
@JvmStatic
|
|
202
|
+
external fun nativeRenderShadow(
|
|
203
|
+
contentWidth: Float,
|
|
204
|
+
contentHeight: Float,
|
|
205
|
+
radiusTopLeft: Float,
|
|
206
|
+
radiusTopRight: Float,
|
|
207
|
+
radiusBottomRight: Float,
|
|
208
|
+
radiusBottomLeft: Float,
|
|
209
|
+
boxShadow: String,
|
|
210
|
+
bleedLeft: Float,
|
|
211
|
+
bleedTop: Float,
|
|
212
|
+
bleedRight: Float,
|
|
213
|
+
bleedBottom: Float,
|
|
214
|
+
scale: Float,
|
|
215
|
+
highQuality: Boolean,
|
|
216
|
+
): Bitmap?
|
|
217
|
+
|
|
218
|
+
@JvmStatic
|
|
219
|
+
external fun nativeParseColor(token: String, out: FloatArray): Boolean
|
|
220
|
+
|
|
221
|
+
@JvmStatic
|
|
222
|
+
external fun nativeClearCache()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package com.figmashadow
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
4
|
+
import com.facebook.react.uimanager.SimpleViewManager
|
|
5
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
7
|
+
import com.facebook.react.uimanager.annotations.ReactProp
|
|
8
|
+
import com.facebook.react.viewmanagers.FigmaShadowViewManagerDelegate
|
|
9
|
+
import com.facebook.react.viewmanagers.FigmaShadowViewManagerInterface
|
|
10
|
+
|
|
11
|
+
@ReactModule(name = FigmaShadowViewManager.NAME)
|
|
12
|
+
class FigmaShadowViewManager :
|
|
13
|
+
SimpleViewManager<FigmaShadowView>(),
|
|
14
|
+
FigmaShadowViewManagerInterface<FigmaShadowView> {
|
|
15
|
+
|
|
16
|
+
private val delegate: ViewManagerDelegate<FigmaShadowView> =
|
|
17
|
+
FigmaShadowViewManagerDelegate(this)
|
|
18
|
+
|
|
19
|
+
override fun getDelegate(): ViewManagerDelegate<FigmaShadowView> = delegate
|
|
20
|
+
|
|
21
|
+
override fun getName(): String = NAME
|
|
22
|
+
|
|
23
|
+
override fun createViewInstance(context: ThemedReactContext): FigmaShadowView =
|
|
24
|
+
FigmaShadowView(context)
|
|
25
|
+
|
|
26
|
+
@ReactProp(name = "shadow")
|
|
27
|
+
override fun setShadow(view: FigmaShadowView, value: String?) = view.setShadowSpec(value)
|
|
28
|
+
|
|
29
|
+
@ReactProp(name = "fillColor")
|
|
30
|
+
override fun setFillColor(view: FigmaShadowView, value: String?) = view.setFillColor(value)
|
|
31
|
+
|
|
32
|
+
@ReactProp(name = "highQuality")
|
|
33
|
+
override fun setHighQuality(view: FigmaShadowView, value: Boolean) = view.setHighQuality(value)
|
|
34
|
+
|
|
35
|
+
@ReactProp(name = "pixelRatio")
|
|
36
|
+
override fun setPixelRatio(view: FigmaShadowView, value: Float) {
|
|
37
|
+
// Android derives density from resources; the JS-supplied value is unused.
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@ReactProp(name = "borderTopLeftRadius", defaultFloat = 0f)
|
|
41
|
+
override fun setBorderTopLeftRadius(view: FigmaShadowView, value: Float) =
|
|
42
|
+
view.setRadiusTopLeft(value)
|
|
43
|
+
|
|
44
|
+
@ReactProp(name = "borderTopRightRadius", defaultFloat = 0f)
|
|
45
|
+
override fun setBorderTopRightRadius(view: FigmaShadowView, value: Float) =
|
|
46
|
+
view.setRadiusTopRight(value)
|
|
47
|
+
|
|
48
|
+
@ReactProp(name = "borderBottomRightRadius", defaultFloat = 0f)
|
|
49
|
+
override fun setBorderBottomRightRadius(view: FigmaShadowView, value: Float) =
|
|
50
|
+
view.setRadiusBottomRight(value)
|
|
51
|
+
|
|
52
|
+
@ReactProp(name = "borderBottomLeftRadius", defaultFloat = 0f)
|
|
53
|
+
override fun setBorderBottomLeftRadius(view: FigmaShadowView, value: Float) =
|
|
54
|
+
view.setRadiusBottomLeft(value)
|
|
55
|
+
|
|
56
|
+
@ReactProp(name = "bleedLeft", defaultFloat = 0f)
|
|
57
|
+
override fun setBleedLeft(view: FigmaShadowView, value: Float) = view.setBleedLeft(value)
|
|
58
|
+
|
|
59
|
+
@ReactProp(name = "bleedTop", defaultFloat = 0f)
|
|
60
|
+
override fun setBleedTop(view: FigmaShadowView, value: Float) = view.setBleedTop(value)
|
|
61
|
+
|
|
62
|
+
@ReactProp(name = "bleedRight", defaultFloat = 0f)
|
|
63
|
+
override fun setBleedRight(view: FigmaShadowView, value: Float) = view.setBleedRight(value)
|
|
64
|
+
|
|
65
|
+
@ReactProp(name = "bleedBottom", defaultFloat = 0f)
|
|
66
|
+
override fun setBleedBottom(view: FigmaShadowView, value: Float) = view.setBleedBottom(value)
|
|
67
|
+
|
|
68
|
+
companion object {
|
|
69
|
+
const val NAME = "FigmaShadowView"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package com.figmashadow
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import com.facebook.react.uimanager.ViewManager
|
|
9
|
+
|
|
10
|
+
class FigmaShadowViewPackage : BaseReactPackage() {
|
|
11
|
+
|
|
12
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
|
|
13
|
+
|
|
14
|
+
override fun createViewManagers(
|
|
15
|
+
reactContext: ReactApplicationContext,
|
|
16
|
+
): List<ViewManager<*, *>> = listOf(FigmaShadowViewManager())
|
|
17
|
+
|
|
18
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
|
|
19
|
+
mapOf(
|
|
20
|
+
FigmaShadowViewManager.NAME to ReactModuleInfo(
|
|
21
|
+
FigmaShadowViewManager.NAME,
|
|
22
|
+
FigmaShadowViewManager.NAME,
|
|
23
|
+
false, // canOverrideExistingModule
|
|
24
|
+
false, // needsEagerInit
|
|
25
|
+
false, // isCxxModule
|
|
26
|
+
true, // isTurboModule
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#include <android/bitmap.h>
|
|
2
|
+
#include <jni.h>
|
|
3
|
+
|
|
4
|
+
#include <cstring>
|
|
5
|
+
#include <string>
|
|
6
|
+
|
|
7
|
+
#include "Color.h"
|
|
8
|
+
#include "FigmaShadow.h"
|
|
9
|
+
|
|
10
|
+
namespace {
|
|
11
|
+
|
|
12
|
+
std::string jstringToStd(JNIEnv *env, jstring value) {
|
|
13
|
+
if (value == nullptr) return {};
|
|
14
|
+
const char *chars = env->GetStringUTFChars(value, nullptr);
|
|
15
|
+
std::string out(chars ? chars : "");
|
|
16
|
+
if (chars) env->ReleaseStringUTFChars(value, chars);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
jobject createArgb8888Bitmap(JNIEnv *env, int width, int height) {
|
|
21
|
+
jclass bitmapClass = env->FindClass("android/graphics/Bitmap");
|
|
22
|
+
jclass configClass = env->FindClass("android/graphics/Bitmap$Config");
|
|
23
|
+
jfieldID argb8888Field = env->GetStaticFieldID(
|
|
24
|
+
configClass, "ARGB_8888", "Landroid/graphics/Bitmap$Config;");
|
|
25
|
+
jobject config = env->GetStaticObjectField(configClass, argb8888Field);
|
|
26
|
+
jmethodID createBitmap = env->GetStaticMethodID(
|
|
27
|
+
bitmapClass, "createBitmap",
|
|
28
|
+
"(IILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;");
|
|
29
|
+
return env->CallStaticObjectMethod(bitmapClass, createBitmap, width, height, config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
} // namespace
|
|
33
|
+
|
|
34
|
+
extern "C" JNIEXPORT jobject JNICALL
|
|
35
|
+
Java_com_figmashadow_FigmaShadowView_nativeRenderShadow(
|
|
36
|
+
JNIEnv *env, jclass /*clazz*/, jfloat contentWidth, jfloat contentHeight,
|
|
37
|
+
jfloat radiusTopLeft, jfloat radiusTopRight, jfloat radiusBottomRight,
|
|
38
|
+
jfloat radiusBottomLeft, jstring boxShadow, jfloat bleedLeft, jfloat bleedTop,
|
|
39
|
+
jfloat bleedRight, jfloat bleedBottom, jfloat scale, jboolean highQuality) {
|
|
40
|
+
const std::string shadow = jstringToStd(env, boxShadow);
|
|
41
|
+
|
|
42
|
+
figmashadow::Bitmap bmp = figmashadow::render(
|
|
43
|
+
contentWidth, contentHeight, radiusTopLeft, radiusTopRight,
|
|
44
|
+
radiusBottomRight, radiusBottomLeft, shadow, bleedLeft, bleedTop, bleedRight,
|
|
45
|
+
bleedBottom, scale, highQuality == JNI_TRUE);
|
|
46
|
+
|
|
47
|
+
if (bmp.empty()) return nullptr;
|
|
48
|
+
|
|
49
|
+
jobject bitmap = createArgb8888Bitmap(env, bmp.width, bmp.height);
|
|
50
|
+
if (bitmap == nullptr) return nullptr;
|
|
51
|
+
|
|
52
|
+
void *pixels = nullptr;
|
|
53
|
+
if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
54
|
+
return nullptr;
|
|
55
|
+
}
|
|
56
|
+
// figmashadow::Bitmap is premultiplied RGBA8888; Android's ARGB_8888 is stored
|
|
57
|
+
// in the same byte order (R,G,B,A) and premultiplied by default.
|
|
58
|
+
std::memcpy(pixels, bmp.pixels.data(), bmp.pixels.size());
|
|
59
|
+
AndroidBitmap_unlockPixels(env, bitmap);
|
|
60
|
+
|
|
61
|
+
return bitmap;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
extern "C" JNIEXPORT jboolean JNICALL
|
|
65
|
+
Java_com_figmashadow_FigmaShadowView_nativeParseColor(JNIEnv *env, jclass /*clazz*/,
|
|
66
|
+
jstring token, jfloatArray out) {
|
|
67
|
+
figmashadow::Color color;
|
|
68
|
+
if (!figmashadow::parseColor(jstringToStd(env, token), color)) return JNI_FALSE;
|
|
69
|
+
jfloat values[4] = {color.r, color.g, color.b, color.a};
|
|
70
|
+
env->SetFloatArrayRegion(out, 0, 4, values);
|
|
71
|
+
return JNI_TRUE;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
extern "C" JNIEXPORT void JNICALL
|
|
75
|
+
Java_com_figmashadow_FigmaShadowView_nativeClearCache(JNIEnv * /*env*/, jclass /*clazz*/) {
|
|
76
|
+
figmashadow::clearCache();
|
|
77
|
+
}
|