react-native-gradient-mask 0.1.0-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/PUBLISHING.md +171 -0
- package/README.md +360 -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/ios/GradientMask.podspec +29 -0
- package/ios/GradientMaskModule.swift +29 -0
- package/ios/GradientMaskView.swift +133 -0
- package/package.json +83 -0
- package/rn-gradient-mask-design.md +657 -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
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
# CharacterChatScreen 動畫與 Mask 系統分析
|
|
2
|
+
|
|
3
|
+
> 此文檔是 `rn-gradient-mask` 原生套件重新設計的重要參考
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 一、現有架構概覽
|
|
8
|
+
|
|
9
|
+
### 元件層級結構
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
CharacterChatScreen
|
|
13
|
+
├── BackgroundImage (角色背景圖)
|
|
14
|
+
├── Header (Animated.View, opacity: uiOpacity)
|
|
15
|
+
├── overlayUI (Animated.View, opacity: uiOpacity)
|
|
16
|
+
│ ├── Coin
|
|
17
|
+
│ ├── Affinity
|
|
18
|
+
│ └── RemainingFreeMessageBubble
|
|
19
|
+
├── Animated.View (translateY 跟隨鍵盤)
|
|
20
|
+
│ ├── messagesContainer (Animated.View, opacity: uiOpacity)
|
|
21
|
+
│ │ └── MaskedView
|
|
22
|
+
│ │ ├── maskElement: AnimatedMaskGradient
|
|
23
|
+
│ │ │ ├── LinearGradient (漸層)
|
|
24
|
+
│ │ │ └── Animated.View (白色覆蓋層)
|
|
25
|
+
│ │ └── children: FlashList (訊息列表)
|
|
26
|
+
│ └── InputArea (Animated.View, opacity: uiOpacity)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 二、主要動畫系統
|
|
32
|
+
|
|
33
|
+
### 1. uiOpacity - 整體 UI 淡入/淡出
|
|
34
|
+
|
|
35
|
+
| 項目 | 說明 |
|
|
36
|
+
|-----|------|
|
|
37
|
+
| **來源** | `useUIState.ts:28` |
|
|
38
|
+
| **類型** | `Animated.Value` (React Native 原生動畫) |
|
|
39
|
+
| **驅動方式** | `Animated.timing()` with `useNativeDriver: true` |
|
|
40
|
+
| **用途** | 控制整體 UI 元素的顯示/隱藏 |
|
|
41
|
+
|
|
42
|
+
**影響範圍**:
|
|
43
|
+
- Header (`opacity: uiOpacity`)
|
|
44
|
+
- overlayUI (Coin, Affinity 等)
|
|
45
|
+
- messagesContainer
|
|
46
|
+
- InputArea 區域
|
|
47
|
+
|
|
48
|
+
**動畫程式碼**:
|
|
49
|
+
```typescript
|
|
50
|
+
// 檔案: index.tsx:1105, 1903
|
|
51
|
+
Animated.timing(uiOpacity, {
|
|
52
|
+
toValue: 1,
|
|
53
|
+
duration: 300,
|
|
54
|
+
useNativeDriver: true,
|
|
55
|
+
}).start();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### 2. maskOpacity - 漸層遮罩效果 ⭐ 重點
|
|
61
|
+
|
|
62
|
+
| 項目 | 說明 |
|
|
63
|
+
|-----|------|
|
|
64
|
+
| **來源** | `useMaskedView.ts:12` |
|
|
65
|
+
| **類型** | `SharedValue<number>` (Reanimated) |
|
|
66
|
+
| **驅動方式** | `withTiming()` |
|
|
67
|
+
| **用途** | 控制 MaskedView 的漸層透明效果強度 |
|
|
68
|
+
|
|
69
|
+
**動畫程式碼**:
|
|
70
|
+
```typescript
|
|
71
|
+
// 檔案: useMaskedView.ts:25-28
|
|
72
|
+
maskOpacity.value = withTiming(want ? 1 : 0, {
|
|
73
|
+
duration: want ? 600 : 400, // 顯示 600ms, 隱藏 400ms
|
|
74
|
+
easing: want ? Easing.in(Easing.quad) : Easing.out(Easing.quad),
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**AnimatedMaskGradient 內部實作**:
|
|
79
|
+
```typescript
|
|
80
|
+
// 檔案: AnimatedMaskGradient.tsx:28-30
|
|
81
|
+
const overlayAnimatedStyle = useAnimatedStyle(() => ({
|
|
82
|
+
opacity: 1 - maskOpacity.value,
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// 結構
|
|
86
|
+
<View>
|
|
87
|
+
<LinearGradient colors={BASE_MASK_COLORS} locations={MASK_GRADIENT_LOCATIONS} />
|
|
88
|
+
<Animated.View style={[{ backgroundColor: "white" }, overlayAnimatedStyle]} />
|
|
89
|
+
</View>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**漸層配置**:
|
|
93
|
+
```typescript
|
|
94
|
+
// 檔案: AnimatedMaskGradient.tsx:9-13
|
|
95
|
+
const MASK_GRADIENT_ALPHA_STOPS = [0, 0, 0.2, 0.6, 0.9, 1];
|
|
96
|
+
const MASK_GRADIENT_LOCATIONS = [0, 0.42, 0.45, 0.48, 0.5, 1];
|
|
97
|
+
const BASE_MASK_COLORS = MASK_GRADIENT_ALPHA_STOPS.map(
|
|
98
|
+
stop => `rgba(255,255,255,${stop})` // 注意:使用白色
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### 3. translateY - 鍵盤跟隨動畫
|
|
105
|
+
|
|
106
|
+
| 項目 | 說明 |
|
|
107
|
+
|-----|------|
|
|
108
|
+
| **來源** | `useKeyboardAnimations.ts:45` |
|
|
109
|
+
| **類型** | `Animated.Value` |
|
|
110
|
+
| **驅動方式** | `Animated.timing()` with `useNativeDriver: true` |
|
|
111
|
+
| **用途** | 讓訊息區域和輸入框跟隨鍵盤移動 |
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 三、使用者旅程與動畫觸發時機
|
|
116
|
+
|
|
117
|
+
### 場景 1: 進入聊天畫面
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
使用者動作: 點擊角色進入聊天
|
|
121
|
+
─────────────────────────────────────
|
|
122
|
+
1. 畫面載入
|
|
123
|
+
└─ uiOpacity: 0 → 1 (300ms 淡入)
|
|
124
|
+
└─ maskOpacity: 0 (無遮罩效果,訊息完全可見)
|
|
125
|
+
|
|
126
|
+
2. 歷史訊息載入完成
|
|
127
|
+
└─ maskOpacity 維持 0
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 場景 2: 滾動訊息列表
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
使用者動作: 開始滾動訊息
|
|
134
|
+
─────────────────────────────────────
|
|
135
|
+
1. onScrollBeginDrag 觸發
|
|
136
|
+
└─ setMaskTargetVisible(true)
|
|
137
|
+
└─ maskOpacity: 0 → 1 (600ms, ease-in-quad)
|
|
138
|
+
└─ 效果: 頂部漸變透明,露出背景圖
|
|
139
|
+
|
|
140
|
+
2. 滾動結束 (onMomentumScrollEnd)
|
|
141
|
+
└─ 延遲後 setMaskTargetVisible(false)
|
|
142
|
+
└─ maskOpacity: 1 → 0 (400ms, ease-out-quad)
|
|
143
|
+
└─ 效果: 漸層消失,訊息完全可見
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 場景 3: 點擊背景隱藏 UI
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
使用者動作: 點擊背景區域
|
|
150
|
+
─────────────────────────────────────
|
|
151
|
+
1. tapGesture 觸發
|
|
152
|
+
└─ uiOpacity: 1 → 0 (隱藏所有 UI)
|
|
153
|
+
└─ maskOpacity: 0 (重置)
|
|
154
|
+
|
|
155
|
+
2. 再次點擊
|
|
156
|
+
└─ uiOpacity: 0 → 1 (顯示所有 UI)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 場景 4: 離開聊天畫面
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
使用者動作: 點擊返回按鈕
|
|
163
|
+
─────────────────────────────────────
|
|
164
|
+
1. handleBackPress 觸發
|
|
165
|
+
└─ cancelAnimation(maskOpacity)
|
|
166
|
+
└─ maskOpacity.value = 0 (立即重置)
|
|
167
|
+
└─ navigation.goBack()
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 四、效能瓶頸分析
|
|
173
|
+
|
|
174
|
+
| 動畫 | 類型 | 效能評估 | 問題 |
|
|
175
|
+
|-----|------|---------|------|
|
|
176
|
+
| **uiOpacity** | RN Animated | ✅ 好 | `useNativeDriver: true`,在原生層執行 |
|
|
177
|
+
| **translateY** | RN Animated | ✅ 好 | `useNativeDriver: true`,在原生層執行 |
|
|
178
|
+
| **maskOpacity** | Reanimated | ⚠️ 有問題 | 需要更深入分析 |
|
|
179
|
+
|
|
180
|
+
### maskOpacity 效能問題詳解
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
現有架構:
|
|
184
|
+
┌─────────────────────────────────────────────────────────┐
|
|
185
|
+
│ JS Thread │
|
|
186
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
187
|
+
│ │ useMaskedView │ │
|
|
188
|
+
│ │ maskOpacity = useSharedValue(0) │ │
|
|
189
|
+
│ │ maskOpacity.value = withTiming(1, {...}) │ │
|
|
190
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
191
|
+
│ │ │
|
|
192
|
+
│ ▼ │
|
|
193
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
194
|
+
│ │ AnimatedMaskGradient │ │
|
|
195
|
+
│ │ useAnimatedStyle(() => ({ │ │
|
|
196
|
+
│ │ opacity: 1 - maskOpacity.value ◄── 每幀執行 │ │
|
|
197
|
+
│ │ })) │ │
|
|
198
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
199
|
+
└─────────────────────────────────────────────────────────┘
|
|
200
|
+
│
|
|
201
|
+
▼
|
|
202
|
+
┌─────────────────────────────────────────────────────────┐
|
|
203
|
+
│ Native (MaskedView - JS 元件) │
|
|
204
|
+
│ ┌─────────────────────────────────────────────────┐ │
|
|
205
|
+
│ │ maskElement 每幀重新渲染? │ │
|
|
206
|
+
│ │ FlashList 可能受影響 │ │
|
|
207
|
+
│ └─────────────────────────────────────────────────┘ │
|
|
208
|
+
└─────────────────────────────────────────────────────────┘
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**問題點**:
|
|
212
|
+
1. `MaskedView` 是 JS 元件,不是純原生
|
|
213
|
+
2. `useAnimatedStyle` 雖然在 UI thread 執行,但 MaskedView 的 re-render 可能在 JS thread
|
|
214
|
+
3. 動畫期間可能影響 FlashList 的滾動效能
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## 五、相關檔案清單
|
|
219
|
+
|
|
220
|
+
### 核心檔案
|
|
221
|
+
|
|
222
|
+
| 檔案 | 用途 |
|
|
223
|
+
|-----|------|
|
|
224
|
+
| `src/screens/CharacterChatScreen/index.tsx` | 主畫面,整合所有動畫 |
|
|
225
|
+
| `src/hooks/useMaskedView.ts` | maskOpacity 狀態與動畫控制 |
|
|
226
|
+
| `src/screens/CharacterChatScreen/hooks/useUIState.ts` | uiOpacity 狀態 |
|
|
227
|
+
| `src/screens/CharacterChatScreen/hooks/useKeyboardAnimations.ts` | translateY 鍵盤動畫 |
|
|
228
|
+
| `src/screens/CharacterChatScreen/components/AnimatedMaskGradient.tsx` | MaskedView 的 maskElement |
|
|
229
|
+
|
|
230
|
+
### 第三方依賴
|
|
231
|
+
|
|
232
|
+
| 套件 | 用途 |
|
|
233
|
+
|-----|------|
|
|
234
|
+
| `@react-native-masked-view/masked-view` | 提供 MaskedView 元件 |
|
|
235
|
+
| `react-native-linear-gradient` | 提供 LinearGradient 元件 |
|
|
236
|
+
| `react-native-reanimated` | 提供 SharedValue 和 worklet 動畫 |
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 六、rn-gradient-mask 目標
|
|
241
|
+
|
|
242
|
+
將以下結構:
|
|
243
|
+
```
|
|
244
|
+
MaskedView (JS)
|
|
245
|
+
├── maskElement: AnimatedMaskGradient (JS + Reanimated)
|
|
246
|
+
│ ├── LinearGradient
|
|
247
|
+
│ └── Animated.View (白色覆蓋層)
|
|
248
|
+
└── children: FlashList
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
替換為:
|
|
252
|
+
```
|
|
253
|
+
RNGradientMask (純原生)
|
|
254
|
+
├── 原生漸層 mask 渲染
|
|
255
|
+
├── 原生 maskOpacity 控制
|
|
256
|
+
└── children: FlashList
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### 設計原則
|
|
260
|
+
|
|
261
|
+
1. **原生層處理所有 mask 渲染** - 不再依賴 MaskedView + LinearGradient
|
|
262
|
+
2. **maskOpacity 透過 Reanimated 驅動原生 prop** - 使用 `useAnimatedProps`
|
|
263
|
+
3. **保持相同的 API 介面** - 讓遷移成本最低
|
|
264
|
+
4. **保持相同的動畫時序** - 600ms 顯示, 400ms 隱藏
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## 七、rn-gradient-mask 功能需求
|
|
269
|
+
|
|
270
|
+
### 目標用法
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
// 取代 MaskedView + AnimatedMaskGradient
|
|
274
|
+
<RNGradientMask
|
|
275
|
+
style={{ flex: 1 }}
|
|
276
|
+
maskOpacity={maskOpacity} // SharedValue<number> 或 number
|
|
277
|
+
colors={GRADIENT_COLORS}
|
|
278
|
+
locations={GRADIENT_LOCATIONS}
|
|
279
|
+
direction="top"
|
|
280
|
+
>
|
|
281
|
+
<FlashList ... />
|
|
282
|
+
</RNGradientMask>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Props 規格
|
|
286
|
+
|
|
287
|
+
| Prop | 類型 | 預設值 | 說明 |
|
|
288
|
+
|-----|------|-------|------|
|
|
289
|
+
| `colors` | `string[]` | 必填 | 漸層顏色陣列 (black alpha 格式) |
|
|
290
|
+
| `locations` | `number[]` | 均勻分布 | 顏色位置 (0-1) |
|
|
291
|
+
| `direction` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'top'` | 漸層方向 |
|
|
292
|
+
| `maskOpacity` | `SharedValue<number>` 或 `number` | `0` | 漸層效果強度 (0-1) |
|
|
293
|
+
| `style` | `ViewStyle` | - | 容器樣式 |
|
|
294
|
+
| `children` | `ReactNode` | - | 子元件 |
|
|
295
|
+
|
|
296
|
+
### maskOpacity 行為定義
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
maskOpacity = 0
|
|
300
|
+
├── 無漸層效果
|
|
301
|
+
├── 子元件完全可見(不透明)
|
|
302
|
+
└── 使用情境:一般瀏覽狀態
|
|
303
|
+
|
|
304
|
+
maskOpacity = 1
|
|
305
|
+
├── 完整漸層效果
|
|
306
|
+
├── 頂部透明,露出背景圖
|
|
307
|
+
└── 使用情境:滾動時顯示背景
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 漸層配置(對應 AnimatedMaskGradient)
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// 原有配置 (白色格式,用於 MaskedView)
|
|
314
|
+
const MASK_GRADIENT_ALPHA_STOPS = [0, 0, 0.2, 0.6, 0.9, 1];
|
|
315
|
+
const MASK_GRADIENT_LOCATIONS = [0, 0.42, 0.45, 0.48, 0.5, 1];
|
|
316
|
+
const BASE_MASK_COLORS = MASK_GRADIENT_ALPHA_STOPS.map(
|
|
317
|
+
stop => `rgba(255,255,255,${stop})`
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// 轉換為原生格式 (黑色格式,用於 RNGradientMask)
|
|
321
|
+
const NATIVE_GRADIENT_COLORS = [
|
|
322
|
+
"rgba(0,0,0,0)", // 0% - 透明
|
|
323
|
+
"rgba(0,0,0,0)", // 42% - 透明
|
|
324
|
+
"rgba(0,0,0,0.2)", // 45% - 20% 不透明
|
|
325
|
+
"rgba(0,0,0,0.6)", // 48% - 60% 不透明
|
|
326
|
+
"rgba(0,0,0,0.9)", // 50% - 90% 不透明
|
|
327
|
+
"rgba(0,0,0,1)", // 100% - 不透明
|
|
328
|
+
];
|
|
329
|
+
const NATIVE_GRADIENT_LOCATIONS = [0, 0.42, 0.45, 0.48, 0.5, 1];
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## 八、動畫驅動方案比較
|
|
335
|
+
|
|
336
|
+
### 方案 A:JS 層驅動(useAnimatedProps)✅ 採用(主要方案)
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
JS 層 (useMaskedView)
|
|
340
|
+
├── maskOpacity = useSharedValue(0)
|
|
341
|
+
├── setMaskTargetVisible(true/false) 觸發動畫
|
|
342
|
+
└── withTiming() 控制動畫時序
|
|
343
|
+
│
|
|
344
|
+
▼
|
|
345
|
+
useAnimatedProps
|
|
346
|
+
│
|
|
347
|
+
▼
|
|
348
|
+
原生層接收 prop 變化,只做渲染
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**優點**:
|
|
352
|
+
- JS 完全控制動畫時序、easing
|
|
353
|
+
- 可以隨時 cancelAnimation
|
|
354
|
+
- 可以根據業務邏輯動態調整
|
|
355
|
+
|
|
356
|
+
**缺點**:
|
|
357
|
+
- 需要 JS ↔ Native 通訊
|
|
358
|
+
- Reanimated worklet 仍在執行
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### 方案 B:原生層驅動(Native Animation)🔄 備用方案
|
|
363
|
+
|
|
364
|
+
```
|
|
365
|
+
JS 層
|
|
366
|
+
├── 呼叫原生方法:showMask() / hideMask()
|
|
367
|
+
└── 不管動畫細節
|
|
368
|
+
│
|
|
369
|
+
▼
|
|
370
|
+
原生層
|
|
371
|
+
├── iOS: CABasicAnimation
|
|
372
|
+
├── Android: ValueAnimator
|
|
373
|
+
└── 完全在原生層執行動畫
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**優點**:
|
|
377
|
+
- 動畫完全在原生層,效能最佳
|
|
378
|
+
- 不需要 Reanimated
|
|
379
|
+
|
|
380
|
+
**缺點**:
|
|
381
|
+
- JS 無法即時控制動畫進度
|
|
382
|
+
- 難以實現 cancelAnimation
|
|
383
|
+
- 動畫時序寫死在原生層,修改需要重新編譯
|
|
384
|
+
- 無法根據業務邏輯動態調整
|
|
385
|
+
|
|
386
|
+
**備用原因**:
|
|
387
|
+
- 如果方案 A 的效能無法滿足需求,可考慮切換到此方案
|
|
388
|
+
- 需要實作原生方法:`showMask(duration: number)` 和 `hideMask(duration: number)`
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
### 方案 C:混合模式(記錄但不採用)
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
JS 層
|
|
396
|
+
├── maskOpacity prop (靜態值或 SharedValue)
|
|
397
|
+
├── 簡單情況:直接傳數值
|
|
398
|
+
└── 動畫情況:用 useAnimatedProps
|
|
399
|
+
│
|
|
400
|
+
▼
|
|
401
|
+
原生層
|
|
402
|
+
├── 接收 maskOpacity prop
|
|
403
|
+
├── 如果需要,可選擇性內建簡單動畫
|
|
404
|
+
└── 但主要依賴 JS 驅動
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**說明**:
|
|
408
|
+
- 此方案結合了方案 A 和 B 的優點
|
|
409
|
+
- 目前不採用,但保留作為未來可能的優化方向
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
### 決定:採用方案 A(JS 層驅動 + useAnimatedProps)
|
|
414
|
+
|
|
415
|
+
**狀態**:✅ 已決定採用,待實作
|
|
416
|
+
|
|
417
|
+
**考量因素**:
|
|
418
|
+
1. **效能**:useAnimatedProps 在 UI thread 執行,效能已經很好
|
|
419
|
+
2. **靈活性**:JS 完全控制動畫,可隨時調整
|
|
420
|
+
3. **維護性**:改動在 JS 層,不需重新編譯原生
|
|
421
|
+
4. **現有架構**:`useMaskedView` 已經用 Reanimated,遷移成本最低
|
|
422
|
+
5. **必要功能**:`cancelAnimation` 是必要的(離開畫面時需要立即停止)
|
|
423
|
+
|
|
424
|
+
**備用方案**:方案 B(原生層驅動)
|
|
425
|
+
- 如果方案 A 的效能無法滿足需求,可考慮切換到方案 B
|
|
426
|
+
- 方案 B 的實作細節已記錄,可作為未來優化方向
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## 九、實作設計
|
|
431
|
+
|
|
432
|
+
### 架構圖
|
|
433
|
+
|
|
434
|
+
```
|
|
435
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
436
|
+
│ JS 層 │
|
|
437
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
438
|
+
│ │ useMaskedView (保留不變) │ │
|
|
439
|
+
│ │ maskOpacity = useSharedValue(0) │ │
|
|
440
|
+
│ │ setMaskTargetVisible(true/false) │ │
|
|
441
|
+
│ │ withTiming() 控制動畫 │ │
|
|
442
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
443
|
+
│ │ │
|
|
444
|
+
│ ▼ │
|
|
445
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
446
|
+
│ │ AnimatedGradientMask (新元件) │ │
|
|
447
|
+
│ │ useAnimatedProps(() => ({ │ │
|
|
448
|
+
│ │ maskOpacity: maskOpacity.value │ │
|
|
449
|
+
│ │ })) │ │
|
|
450
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
451
|
+
└─────────────────────────────────────────────────────────────┘
|
|
452
|
+
│
|
|
453
|
+
▼ (prop 變化直接更新原生層)
|
|
454
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
455
|
+
│ 原生層 (RNGradientMask) │
|
|
456
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
457
|
+
│ │ iOS: CAGradientLayer + solidMaskLayer │ │
|
|
458
|
+
│ │ Android: LinearGradient + PorterDuff.Mode.DST_IN │ │
|
|
459
|
+
│ │ │ │
|
|
460
|
+
│ │ maskOpacity prop 變化 → 更新 solidMaskLayer opacity │ │
|
|
461
|
+
│ │ (不觸發 JS re-render) │ │
|
|
462
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
463
|
+
│ │ │
|
|
464
|
+
│ ▼ │
|
|
465
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
466
|
+
│ │ children: FlashList (不受動畫影響) │ │
|
|
467
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
468
|
+
└─────────────────────────────────────────────────────────────┘
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### 元件 API
|
|
472
|
+
|
|
473
|
+
```tsx
|
|
474
|
+
// 靜態用法
|
|
475
|
+
<GradientMask
|
|
476
|
+
colors={GRADIENT_COLORS}
|
|
477
|
+
locations={GRADIENT_LOCATIONS}
|
|
478
|
+
direction="top"
|
|
479
|
+
maskOpacity={1} // 固定值
|
|
480
|
+
>
|
|
481
|
+
<FlashList ... />
|
|
482
|
+
</GradientMask>
|
|
483
|
+
|
|
484
|
+
// 動畫用法 (搭配 useMaskedView)
|
|
485
|
+
const { maskOpacity } = useMaskedView();
|
|
486
|
+
|
|
487
|
+
<AnimatedGradientMask
|
|
488
|
+
colors={GRADIENT_COLORS}
|
|
489
|
+
locations={GRADIENT_LOCATIONS}
|
|
490
|
+
direction="top"
|
|
491
|
+
maskOpacity={maskOpacity} // SharedValue<number>
|
|
492
|
+
>
|
|
493
|
+
<FlashList ... />
|
|
494
|
+
</AnimatedGradientMask>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## 十、檔案修改清單
|
|
500
|
+
|
|
501
|
+
### 需要修改的檔案
|
|
502
|
+
|
|
503
|
+
| 檔案 | 修改內容 |
|
|
504
|
+
|-----|---------|
|
|
505
|
+
| `ios/RNGradientMask/RNGradientMask.swift` | 確認 maskOpacity prop 正確運作 |
|
|
506
|
+
| `ios/RNGradientMask/RNGradientMaskManager.m` | 確認導出 maskOpacity |
|
|
507
|
+
| `android/.../RNGradientMaskView.kt` | 確認 maskOpacity prop 正確運作 |
|
|
508
|
+
| `android/.../RNGradientMaskManager.kt` | 確認導出 maskOpacity |
|
|
509
|
+
| `src/native-modules/rn-gradient-mask/index.tsx` | 簡化,移除多餘的 hook |
|
|
510
|
+
|
|
511
|
+
### 需要刪除的檔案
|
|
512
|
+
|
|
513
|
+
| 檔案 | 原因 |
|
|
514
|
+
|-----|------|
|
|
515
|
+
| `src/native-modules/rn-gradient-mask/hooks/index.ts` | 多餘,直接使用 useMaskedView |
|
|
516
|
+
|
|
517
|
+
### CharacterChatScreen 遷移
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
// 移除
|
|
521
|
+
import MaskedView from "@react-native-masked-view/masked-view";
|
|
522
|
+
import AnimatedMaskGradient from "./components/AnimatedMaskGradient";
|
|
523
|
+
|
|
524
|
+
// 新增
|
|
525
|
+
import { AnimatedGradientMask, GRADIENT_PRESETS } from "@/native-modules/rn-gradient-mask";
|
|
526
|
+
|
|
527
|
+
// 移除 maskElement
|
|
528
|
+
const maskElement = useMemo(() => (
|
|
529
|
+
<AnimatedMaskGradient maskOpacity={maskOpacity} style={styles.gradientMask} />
|
|
530
|
+
), [maskOpacity]);
|
|
531
|
+
|
|
532
|
+
// 替換 MaskedView
|
|
533
|
+
<MaskedView style={{ flex: 1 }} maskElement={maskElement}>
|
|
534
|
+
<FlashList ... />
|
|
535
|
+
</MaskedView>
|
|
536
|
+
|
|
537
|
+
// 改為
|
|
538
|
+
<AnimatedGradientMask
|
|
539
|
+
style={{ flex: 1 }}
|
|
540
|
+
colors={GRADIENT_PRESETS.CHAT_MASK.colors}
|
|
541
|
+
locations={GRADIENT_PRESETS.CHAT_MASK.locations}
|
|
542
|
+
direction="top"
|
|
543
|
+
maskOpacity={maskOpacity}
|
|
544
|
+
>
|
|
545
|
+
<FlashList ... />
|
|
546
|
+
</AnimatedGradientMask>
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## 十一、實作步驟
|
|
552
|
+
|
|
553
|
+
### 階段 1:驗證現有原生實作
|
|
554
|
+
|
|
555
|
+
1. 確認 iOS `RNGradientMask.swift` 的 maskOpacity 行為正確
|
|
556
|
+
2. 確認 Android `RNGradientMaskView.kt` 的 maskOpacity 行為正確
|
|
557
|
+
3. 使用 `CharacterChatScreenV3DTestNative` 測試頁面驗證
|
|
558
|
+
|
|
559
|
+
### 階段 2:簡化 JS 層
|
|
560
|
+
|
|
561
|
+
1. 移除 `hooks/index.ts`(useGradientMaskAnimation)
|
|
562
|
+
2. 更新 `index.tsx`:
|
|
563
|
+
- `GradientMask`: 接受 `maskOpacity: number`
|
|
564
|
+
- `AnimatedGradientMask`: 接受 `maskOpacity: SharedValue<number>`
|
|
565
|
+
3. 確認 `useAnimatedProps` 正確驅動原生 prop
|
|
566
|
+
|
|
567
|
+
### 階段 3:整合測試
|
|
568
|
+
|
|
569
|
+
1. 更新 `CharacterChatScreenV3DTestNative` 使用 `useMaskedView`
|
|
570
|
+
2. 比較效能與原有 MaskedView 方案
|
|
571
|
+
3. 確認所有使用情境正常:
|
|
572
|
+
- 滾動顯示/隱藏 mask
|
|
573
|
+
- 離開畫面時 cancelAnimation
|
|
574
|
+
- 點擊背景重置 mask
|
|
575
|
+
|
|
576
|
+
### 階段 4:正式遷移(如果測試通過)
|
|
577
|
+
|
|
578
|
+
1. 在 `CharacterChatScreen` 替換 MaskedView
|
|
579
|
+
2. 移除 `AnimatedMaskGradient.tsx` 元件
|
|
580
|
+
3. 更新相關 import
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## 十二、預設漸層配置
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
// src/native-modules/rn-gradient-mask/index.tsx
|
|
588
|
+
|
|
589
|
+
export const GRADIENT_PRESETS = {
|
|
590
|
+
/**
|
|
591
|
+
* 聊天畫面遮罩 - 對應 AnimatedMaskGradient 的配置
|
|
592
|
+
* 頂部 42% 透明,漸層過渡到不透明
|
|
593
|
+
*/
|
|
594
|
+
CHAT_MASK: {
|
|
595
|
+
colors: [
|
|
596
|
+
"rgba(0,0,0,0)", // 0% - 透明
|
|
597
|
+
"rgba(0,0,0,0)", // 42% - 透明
|
|
598
|
+
"rgba(0,0,0,0.2)", // 45% - 20% 不透明
|
|
599
|
+
"rgba(0,0,0,0.6)", // 48% - 60% 不透明
|
|
600
|
+
"rgba(0,0,0,0.9)", // 50% - 90% 不透明
|
|
601
|
+
"rgba(0,0,0,1)", // 100% - 不透明
|
|
602
|
+
],
|
|
603
|
+
locations: [0, 0.42, 0.45, 0.48, 0.5, 1],
|
|
604
|
+
},
|
|
605
|
+
} as const;
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## 十三、實作狀態與待辦事項
|
|
611
|
+
|
|
612
|
+
### 當前實作狀態
|
|
613
|
+
|
|
614
|
+
| 功能 | 狀態 | 說明 |
|
|
615
|
+
|-----|------|------|
|
|
616
|
+
| 基本漸層遮罩 | ✅ 已完成 | iOS 和 Android 原生實作完成 |
|
|
617
|
+
| maskOpacity 靜態控制 | ✅ 已完成 | 支援 0-1 數值控制 |
|
|
618
|
+
| 漸層方向支援 | ✅ 已完成 | top/bottom/left/right |
|
|
619
|
+
| 顏色和位置配置 | ✅ 已完成 | colors 和 locations props |
|
|
620
|
+
| **maskOpacity 動畫** | ⏳ **待實作** | 使用方案 A(JS 層驅動) |
|
|
621
|
+
| AnimatedGradientMask 元件 | ⏳ **待實作** | 支援 SharedValue 動畫 |
|
|
622
|
+
| useAnimatedProps 整合 | ⏳ **待實作** | Reanimated 動畫驅動 |
|
|
623
|
+
|
|
624
|
+
### 動畫方案決定
|
|
625
|
+
|
|
626
|
+
- **主要方案**:方案 A(JS 層驅動 + useAnimatedProps)
|
|
627
|
+
- 狀態:✅ 已決定採用,待實作
|
|
628
|
+
- 實作方式:使用 `useAnimatedProps` 將 Reanimated `SharedValue` 傳遞給原生層
|
|
629
|
+
- 動畫時序:顯示 600ms (ease-in-quad),隱藏 400ms (ease-out-quad)
|
|
630
|
+
|
|
631
|
+
- **備用方案**:方案 B(原生層驅動)
|
|
632
|
+
- 狀態:🔄 備用方案,暫不實作
|
|
633
|
+
- 使用時機:如果方案 A 效能無法滿足需求時考慮
|
|
634
|
+
- 實作方式:原生層內建 CABasicAnimation (iOS) / ValueAnimator (Android)
|
|
635
|
+
|
|
636
|
+
### 待實作項目
|
|
637
|
+
|
|
638
|
+
1. **AnimatedGradientMask 元件**
|
|
639
|
+
- 創建支援 `SharedValue<number>` 的動畫版本元件
|
|
640
|
+
- 使用 `useAnimatedProps` 將動畫值傳遞給原生層
|
|
641
|
+
- 保持與靜態 `GradientMask` 元件相同的 API
|
|
642
|
+
|
|
643
|
+
2. **動畫時序配置**
|
|
644
|
+
- 顯示動畫:600ms,ease-in-quad
|
|
645
|
+
- 隱藏動畫:400ms,ease-out-quad
|
|
646
|
+
- 支援 `cancelAnimation` 功能
|
|
647
|
+
|
|
648
|
+
3. **測試與驗證**
|
|
649
|
+
- 驗證動畫流暢度
|
|
650
|
+
- 測試與 FlashList 的相容性
|
|
651
|
+
- 效能對比測試(與原有 MaskedView 方案)
|
|
652
|
+
|
|
653
|
+
### 備註
|
|
654
|
+
|
|
655
|
+
- 當前版本已符合基本需求(靜態 maskOpacity 控制)
|
|
656
|
+
- 動畫功能待後續實作,不影響基本使用
|
|
657
|
+
- 方案 B 的實作細節已記錄,可作為未來優化方向
|