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,60 @@
|
|
|
1
|
+
import { requireNativeView } from 'expo';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { useAnimatedProps } from 'react-native-reanimated';
|
|
4
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
5
|
+
import Animated from 'react-native-reanimated';
|
|
6
|
+
|
|
7
|
+
import { GradientMaskViewProps } from './GradientMask.types';
|
|
8
|
+
|
|
9
|
+
const NativeView: React.ComponentType<GradientMaskViewProps> =
|
|
10
|
+
requireNativeView('GradientMask');
|
|
11
|
+
|
|
12
|
+
const AnimatedNativeView = Animated.createAnimatedComponent(NativeView);
|
|
13
|
+
|
|
14
|
+
export type AnimatedGradientMaskViewProps = Omit<
|
|
15
|
+
GradientMaskViewProps,
|
|
16
|
+
'maskOpacity'
|
|
17
|
+
> & {
|
|
18
|
+
/**
|
|
19
|
+
* Mask effect intensity (0-1) as a Reanimated SharedValue
|
|
20
|
+
* 0 = no gradient effect (content fully visible)
|
|
21
|
+
* 1 = full gradient effect
|
|
22
|
+
*/
|
|
23
|
+
maskOpacity: SharedValue<number>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* AnimatedGradientMaskView - 支援 Reanimated SharedValue 動畫的漸層遮罩元件
|
|
28
|
+
*
|
|
29
|
+
* 使用方式:
|
|
30
|
+
* ```tsx
|
|
31
|
+
* const maskOpacity = useSharedValue(0);
|
|
32
|
+
*
|
|
33
|
+
* // 動畫顯示
|
|
34
|
+
* maskOpacity.value = withTiming(1, {
|
|
35
|
+
* duration: 600,
|
|
36
|
+
* easing: Easing.in(Easing.quad),
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* <AnimatedGradientMaskView
|
|
40
|
+
* colors={colors}
|
|
41
|
+
* locations={locations}
|
|
42
|
+
* direction="top"
|
|
43
|
+
* maskOpacity={maskOpacity}
|
|
44
|
+
* >
|
|
45
|
+
* <FlashList ... />
|
|
46
|
+
* </AnimatedGradientMaskView>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export default function AnimatedGradientMaskView(
|
|
50
|
+
props: AnimatedGradientMaskViewProps
|
|
51
|
+
) {
|
|
52
|
+
const { maskOpacity, ...restProps } = props;
|
|
53
|
+
|
|
54
|
+
const animatedProps = useAnimatedProps<Pick<GradientMaskViewProps, 'maskOpacity'>>(() => ({
|
|
55
|
+
maskOpacity: maskOpacity.value,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
return <AnimatedNativeView {...restProps} animatedProps={animatedProps} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
|
|
4
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
5
|
+
|
|
6
|
+
import { GradientMaskViewProps } from './GradientMask.types';
|
|
7
|
+
|
|
8
|
+
export type AnimatedGradientMaskViewProps = Omit<
|
|
9
|
+
GradientMaskViewProps,
|
|
10
|
+
'maskOpacity'
|
|
11
|
+
> & {
|
|
12
|
+
/**
|
|
13
|
+
* Mask effect intensity (0-1) as a Reanimated SharedValue
|
|
14
|
+
* 0 = no gradient effect (content fully visible)
|
|
15
|
+
* 1 = full gradient effect
|
|
16
|
+
*/
|
|
17
|
+
maskOpacity: SharedValue<number>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 將 processColor 處理過的顏色值轉回 rgba 字串
|
|
22
|
+
*/
|
|
23
|
+
function colorToRgba(color: number | null): string {
|
|
24
|
+
if (color === null || color === undefined) {
|
|
25
|
+
return 'rgba(0, 0, 0, 0)';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const intValue = color >>> 0;
|
|
29
|
+
const a = ((intValue >> 24) & 0xff) / 255;
|
|
30
|
+
const r = (intValue >> 16) & 0xff;
|
|
31
|
+
const g = (intValue >> 8) & 0xff;
|
|
32
|
+
const b = intValue & 0xff;
|
|
33
|
+
|
|
34
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 根據 direction 取得 CSS linear-gradient 的方向
|
|
39
|
+
*/
|
|
40
|
+
function getGradientDirection(direction: GradientMaskViewProps['direction']): string {
|
|
41
|
+
switch (direction) {
|
|
42
|
+
case 'top':
|
|
43
|
+
return 'to bottom';
|
|
44
|
+
case 'bottom':
|
|
45
|
+
return 'to top';
|
|
46
|
+
case 'left':
|
|
47
|
+
return 'to right';
|
|
48
|
+
case 'right':
|
|
49
|
+
return 'to left';
|
|
50
|
+
default:
|
|
51
|
+
return 'to bottom';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 建立 CSS linear-gradient 字串
|
|
57
|
+
*/
|
|
58
|
+
function buildGradientString(
|
|
59
|
+
colors: (number | null)[],
|
|
60
|
+
locations: number[],
|
|
61
|
+
direction: GradientMaskViewProps['direction']
|
|
62
|
+
): string {
|
|
63
|
+
const gradientDirection = getGradientDirection(direction);
|
|
64
|
+
|
|
65
|
+
const colorStops = colors.map((color, index) => {
|
|
66
|
+
const rgba = colorToRgba(color);
|
|
67
|
+
const location = locations[index] !== undefined ? locations[index] * 100 : (index / (colors.length - 1)) * 100;
|
|
68
|
+
return `${rgba} ${location}%`;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return `linear-gradient(${gradientDirection}, ${colorStops.join(', ')})`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 根據 maskOpacity 調整顏色的 alpha 值
|
|
76
|
+
*/
|
|
77
|
+
function adjustColorsForOpacity(
|
|
78
|
+
colors: (number | null)[],
|
|
79
|
+
maskOpacity: number
|
|
80
|
+
): (number | null)[] {
|
|
81
|
+
if (maskOpacity >= 1) return colors;
|
|
82
|
+
if (maskOpacity <= 0) {
|
|
83
|
+
return colors.map(() => 0xff000000);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return colors.map((color) => {
|
|
87
|
+
if (color === null || color === undefined) return color;
|
|
88
|
+
const intValue = color >>> 0;
|
|
89
|
+
const a = ((intValue >> 24) & 0xff) / 255;
|
|
90
|
+
const r = (intValue >> 16) & 0xff;
|
|
91
|
+
const g = (intValue >> 8) & 0xff;
|
|
92
|
+
const b = intValue & 0xff;
|
|
93
|
+
const adjustedAlpha = a + (1 - a) * (1 - maskOpacity);
|
|
94
|
+
return ((Math.round(adjustedAlpha * 255) << 24) | (r << 16) | (g << 8) | b) >>> 0;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* AnimatedGradientMaskView - Web 實作
|
|
100
|
+
* 使用 CSS mask-image 搭配 useAnimatedReaction 監聽 SharedValue 變化
|
|
101
|
+
*/
|
|
102
|
+
export default function AnimatedGradientMaskView(props: AnimatedGradientMaskViewProps) {
|
|
103
|
+
const {
|
|
104
|
+
colors,
|
|
105
|
+
locations,
|
|
106
|
+
direction = 'top',
|
|
107
|
+
maskOpacity,
|
|
108
|
+
style,
|
|
109
|
+
children,
|
|
110
|
+
} = props;
|
|
111
|
+
|
|
112
|
+
const [currentOpacity, setCurrentOpacity] = React.useState(maskOpacity.value);
|
|
113
|
+
|
|
114
|
+
// 監聽 SharedValue 變化並更新 state
|
|
115
|
+
useAnimatedReaction(
|
|
116
|
+
() => maskOpacity.value,
|
|
117
|
+
(value) => {
|
|
118
|
+
runOnJS(setCurrentOpacity)(value);
|
|
119
|
+
},
|
|
120
|
+
[maskOpacity]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const maskStyle = React.useMemo(() => {
|
|
124
|
+
if (currentOpacity <= 0) {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const adjustedColors = adjustColorsForOpacity(colors, currentOpacity);
|
|
129
|
+
const gradientString = buildGradientString(adjustedColors, locations, direction);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
WebkitMaskImage: gradientString,
|
|
133
|
+
maskImage: gradientString,
|
|
134
|
+
transition: 'mask-image 0.1s ease-out, -webkit-mask-image 0.1s ease-out',
|
|
135
|
+
};
|
|
136
|
+
}, [colors, locations, direction, currentOpacity]);
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<View style={[styles.container, style, maskStyle as any]}>
|
|
140
|
+
{children}
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const styles = StyleSheet.create({
|
|
146
|
+
container: {
|
|
147
|
+
overflow: 'hidden',
|
|
148
|
+
},
|
|
149
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export type GradientMaskViewProps = {
|
|
4
|
+
/**
|
|
5
|
+
* Gradient colors array (processed colors from processColor)
|
|
6
|
+
* Use alpha values to control opacity
|
|
7
|
+
* e.g., ['rgba(0,0,0,0)', 'rgba(0,0,0,1)'] = transparent to opaque
|
|
8
|
+
*/
|
|
9
|
+
colors: (number | null)[];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Position of each color (0-1)
|
|
13
|
+
* e.g., [0, 0.3, 1] means first color at 0%, second at 30%, third at 100%
|
|
14
|
+
*/
|
|
15
|
+
locations: number[];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gradient direction
|
|
19
|
+
* 'top' = top transparent, bottom opaque
|
|
20
|
+
* 'bottom' = bottom transparent, top opaque
|
|
21
|
+
* 'left' = left transparent, right opaque
|
|
22
|
+
* 'right' = right transparent, left opaque
|
|
23
|
+
*/
|
|
24
|
+
direction?: 'top' | 'bottom' | 'left' | 'right';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mask effect intensity (0-1)
|
|
28
|
+
* 0 = no gradient effect (content fully visible)
|
|
29
|
+
* 1 = full gradient effect
|
|
30
|
+
* @default 1
|
|
31
|
+
*/
|
|
32
|
+
maskOpacity?: number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Style
|
|
36
|
+
*/
|
|
37
|
+
style?: StyleProp<ViewStyle>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Children
|
|
41
|
+
*/
|
|
42
|
+
children?: React.ReactNode;
|
|
43
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { requireNativeView } from 'expo';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
|
|
4
|
+
import { GradientMaskViewProps } from './GradientMask.types';
|
|
5
|
+
|
|
6
|
+
const NativeView: React.ComponentType<GradientMaskViewProps> =
|
|
7
|
+
requireNativeView('GradientMask');
|
|
8
|
+
|
|
9
|
+
export default function GradientMaskView(props: GradientMaskViewProps) {
|
|
10
|
+
return <NativeView {...props} />;
|
|
11
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { GradientMaskViewProps } from './GradientMask.types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 將 processColor 處理過的顏色值轉回 rgba 字串
|
|
8
|
+
*/
|
|
9
|
+
function colorToRgba(color: number | null): string {
|
|
10
|
+
if (color === null || color === undefined) {
|
|
11
|
+
return 'rgba(0, 0, 0, 0)';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// processColor 在 web 上回傳的格式是 AARRGGBB (32-bit integer)
|
|
15
|
+
const intValue = color >>> 0; // 確保是 unsigned
|
|
16
|
+
const a = ((intValue >> 24) & 0xff) / 255;
|
|
17
|
+
const r = (intValue >> 16) & 0xff;
|
|
18
|
+
const g = (intValue >> 8) & 0xff;
|
|
19
|
+
const b = intValue & 0xff;
|
|
20
|
+
|
|
21
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 根據 direction 取得 CSS linear-gradient 的方向
|
|
26
|
+
*/
|
|
27
|
+
function getGradientDirection(direction: GradientMaskViewProps['direction']): string {
|
|
28
|
+
switch (direction) {
|
|
29
|
+
case 'top':
|
|
30
|
+
return 'to bottom'; // 頂部透明 → 底部不透明
|
|
31
|
+
case 'bottom':
|
|
32
|
+
return 'to top'; // 底部透明 → 頂部不透明
|
|
33
|
+
case 'left':
|
|
34
|
+
return 'to right'; // 左側透明 → 右側不透明
|
|
35
|
+
case 'right':
|
|
36
|
+
return 'to left'; // 右側透明 → 左側不透明
|
|
37
|
+
default:
|
|
38
|
+
return 'to bottom';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 建立 CSS linear-gradient 字串
|
|
44
|
+
*/
|
|
45
|
+
function buildGradientString(
|
|
46
|
+
colors: (number | null)[],
|
|
47
|
+
locations: number[],
|
|
48
|
+
direction: GradientMaskViewProps['direction']
|
|
49
|
+
): string {
|
|
50
|
+
const gradientDirection = getGradientDirection(direction);
|
|
51
|
+
|
|
52
|
+
const colorStops = colors.map((color, index) => {
|
|
53
|
+
const rgba = colorToRgba(color);
|
|
54
|
+
const location = locations[index] !== undefined ? locations[index] * 100 : (index / (colors.length - 1)) * 100;
|
|
55
|
+
return `${rgba} ${location}%`;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return `linear-gradient(${gradientDirection}, ${colorStops.join(', ')})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 根據 maskOpacity 調整顏色的 alpha 值
|
|
63
|
+
* maskOpacity = 0 時,所有顏色變為完全不透明(無遮罩效果)
|
|
64
|
+
* maskOpacity = 1 時,保持原本的 alpha 值
|
|
65
|
+
*/
|
|
66
|
+
function adjustColorsForOpacity(
|
|
67
|
+
colors: (number | null)[],
|
|
68
|
+
maskOpacity: number
|
|
69
|
+
): (number | null)[] {
|
|
70
|
+
if (maskOpacity >= 1) return colors;
|
|
71
|
+
if (maskOpacity <= 0) {
|
|
72
|
+
// 全部變成不透明黑色,表示內容完全可見
|
|
73
|
+
return colors.map(() => 0xff000000);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return colors.map((color) => {
|
|
77
|
+
if (color === null || color === undefined) return color;
|
|
78
|
+
const intValue = color >>> 0;
|
|
79
|
+
const a = ((intValue >> 24) & 0xff) / 255;
|
|
80
|
+
const r = (intValue >> 16) & 0xff;
|
|
81
|
+
const g = (intValue >> 8) & 0xff;
|
|
82
|
+
const b = intValue & 0xff;
|
|
83
|
+
// 根據 maskOpacity 調整 alpha:當 maskOpacity = 0 時 alpha 趨近於 1(完全可見)
|
|
84
|
+
const adjustedAlpha = a + (1 - a) * (1 - maskOpacity);
|
|
85
|
+
return ((Math.round(adjustedAlpha * 255) << 24) | (r << 16) | (g << 8) | b) >>> 0;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* GradientMaskView - Web 實作
|
|
91
|
+
* 使用 CSS mask-image 搭配 linear-gradient 實現漸層遮罩效果
|
|
92
|
+
*/
|
|
93
|
+
export default function GradientMaskView(props: GradientMaskViewProps) {
|
|
94
|
+
const {
|
|
95
|
+
colors,
|
|
96
|
+
locations,
|
|
97
|
+
direction = 'top',
|
|
98
|
+
maskOpacity = 1,
|
|
99
|
+
style,
|
|
100
|
+
children,
|
|
101
|
+
} = props;
|
|
102
|
+
|
|
103
|
+
const maskStyle = React.useMemo(() => {
|
|
104
|
+
if (maskOpacity <= 0) {
|
|
105
|
+
// 無遮罩效果
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const adjustedColors = adjustColorsForOpacity(colors, maskOpacity);
|
|
110
|
+
const gradientString = buildGradientString(adjustedColors, locations, direction);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
WebkitMaskImage: gradientString,
|
|
114
|
+
maskImage: gradientString,
|
|
115
|
+
};
|
|
116
|
+
}, [colors, locations, direction, maskOpacity]);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<View style={[styles.container, style, maskStyle as any]}>
|
|
120
|
+
{children}
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const styles = StyleSheet.create({
|
|
126
|
+
container: {
|
|
127
|
+
overflow: 'hidden',
|
|
128
|
+
},
|
|
129
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Reexport the native module. On web, it will be resolved to GradientMaskModule.web.ts
|
|
2
|
+
// and on native platforms to GradientMaskModule.ts
|
|
3
|
+
export { default } from './GradientMaskModule';
|
|
4
|
+
export { default as GradientMaskView } from './GradientMaskView';
|
|
5
|
+
export { default as AnimatedGradientMaskView } from './AnimatedGradientMaskView';
|
|
6
|
+
export type { AnimatedGradientMaskViewProps } from './AnimatedGradientMaskView';
|
|
7
|
+
export * from './GradientMask.types';
|
package/tsconfig.json
ADDED