react-native-gradient-mask 0.0.1-beta.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/.eslintrc.js +5 -0
- package/README.ja.md +335 -0
- package/README.md +335 -0
- package/README.zh-TW.md +335 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/gradientmask/GradientMaskModule.kt +33 -0
- package/android/src/main/java/expo/modules/gradientmask/GradientMaskView.kt +198 -0
- package/build/AnimatedGradientMaskView.d.ts +36 -0
- package/build/AnimatedGradientMaskView.d.ts.map +1 -0
- package/build/AnimatedGradientMaskView.js +37 -0
- package/build/AnimatedGradientMaskView.js.map +1 -0
- package/build/AnimatedGradientMaskView.web.d.ts +17 -0
- package/build/AnimatedGradientMaskView.web.d.ts.map +1 -0
- package/build/AnimatedGradientMaskView.web.js +100 -0
- package/build/AnimatedGradientMaskView.web.js.map +1 -0
- package/build/GradientMask.types.d.ts +38 -0
- package/build/GradientMask.types.d.ts.map +1 -0
- package/build/GradientMask.types.js +2 -0
- package/build/GradientMask.types.js.map +1 -0
- package/build/GradientMaskModule.d.ts +3 -0
- package/build/GradientMaskModule.d.ts.map +1 -0
- package/build/GradientMaskModule.js +4 -0
- package/build/GradientMaskModule.js.map +1 -0
- package/build/GradientMaskModule.web.d.ts +3 -0
- package/build/GradientMaskModule.web.d.ts.map +1 -0
- package/build/GradientMaskModule.web.js +3 -0
- package/build/GradientMaskModule.web.js.map +1 -0
- package/build/GradientMaskView.d.ts +4 -0
- package/build/GradientMaskView.d.ts.map +1 -0
- package/build/GradientMaskView.js +7 -0
- package/build/GradientMaskView.js.map +1 -0
- package/build/GradientMaskView.web.d.ts +8 -0
- package/build/GradientMaskView.web.d.ts.map +1 -0
- package/build/GradientMaskView.web.js +99 -0
- package/build/GradientMaskView.web.js.map +1 -0
- package/build/index.d.ts +6 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/images/android.mp4 +0 -0
- package/images/android.png +0 -0
- package/images/demo.mov +0 -0
- package/images/ios Demo Video.webm +0 -0
- package/images/ios.png +0 -0
- package/ios/GradientMask.podspec +29 -0
- package/ios/GradientMaskModule.swift +29 -0
- package/ios/GradientMaskView.swift +133 -0
- package/package.json +83 -0
- package/src/AnimatedGradientMaskView.tsx +60 -0
- package/src/AnimatedGradientMaskView.web.tsx +149 -0
- package/src/GradientMask.types.ts +43 -0
- package/src/GradientMaskModule.ts +4 -0
- package/src/GradientMaskModule.web.ts +2 -0
- package/src/GradientMaskView.tsx +11 -0
- package/src/GradientMaskView.web.tsx +129 -0
- package/src/index.ts +7 -0
- package/tsconfig.json +9 -0
package/README.zh-TW.md
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<h1 align="center">react-native-gradient-mask</h1>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<b>React Native 原生漸層遮罩元件</b>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
輕鬆建立精美的淡出效果、列表遮罩與流暢的漸層過渡動畫,具備原生效能與 Reanimated 動畫支援。
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<a href="https://www.npmjs.com/package/react-native-gradient-mask">
|
|
15
|
+
<img src="https://img.shields.io/npm/v/react-native-gradient-mask.svg" alt="npm version" />
|
|
16
|
+
</a>
|
|
17
|
+
<a href="https://www.npmjs.com/package/react-native-gradient-mask">
|
|
18
|
+
<img src="https://img.shields.io/npm/dm/react-native-gradient-mask.svg" alt="npm downloads" />
|
|
19
|
+
</a>
|
|
20
|
+
<img src="https://img.shields.io/badge/platforms-iOS%20%7C%20Android%20%7C%20Web-brightgreen.svg" alt="platforms" />
|
|
21
|
+
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="license" />
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<a href="./README.md">English</a> •
|
|
26
|
+
<a href="./README.ja.md">日本語</a>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 展示
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<table>
|
|
35
|
+
<tr>
|
|
36
|
+
<td align="center"><b>iOS</b></td>
|
|
37
|
+
<td align="center"><b>Android</b></td>
|
|
38
|
+
</tr>
|
|
39
|
+
<tr>
|
|
40
|
+
<td><a href="./images/ios Demo Video.webm"><img src="./images/ios.png" alt="iOS 展示" width="280" /></a></td>
|
|
41
|
+
<td><a href="./images/android.mp4"><img src="./images/android.png" alt="Android 展示" width="280" /></a></td>
|
|
42
|
+
</tr>
|
|
43
|
+
</table>
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
## 特色
|
|
47
|
+
|
|
48
|
+
| 特色 | 說明 |
|
|
49
|
+
|------|------|
|
|
50
|
+
| **跨平台** | 支援 iOS、Android 和 Web |
|
|
51
|
+
| **原生效能** | iOS: `CAGradientLayer` • Android: `Bitmap` + `PorterDuff` • Web: CSS `mask-image` |
|
|
52
|
+
| **Reanimated 支援** | 透過 `AnimatedGradientMaskView` 實現 60fps 流暢遮罩動畫 |
|
|
53
|
+
| **彈性設定** | 自訂顏色、位置、方向與遮罩強度 |
|
|
54
|
+
| **TypeScript** | 完整型別定義 |
|
|
55
|
+
|
|
56
|
+
## 安裝
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install react-native-gradient-mask
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
yarn add react-native-gradient-mask
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 需求
|
|
67
|
+
|
|
68
|
+
| 相依套件 | 版本 |
|
|
69
|
+
|----------|------|
|
|
70
|
+
| Expo SDK | 50+ |
|
|
71
|
+
| React Native | 0.73+ |
|
|
72
|
+
| react-native-reanimated | >= 3.0.0 *(選用)* |
|
|
73
|
+
|
|
74
|
+
### 設定
|
|
75
|
+
|
|
76
|
+
<details>
|
|
77
|
+
<summary><b>iOS</b></summary>
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
cd ios && pod install
|
|
81
|
+
```
|
|
82
|
+
</details>
|
|
83
|
+
|
|
84
|
+
<details>
|
|
85
|
+
<summary><b>Android</b></summary>
|
|
86
|
+
|
|
87
|
+
無需額外設定,自動連結已啟用。
|
|
88
|
+
</details>
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 快速開始
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
import { processColor } from 'react-native';
|
|
96
|
+
import { GradientMaskView } from 'react-native-gradient-mask';
|
|
97
|
+
|
|
98
|
+
const colors = [
|
|
99
|
+
processColor('rgba(0,0,0,0)'),
|
|
100
|
+
processColor('rgba(0,0,0,1)'),
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
export default function App() {
|
|
104
|
+
return (
|
|
105
|
+
<GradientMaskView
|
|
106
|
+
colors={colors}
|
|
107
|
+
locations={[0, 1]}
|
|
108
|
+
direction="top"
|
|
109
|
+
style={{ flex: 1 }}
|
|
110
|
+
>
|
|
111
|
+
<YourContent />
|
|
112
|
+
</GradientMaskView>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## API 參考
|
|
120
|
+
|
|
121
|
+
### 元件
|
|
122
|
+
|
|
123
|
+
| 元件 | 說明 |
|
|
124
|
+
|------|------|
|
|
125
|
+
| `GradientMaskView` | 基礎漸層遮罩元件 |
|
|
126
|
+
| `AnimatedGradientMaskView` | 支援 Reanimated 動畫的漸層遮罩元件 |
|
|
127
|
+
|
|
128
|
+
### Props
|
|
129
|
+
|
|
130
|
+
#### GradientMaskView
|
|
131
|
+
|
|
132
|
+
| 屬性 | 型別 | 必填 | 預設值 | 說明 |
|
|
133
|
+
|------|------|:----:|--------|------|
|
|
134
|
+
| `colors` | `(number \| null)[]` | 是 | - | 漸層顏色(需使用 `processColor()`) |
|
|
135
|
+
| `locations` | `number[]` | 是 | - | 顏色位置 (0-1) |
|
|
136
|
+
| `direction` | `'top' \| 'bottom' \| 'left' \| 'right'` | 否 | `'top'` | 漸層方向 |
|
|
137
|
+
| `maskOpacity` | `number` | 否 | `1` | 遮罩強度 (0-1) |
|
|
138
|
+
| `style` | `ViewStyle` | 否 | - | 容器樣式 |
|
|
139
|
+
| `children` | `ReactNode` | 否 | - | 要套用遮罩的內容 |
|
|
140
|
+
|
|
141
|
+
#### AnimatedGradientMaskView
|
|
142
|
+
|
|
143
|
+
與 `GradientMaskView` 相同,但 `maskOpacity` 接受 `SharedValue<number>` 用於動畫控制。
|
|
144
|
+
|
|
145
|
+
### 方向說明
|
|
146
|
+
|
|
147
|
+
| 方向 | 效果 |
|
|
148
|
+
|------|------|
|
|
149
|
+
| `top` | 頂部透明 → 底部不透明 |
|
|
150
|
+
| `bottom` | 底部透明 → 頂部不透明 |
|
|
151
|
+
| `left` | 左側透明 → 右側不透明 |
|
|
152
|
+
| `right` | 右側透明 → 左側不透明 |
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 範例
|
|
157
|
+
|
|
158
|
+
### 基本淡出效果
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
import { processColor } from 'react-native';
|
|
162
|
+
import { GradientMaskView } from 'react-native-gradient-mask';
|
|
163
|
+
|
|
164
|
+
const colors = [
|
|
165
|
+
processColor('rgba(0,0,0,0)'),
|
|
166
|
+
processColor('rgba(0,0,0,0.5)'),
|
|
167
|
+
processColor('rgba(0,0,0,1)'),
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
function FadeExample() {
|
|
171
|
+
return (
|
|
172
|
+
<GradientMaskView
|
|
173
|
+
colors={colors}
|
|
174
|
+
locations={[0, 0.3, 1]}
|
|
175
|
+
direction="top"
|
|
176
|
+
style={{ flex: 1 }}
|
|
177
|
+
>
|
|
178
|
+
<ScrollView>
|
|
179
|
+
<Text>套用淡出效果的內容</Text>
|
|
180
|
+
</ScrollView>
|
|
181
|
+
</GradientMaskView>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### 搭配 Reanimated 動畫
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
import { processColor } from 'react-native';
|
|
190
|
+
import { AnimatedGradientMaskView } from 'react-native-gradient-mask';
|
|
191
|
+
import { useSharedValue, withTiming } from 'react-native-reanimated';
|
|
192
|
+
|
|
193
|
+
function AnimatedExample() {
|
|
194
|
+
const maskOpacity = useSharedValue(0);
|
|
195
|
+
|
|
196
|
+
const showMask = () => {
|
|
197
|
+
maskOpacity.value = withTiming(1, { duration: 600 });
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const hideMask = () => {
|
|
201
|
+
maskOpacity.value = withTiming(0, { duration: 400 });
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<AnimatedGradientMaskView
|
|
206
|
+
colors={[
|
|
207
|
+
processColor('rgba(0,0,0,0)'),
|
|
208
|
+
processColor('rgba(0,0,0,1)'),
|
|
209
|
+
]}
|
|
210
|
+
locations={[0, 1]}
|
|
211
|
+
maskOpacity={maskOpacity}
|
|
212
|
+
style={{ flex: 1 }}
|
|
213
|
+
>
|
|
214
|
+
<YourContent />
|
|
215
|
+
</AnimatedGradientMaskView>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### 聊天列表動態遮罩
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
import { useMemo, useCallback, useRef } from 'react';
|
|
224
|
+
import { processColor } from 'react-native';
|
|
225
|
+
import { FlashList } from '@shopify/flash-list';
|
|
226
|
+
import { AnimatedGradientMaskView } from 'react-native-gradient-mask';
|
|
227
|
+
import { useSharedValue, withTiming, cancelAnimation, Easing } from 'react-native-reanimated';
|
|
228
|
+
|
|
229
|
+
function ChatList({ messages }) {
|
|
230
|
+
const maskOpacity = useSharedValue(0);
|
|
231
|
+
const isAtBottomRef = useRef(false);
|
|
232
|
+
|
|
233
|
+
const maskColors = useMemo(() => [
|
|
234
|
+
processColor('rgba(0,0,0,0)'),
|
|
235
|
+
processColor('rgba(0,0,0,0)'),
|
|
236
|
+
processColor('rgba(0,0,0,0.2)'),
|
|
237
|
+
processColor('rgba(0,0,0,0.6)'),
|
|
238
|
+
processColor('rgba(0,0,0,0.9)'),
|
|
239
|
+
processColor('rgba(0,0,0,1)'),
|
|
240
|
+
], []);
|
|
241
|
+
|
|
242
|
+
const handleScroll = useCallback((e) => {
|
|
243
|
+
const { contentOffset, layoutMeasurement, contentSize } = e.nativeEvent;
|
|
244
|
+
const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
|
|
245
|
+
const isAtBottom = distanceFromBottom <= 30;
|
|
246
|
+
|
|
247
|
+
if (isAtBottom !== isAtBottomRef.current) {
|
|
248
|
+
isAtBottomRef.current = isAtBottom;
|
|
249
|
+
cancelAnimation(maskOpacity);
|
|
250
|
+
maskOpacity.value = withTiming(isAtBottom ? 1 : 0, {
|
|
251
|
+
duration: isAtBottom ? 600 : 400,
|
|
252
|
+
easing: isAtBottom ? Easing.in(Easing.quad) : Easing.out(Easing.quad),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}, []);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<AnimatedGradientMaskView
|
|
259
|
+
colors={maskColors}
|
|
260
|
+
locations={[0, 0.42, 0.45, 0.48, 0.5, 1]}
|
|
261
|
+
direction="top"
|
|
262
|
+
maskOpacity={maskOpacity}
|
|
263
|
+
style={{ flex: 1 }}
|
|
264
|
+
>
|
|
265
|
+
<FlashList
|
|
266
|
+
data={messages}
|
|
267
|
+
renderItem={({ item }) => <MessageItem item={item} />}
|
|
268
|
+
onScroll={handleScroll}
|
|
269
|
+
scrollEventThrottle={16}
|
|
270
|
+
/>
|
|
271
|
+
</AnimatedGradientMaskView>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 技巧與最佳實踐
|
|
279
|
+
|
|
280
|
+
### 務必使用 `processColor()`
|
|
281
|
+
|
|
282
|
+
```tsx
|
|
283
|
+
// ✅ 正確
|
|
284
|
+
const colors = [
|
|
285
|
+
processColor('rgba(0,0,0,0)'),
|
|
286
|
+
processColor('rgba(0,0,0,1)'),
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
// ❌ 錯誤 - 無法運作
|
|
290
|
+
const colors = [
|
|
291
|
+
'rgba(0,0,0,0)',
|
|
292
|
+
'rgba(0,0,0,1)',
|
|
293
|
+
];
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### 使用 `useMemo` 優化效能
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
const maskColors = useMemo(() => [
|
|
300
|
+
processColor('rgba(0,0,0,0)'),
|
|
301
|
+
processColor('rgba(0,0,0,1)'),
|
|
302
|
+
], []);
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### 避免閃爍
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
import { cancelAnimation } from 'react-native-reanimated';
|
|
309
|
+
|
|
310
|
+
// 在開始新動畫前取消前一個動畫
|
|
311
|
+
cancelAnimation(maskOpacity);
|
|
312
|
+
maskOpacity.value = withTiming(newValue, { duration: 300 });
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## 平台支援
|
|
318
|
+
|
|
319
|
+
| 平台 | 實作方式 | 狀態 |
|
|
320
|
+
|------|----------|:----:|
|
|
321
|
+
| iOS | `CAGradientLayer` | ✅ |
|
|
322
|
+
| Android | `Bitmap` + `LinearGradient` + `PorterDuff` | ✅ |
|
|
323
|
+
| Web | CSS `mask-image` + `linear-gradient` | ✅ |
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## 授權
|
|
328
|
+
|
|
329
|
+
MIT © [DaYuan Lin (CS6)](https://github.com/CS6)
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
<p align="center">
|
|
334
|
+
<sub>為 React Native 社群用心打造 ❤️</sub>
|
|
335
|
+
</p>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
group = 'expo.modules.gradientmask'
|
|
4
|
+
version = '0.1.0'
|
|
5
|
+
|
|
6
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
|
+
apply from: expoModulesCorePlugin
|
|
8
|
+
applyKotlinExpoModulesCorePlugin()
|
|
9
|
+
useCoreDependencies()
|
|
10
|
+
useExpoPublishing()
|
|
11
|
+
|
|
12
|
+
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
|
13
|
+
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
|
14
|
+
// Most of the time, you may like to manage the Android SDK versions yourself.
|
|
15
|
+
def useManagedAndroidSdkVersions = false
|
|
16
|
+
if (useManagedAndroidSdkVersions) {
|
|
17
|
+
useDefaultAndroidSdkVersions()
|
|
18
|
+
} else {
|
|
19
|
+
buildscript {
|
|
20
|
+
// Simple helper that allows the root project to override versions declared by this library.
|
|
21
|
+
ext.safeExtGet = { prop, fallback ->
|
|
22
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
project.android {
|
|
26
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
|
27
|
+
defaultConfig {
|
|
28
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
29
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
android {
|
|
35
|
+
namespace "expo.modules.gradientmask"
|
|
36
|
+
defaultConfig {
|
|
37
|
+
versionCode 1
|
|
38
|
+
versionName "0.1.0"
|
|
39
|
+
}
|
|
40
|
+
lintOptions {
|
|
41
|
+
abortOnError false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package expo.modules.gradientmask
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
class GradientMaskModule : Module() {
|
|
7
|
+
override fun definition() = ModuleDefinition {
|
|
8
|
+
Name("GradientMask")
|
|
9
|
+
|
|
10
|
+
// View definition
|
|
11
|
+
View(GradientMaskView::class) {
|
|
12
|
+
// colors: List of processed colors (from processColor in JS)
|
|
13
|
+
Prop("colors") { view: GradientMaskView, colors: List<Int>? ->
|
|
14
|
+
view.setColors(colors)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// locations: Array of floats (0-1) for gradient stops
|
|
18
|
+
Prop("locations") { view: GradientMaskView, locations: List<Double>? ->
|
|
19
|
+
view.setLocations(locations)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// direction: "top" | "bottom" | "left" | "right"
|
|
23
|
+
Prop("direction") { view: GradientMaskView, direction: String? ->
|
|
24
|
+
view.setDirection(direction ?: "top")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// maskOpacity: 0 = no mask effect, 1 = full gradient mask
|
|
28
|
+
Prop("maskOpacity") { view: GradientMaskView, opacity: Double? ->
|
|
29
|
+
view.setMaskOpacity(opacity ?: 1.0)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
package expo.modules.gradientmask
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.*
|
|
5
|
+
import android.view.View
|
|
6
|
+
import expo.modules.kotlin.AppContext
|
|
7
|
+
import expo.modules.kotlin.views.ExpoView
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GradientMaskView - 原生漸層透明遮罩
|
|
11
|
+
*
|
|
12
|
+
* 使用 Bitmap 作為 mask,配合 setLayerType + PorterDuff.Mode.DST_IN
|
|
13
|
+
*
|
|
14
|
+
* 顏色語意(與 iOS CAGradientLayer mask 一致):
|
|
15
|
+
* - 顏色的 alpha 值決定該區域內容的可見度
|
|
16
|
+
* - alpha = 0 → 內容透明(看到背景)
|
|
17
|
+
* - alpha = 255 → 內容不透明(看到內容)
|
|
18
|
+
*
|
|
19
|
+
* maskOpacity 控制漸層 mask 效果的顯示程度:
|
|
20
|
+
* - maskOpacity = 0 → 無漸層效果,內容完全可見(全部 alpha=255)
|
|
21
|
+
* - maskOpacity = 1 → 完整漸層效果(使用原始 alpha)
|
|
22
|
+
*/
|
|
23
|
+
class GradientMaskView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
24
|
+
|
|
25
|
+
// Gradient mask 的參數
|
|
26
|
+
private var colors: IntArray? = null
|
|
27
|
+
private var locations: FloatArray? = null
|
|
28
|
+
private var direction: String = "top"
|
|
29
|
+
|
|
30
|
+
// maskOpacity: 0 = 無漸層效果, 1 = 完整漸層效果
|
|
31
|
+
private var maskOpacity: Float = 1f
|
|
32
|
+
|
|
33
|
+
// Mask bitmap 和相關的 Paint
|
|
34
|
+
private var maskBitmap: Bitmap? = null
|
|
35
|
+
private var maskBitmapInvalidated = true
|
|
36
|
+
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
37
|
+
private val porterDuffXferMode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
|
|
38
|
+
|
|
39
|
+
init {
|
|
40
|
+
// 確保背景是透明的
|
|
41
|
+
setBackgroundColor(Color.TRANSPARENT)
|
|
42
|
+
// 始終使用 SOFTWARE 模式,避免動態切換層模式造成的黑色閃爍
|
|
43
|
+
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
|
44
|
+
|
|
45
|
+
android.util.Log.d("GradientMask", "=== GradientMaskView init ===")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// MARK: - Props setters
|
|
49
|
+
|
|
50
|
+
fun setColors(colorArray: List<Int>?) {
|
|
51
|
+
colors = colorArray?.toIntArray()
|
|
52
|
+
maskBitmapInvalidated = true
|
|
53
|
+
invalidate()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fun setLocations(locationArray: List<Double>?) {
|
|
57
|
+
locations = locationArray?.map { it.toFloat() }?.toFloatArray()
|
|
58
|
+
maskBitmapInvalidated = true
|
|
59
|
+
invalidate()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fun setDirection(dir: String) {
|
|
63
|
+
direction = dir
|
|
64
|
+
maskBitmapInvalidated = true
|
|
65
|
+
invalidate()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fun setMaskOpacity(opacity: Double) {
|
|
69
|
+
maskOpacity = opacity.toFloat().coerceIn(0f, 1f)
|
|
70
|
+
maskBitmapInvalidated = true
|
|
71
|
+
invalidate()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// MARK: - Layout
|
|
75
|
+
|
|
76
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
77
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
78
|
+
if (w > 0 && h > 0) {
|
|
79
|
+
updateMaskBitmap()
|
|
80
|
+
maskBitmapInvalidated = false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
|
85
|
+
super.onLayout(changed, l, t, r, b)
|
|
86
|
+
if (changed) {
|
|
87
|
+
maskBitmapInvalidated = true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MARK: - Drawing
|
|
92
|
+
|
|
93
|
+
override fun dispatchDraw(canvas: Canvas) {
|
|
94
|
+
// 檢查尺寸是否有效
|
|
95
|
+
if (width <= 0 || height <= 0) {
|
|
96
|
+
super.dispatchDraw(canvas)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 如果 mask bitmap 需要更新,重新創建
|
|
101
|
+
if (maskBitmapInvalidated) {
|
|
102
|
+
updateMaskBitmap()
|
|
103
|
+
maskBitmapInvalidated = false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
val bitmap = maskBitmap
|
|
107
|
+
// 如果沒有 mask bitmap 或 maskOpacity=0,直接繪製子元件(無 mask 效果)
|
|
108
|
+
if (bitmap == null || maskOpacity <= 0f) {
|
|
109
|
+
super.dispatchDraw(canvas)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 使用 saveLayer 創建離屏緩衝區
|
|
114
|
+
val saveCount = canvas.saveLayer(
|
|
115
|
+
0f, 0f,
|
|
116
|
+
width.toFloat(), height.toFloat(),
|
|
117
|
+
null
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// 先繪製所有子元件到離屏緩衝區
|
|
122
|
+
super.dispatchDraw(canvas)
|
|
123
|
+
|
|
124
|
+
// 應用 mask(使用 DST_IN 模式)
|
|
125
|
+
paint.xfermode = porterDuffXferMode
|
|
126
|
+
canvas.drawBitmap(bitmap, 0f, 0f, paint)
|
|
127
|
+
paint.xfermode = null
|
|
128
|
+
} finally {
|
|
129
|
+
canvas.restoreToCount(saveCount)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun updateMaskBitmap() {
|
|
134
|
+
if (width <= 0 || height <= 0) return
|
|
135
|
+
|
|
136
|
+
// 回收舊的 bitmap
|
|
137
|
+
maskBitmap?.recycle()
|
|
138
|
+
|
|
139
|
+
// 創建新的 mask bitmap
|
|
140
|
+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
141
|
+
val bitmapCanvas = Canvas(bitmap)
|
|
142
|
+
|
|
143
|
+
val currentColors = colors
|
|
144
|
+
val currentLocations = locations
|
|
145
|
+
|
|
146
|
+
// 如果沒有 colors/locations,創建全白 mask(內容完全可見)
|
|
147
|
+
if (currentColors == null || currentLocations == null ||
|
|
148
|
+
currentColors.size != currentLocations.size ||
|
|
149
|
+
currentColors.isEmpty()) {
|
|
150
|
+
bitmapCanvas.drawColor(Color.WHITE)
|
|
151
|
+
maskBitmap = bitmap
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 計算 effective colors(根據 maskOpacity 混合)
|
|
156
|
+
val effectiveColors = IntArray(currentColors.size) { i ->
|
|
157
|
+
val originalColor = currentColors[i]
|
|
158
|
+
val originalAlpha = Color.alpha(originalColor)
|
|
159
|
+
// 當 maskOpacity = 0,alpha = 255(完全不透明,內容完全可見)
|
|
160
|
+
// 當 maskOpacity = 1,alpha = originalAlpha
|
|
161
|
+
val blendedAlpha = (255 + (originalAlpha - 255) * maskOpacity).toInt()
|
|
162
|
+
Color.argb(blendedAlpha, 255, 255, 255)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 建立 gradient shader
|
|
166
|
+
val (startX, startY, endX, endY) = getGradientCoordinates()
|
|
167
|
+
val shader = LinearGradient(
|
|
168
|
+
startX, startY, endX, endY,
|
|
169
|
+
effectiveColors,
|
|
170
|
+
currentLocations,
|
|
171
|
+
Shader.TileMode.CLAMP
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// 繪製漸層到 bitmap
|
|
175
|
+
val gradientPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
176
|
+
this.shader = shader
|
|
177
|
+
}
|
|
178
|
+
bitmapCanvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), gradientPaint)
|
|
179
|
+
|
|
180
|
+
maskBitmap = bitmap
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
override fun onDetachedFromWindow() {
|
|
184
|
+
super.onDetachedFromWindow()
|
|
185
|
+
maskBitmap?.recycle()
|
|
186
|
+
maskBitmap = null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private fun getGradientCoordinates(): List<Float> {
|
|
190
|
+
return when (direction) {
|
|
191
|
+
"top" -> listOf(0f, 0f, 0f, height.toFloat())
|
|
192
|
+
"bottom" -> listOf(0f, height.toFloat(), 0f, 0f)
|
|
193
|
+
"left" -> listOf(0f, 0f, width.toFloat(), 0f)
|
|
194
|
+
"right" -> listOf(width.toFloat(), 0f, 0f, 0f)
|
|
195
|
+
else -> listOf(0f, 0f, 0f, height.toFloat())
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
3
|
+
import { GradientMaskViewProps } from './GradientMask.types';
|
|
4
|
+
export type AnimatedGradientMaskViewProps = Omit<GradientMaskViewProps, 'maskOpacity'> & {
|
|
5
|
+
/**
|
|
6
|
+
* Mask effect intensity (0-1) as a Reanimated SharedValue
|
|
7
|
+
* 0 = no gradient effect (content fully visible)
|
|
8
|
+
* 1 = full gradient effect
|
|
9
|
+
*/
|
|
10
|
+
maskOpacity: SharedValue<number>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* AnimatedGradientMaskView - 支援 Reanimated SharedValue 動畫的漸層遮罩元件
|
|
14
|
+
*
|
|
15
|
+
* 使用方式:
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const maskOpacity = useSharedValue(0);
|
|
18
|
+
*
|
|
19
|
+
* // 動畫顯示
|
|
20
|
+
* maskOpacity.value = withTiming(1, {
|
|
21
|
+
* duration: 600,
|
|
22
|
+
* easing: Easing.in(Easing.quad),
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* <AnimatedGradientMaskView
|
|
26
|
+
* colors={colors}
|
|
27
|
+
* locations={locations}
|
|
28
|
+
* direction="top"
|
|
29
|
+
* maskOpacity={maskOpacity}
|
|
30
|
+
* >
|
|
31
|
+
* <FlashList ... />
|
|
32
|
+
* </AnimatedGradientMaskView>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export default function AnimatedGradientMaskView(props: AnimatedGradientMaskViewProps): React.JSX.Element;
|
|
36
|
+
//# sourceMappingURL=AnimatedGradientMaskView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AnimatedGradientMaskView.d.ts","sourceRoot":"","sources":["../src/AnimatedGradientMaskView.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAG3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAO7D,MAAM,MAAM,6BAA6B,GAAG,IAAI,CAC9C,qBAAqB,EACrB,aAAa,CACd,GAAG;IACF;;;;OAIG;IACH,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAClC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,OAAO,UAAU,wBAAwB,CAC9C,KAAK,EAAE,6BAA6B,qBASrC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { requireNativeView } from 'expo';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { useAnimatedProps } from 'react-native-reanimated';
|
|
4
|
+
import Animated from 'react-native-reanimated';
|
|
5
|
+
const NativeView = requireNativeView('GradientMask');
|
|
6
|
+
const AnimatedNativeView = Animated.createAnimatedComponent(NativeView);
|
|
7
|
+
/**
|
|
8
|
+
* AnimatedGradientMaskView - 支援 Reanimated SharedValue 動畫的漸層遮罩元件
|
|
9
|
+
*
|
|
10
|
+
* 使用方式:
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const maskOpacity = useSharedValue(0);
|
|
13
|
+
*
|
|
14
|
+
* // 動畫顯示
|
|
15
|
+
* maskOpacity.value = withTiming(1, {
|
|
16
|
+
* duration: 600,
|
|
17
|
+
* easing: Easing.in(Easing.quad),
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* <AnimatedGradientMaskView
|
|
21
|
+
* colors={colors}
|
|
22
|
+
* locations={locations}
|
|
23
|
+
* direction="top"
|
|
24
|
+
* maskOpacity={maskOpacity}
|
|
25
|
+
* >
|
|
26
|
+
* <FlashList ... />
|
|
27
|
+
* </AnimatedGradientMaskView>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export default function AnimatedGradientMaskView(props) {
|
|
31
|
+
const { maskOpacity, ...restProps } = props;
|
|
32
|
+
const animatedProps = useAnimatedProps(() => ({
|
|
33
|
+
maskOpacity: maskOpacity.value,
|
|
34
|
+
}));
|
|
35
|
+
return <AnimatedNativeView {...restProps} animatedProps={animatedProps}/>;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=AnimatedGradientMaskView.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AnimatedGradientMaskView.js","sourceRoot":"","sources":["../src/AnimatedGradientMaskView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACzC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,QAAQ,MAAM,yBAAyB,CAAC;AAI/C,MAAM,UAAU,GACd,iBAAiB,CAAC,cAAc,CAAC,CAAC;AAEpC,MAAM,kBAAkB,GAAG,QAAQ,CAAC,uBAAuB,CAAC,UAAU,CAAC,CAAC;AAcxE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,OAAO,UAAU,wBAAwB,CAC9C,KAAoC;IAEpC,MAAM,EAAE,WAAW,EAAE,GAAG,SAAS,EAAE,GAAG,KAAK,CAAC;IAE5C,MAAM,aAAa,GAAG,gBAAgB,CAA6C,GAAG,EAAE,CAAC,CAAC;QACxF,WAAW,EAAE,WAAW,CAAC,KAAK;KAC/B,CAAC,CAAC,CAAC;IAEJ,OAAO,CAAC,kBAAkB,CAAC,IAAI,SAAS,CAAC,CAAC,aAAa,CAAC,CAAC,aAAa,CAAC,EAAG,CAAC;AAC7E,CAAC","sourcesContent":["import { requireNativeView } from 'expo';\nimport * as React from 'react';\nimport { useAnimatedProps } from 'react-native-reanimated';\nimport type { SharedValue } from 'react-native-reanimated';\nimport Animated from 'react-native-reanimated';\n\nimport { GradientMaskViewProps } from './GradientMask.types';\n\nconst NativeView: React.ComponentType<GradientMaskViewProps> =\n requireNativeView('GradientMask');\n\nconst AnimatedNativeView = Animated.createAnimatedComponent(NativeView);\n\nexport type AnimatedGradientMaskViewProps = Omit<\n GradientMaskViewProps,\n 'maskOpacity'\n> & {\n /**\n * Mask effect intensity (0-1) as a Reanimated SharedValue\n * 0 = no gradient effect (content fully visible)\n * 1 = full gradient effect\n */\n maskOpacity: SharedValue<number>;\n};\n\n/**\n * AnimatedGradientMaskView - 支援 Reanimated SharedValue 動畫的漸層遮罩元件\n *\n * 使用方式:\n * ```tsx\n * const maskOpacity = useSharedValue(0);\n *\n * // 動畫顯示\n * maskOpacity.value = withTiming(1, {\n * duration: 600,\n * easing: Easing.in(Easing.quad),\n * });\n *\n * <AnimatedGradientMaskView\n * colors={colors}\n * locations={locations}\n * direction=\"top\"\n * maskOpacity={maskOpacity}\n * >\n * <FlashList ... />\n * </AnimatedGradientMaskView>\n * ```\n */\nexport default function AnimatedGradientMaskView(\n props: AnimatedGradientMaskViewProps\n) {\n const { maskOpacity, ...restProps } = props;\n\n const animatedProps = useAnimatedProps<Pick<GradientMaskViewProps, 'maskOpacity'>>(() => ({\n maskOpacity: maskOpacity.value,\n }));\n\n return <AnimatedNativeView {...restProps} animatedProps={animatedProps} />;\n}\n\n"]}
|