1 | import * as React from 'react';
|
2 | import {
|
3 | Animated,
|
4 | Easing,
|
5 | Platform,
|
6 | StyleProp,
|
7 | StyleSheet,
|
8 | View,
|
9 | ViewStyle,
|
10 | } from 'react-native';
|
11 | import { withTheme } from '../core/theming';
|
12 |
|
13 | type Props = React.ComponentPropsWithRef<typeof View> & {
|
14 | |
15 |
|
16 |
|
17 | animating?: boolean;
|
18 | |
19 |
|
20 |
|
21 | color?: string;
|
22 | |
23 |
|
24 |
|
25 | size?: 'small' | 'large' | number;
|
26 | |
27 |
|
28 |
|
29 | hidesWhenStopped?: boolean;
|
30 | style?: StyleProp<ViewStyle>;
|
31 | |
32 |
|
33 |
|
34 | theme: ReactNativePaper.Theme;
|
35 | };
|
36 |
|
37 | const DURATION = 2400;
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 |
|
52 |
|
53 |
|
54 |
|
55 |
|
56 |
|
57 |
|
58 |
|
59 | const ActivityIndicator = ({
|
60 | animating = true,
|
61 | color: indicatorColor,
|
62 | hidesWhenStopped = true,
|
63 | size: indicatorSize = 'small',
|
64 | style,
|
65 | theme,
|
66 | ...rest
|
67 | }: Props) => {
|
68 | const { current: timer } = React.useRef<Animated.Value>(
|
69 | new Animated.Value(0)
|
70 | );
|
71 | const { current: fade } = React.useRef<Animated.Value>(
|
72 | new Animated.Value(!animating && hidesWhenStopped ? 0 : 1)
|
73 | );
|
74 |
|
75 | const rotation = React.useRef<Animated.CompositeAnimation | undefined>(
|
76 | undefined
|
77 | );
|
78 |
|
79 | const {
|
80 | animation: { scale },
|
81 | } = theme;
|
82 |
|
83 | const startRotation = React.useCallback(() => {
|
84 |
|
85 | Animated.timing(fade, {
|
86 | duration: 200 * scale,
|
87 | toValue: 1,
|
88 | isInteraction: false,
|
89 | useNativeDriver: true,
|
90 | }).start();
|
91 |
|
92 |
|
93 | if (rotation.current) {
|
94 | timer.setValue(0);
|
95 |
|
96 | Animated.loop(rotation.current).start();
|
97 | }
|
98 | }, [scale, fade, timer]);
|
99 |
|
100 | const stopRotation = () => {
|
101 | if (rotation.current) {
|
102 | rotation.current.stop();
|
103 | }
|
104 | };
|
105 |
|
106 | React.useEffect(() => {
|
107 | if (rotation.current === undefined) {
|
108 |
|
109 | rotation.current = Animated.timing(timer, {
|
110 | duration: DURATION,
|
111 | easing: Easing.linear,
|
112 |
|
113 | useNativeDriver: Platform.OS !== 'web',
|
114 | toValue: 1,
|
115 | isInteraction: false,
|
116 | });
|
117 | }
|
118 |
|
119 | if (animating) {
|
120 | startRotation();
|
121 | } else if (hidesWhenStopped) {
|
122 |
|
123 | Animated.timing(fade, {
|
124 | duration: 200 * scale,
|
125 | toValue: 0,
|
126 | useNativeDriver: true,
|
127 | isInteraction: false,
|
128 | }).start(stopRotation);
|
129 | } else {
|
130 | stopRotation();
|
131 | }
|
132 | }, [animating, fade, hidesWhenStopped, startRotation, scale, timer]);
|
133 |
|
134 | const color = indicatorColor || theme.colors.primary;
|
135 | const size =
|
136 | typeof indicatorSize === 'string'
|
137 | ? indicatorSize === 'small'
|
138 | ? 24
|
139 | : 48
|
140 | : indicatorSize
|
141 | ? indicatorSize
|
142 | : 24;
|
143 |
|
144 | const frames = (60 * DURATION) / 1000;
|
145 | const easing = Easing.bezier(0.4, 0.0, 0.7, 1.0);
|
146 | const containerStyle = {
|
147 | width: size,
|
148 | height: size / 2,
|
149 | overflow: 'hidden' as const,
|
150 | };
|
151 |
|
152 | return (
|
153 | <View
|
154 | style={[styles.container, style]}
|
155 | {...rest}
|
156 | accessible
|
157 | accessibilityRole="progressbar"
|
158 | accessibilityState={{ busy: animating }}
|
159 | >
|
160 | <Animated.View
|
161 | style={[{ width: size, height: size, opacity: fade }]}
|
162 | collapsable={false}
|
163 | >
|
164 | {[0, 1].map((index) => {
|
165 |
|
166 | const inputRange = Array.from(
|
167 | new Array(frames),
|
168 | (_, frameIndex) => frameIndex / (frames - 1)
|
169 | );
|
170 | const outputRange = Array.from(new Array(frames), (_, frameIndex) => {
|
171 | let progress = (2 * frameIndex) / (frames - 1);
|
172 | const rotation = index ? +(360 - 15) : -(180 - 15);
|
173 |
|
174 | if (progress > 1.0) {
|
175 | progress = 2.0 - progress;
|
176 | }
|
177 |
|
178 | const direction = index ? -1 : +1;
|
179 |
|
180 | return `${direction * (180 - 30) * easing(progress) + rotation}deg`;
|
181 | });
|
182 |
|
183 | const layerStyle = {
|
184 | width: size,
|
185 | height: size,
|
186 | transform: [
|
187 | {
|
188 | rotate: timer.interpolate({
|
189 | inputRange: [0, 1],
|
190 | outputRange: [`${0 + 30 + 15}deg`, `${2 * 360 + 30 + 15}deg`],
|
191 | }),
|
192 | },
|
193 | ],
|
194 | };
|
195 |
|
196 | const viewportStyle = {
|
197 | width: size,
|
198 | height: size,
|
199 | transform: [
|
200 | {
|
201 | translateY: index ? -size / 2 : 0,
|
202 | },
|
203 | {
|
204 | rotate: timer.interpolate({ inputRange, outputRange }),
|
205 | },
|
206 | ],
|
207 | };
|
208 |
|
209 | const offsetStyle = index ? { top: size / 2 } : null;
|
210 |
|
211 | const lineStyle = {
|
212 | width: size,
|
213 | height: size,
|
214 | borderColor: color,
|
215 | borderWidth: size / 10,
|
216 | borderRadius: size / 2,
|
217 | };
|
218 |
|
219 | return (
|
220 | <Animated.View key={index} style={[styles.layer]}>
|
221 | <Animated.View style={layerStyle}>
|
222 | <Animated.View
|
223 | style={[containerStyle, offsetStyle]}
|
224 | collapsable={false}
|
225 | >
|
226 | <Animated.View style={viewportStyle}>
|
227 | <Animated.View style={containerStyle} collapsable={false}>
|
228 | <Animated.View style={lineStyle} />
|
229 | </Animated.View>
|
230 | </Animated.View>
|
231 | </Animated.View>
|
232 | </Animated.View>
|
233 | </Animated.View>
|
234 | );
|
235 | })}
|
236 | </Animated.View>
|
237 | </View>
|
238 | );
|
239 | };
|
240 |
|
241 | const styles = StyleSheet.create({
|
242 | container: {
|
243 | justifyContent: 'center',
|
244 | alignItems: 'center',
|
245 | },
|
246 |
|
247 | layer: {
|
248 | ...StyleSheet.absoluteFillObject,
|
249 |
|
250 | justifyContent: 'center',
|
251 | alignItems: 'center',
|
252 | },
|
253 | });
|
254 |
|
255 | export default withTheme(ActivityIndicator);
|