Skip to content

Commit e1c795e

Browse files
lukewalczaksatya164
authored andcommitted
feat: add FABGroup component for FAB with speed dial (#218)
Fixes #210
1 parent 0feb270 commit e1c795e

File tree

8 files changed

+681
-93
lines changed

8 files changed

+681
-93
lines changed

docs/assets/screenshots/fab-group.png

20.3 KB
Loading

example/src/FABExample.js

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,57 @@
22

33
import * as React from 'react';
44
import { View, StyleSheet } from 'react-native';
5-
import { Colors, FAB, withTheme } from 'react-native-paper';
5+
import { Colors, FAB, FABGroup, withTheme } from 'react-native-paper';
66
import type { Theme } from 'react-native-paper/types';
77

88
type Props = {
99
theme: Theme,
1010
};
1111

12-
class ButtonExample extends React.Component<Props> {
12+
type State = {
13+
open: boolean,
14+
};
15+
16+
class ButtonExample extends React.Component<Props, State> {
1317
static title = 'Floating Action Button';
1418

15-
_handlePress = () => {};
19+
state = {
20+
open: false,
21+
};
1622

1723
render() {
1824
const {
1925
theme: {
2026
colors: { background },
2127
},
2228
} = this.props;
29+
2330
return (
2431
<View style={[styles.container, { backgroundColor: background }]}>
2532
<View style={styles.row}>
26-
<FAB
27-
small
28-
icon="add"
29-
style={styles.fab}
30-
onPress={this._handlePress}
31-
/>
32-
<FAB icon="favorite" style={styles.fab} onPress={this._handlePress} />
33+
<FAB small icon="add" style={styles.fab} onPress={() => {}} />
34+
<FAB icon="favorite" style={styles.fab} onPress={() => {}} />
3335
<FAB
3436
icon="done"
3537
label="Extended FAB"
3638
style={styles.fab}
37-
onPress={this._handlePress}
39+
onPress={() => {}}
40+
/>
41+
<FABGroup
42+
open={this.state.open}
43+
icon={this.state.open ? 'today' : 'add'}
44+
actions={[
45+
{ icon: 'add', onPress: () => {} },
46+
{ icon: 'star', label: 'Star', onPress: () => {} },
47+
{ icon: 'email', label: 'Email', onPress: () => {} },
48+
{ icon: 'notifications', label: 'Remind', onPress: () => {} },
49+
]}
50+
onStateChange={({ open }) => this.setState({ open })}
51+
onPress={() => {
52+
if (this.state.open) {
53+
// do something if the speed dial is open
54+
}
55+
}}
3856
/>
3957
</View>
4058
</View>

src/components/CrossFadeIcon.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/* @flow */
2+
3+
import * as React from 'react';
4+
import { Animated, StyleSheet, View } from 'react-native';
5+
import { polyfill } from 'react-lifecycles-compat';
6+
import Icon, { isValidIcon, isEqualIcon } from './Icon';
7+
import type { IconSource } from './Icon';
8+
9+
type Props = {
10+
/**
11+
* Icon to display for the `CrossFadeIcon`.
12+
*/
13+
source: IconSource,
14+
/**
15+
* Color of the icon.
16+
*/
17+
color: string,
18+
/**
19+
* Size of the icon.
20+
*/
21+
size: number,
22+
};
23+
24+
type State = {
25+
currentIcon: IconSource,
26+
previousIcon: ?IconSource,
27+
fade: Animated.Value,
28+
};
29+
30+
class CrossFadeIcon extends React.Component<Props, State> {
31+
static getDerivedStateFromProps(nextProps: Props, nextState: State) {
32+
if (nextState.currentIcon === nextProps.source) {
33+
return null;
34+
}
35+
36+
return {
37+
currentIcon: nextProps.source,
38+
previousIcon: nextState.currentIcon,
39+
};
40+
}
41+
42+
state = {
43+
currentIcon: this.props.source,
44+
previousIcon: null,
45+
fade: new Animated.Value(1),
46+
};
47+
48+
componentDidUpdate(prevProps: Props, prevState: State) {
49+
const { previousIcon } = this.state;
50+
51+
if (
52+
!isValidIcon(previousIcon) ||
53+
isEqualIcon(previousIcon, prevState.previousIcon)
54+
) {
55+
return;
56+
}
57+
58+
this.state.fade.setValue(1);
59+
60+
Animated.timing(this.state.fade, {
61+
duration: 200,
62+
toValue: 0,
63+
}).start();
64+
}
65+
66+
render() {
67+
const { color, size } = this.props;
68+
const opacityPrev = this.state.fade;
69+
const opacityNext = this.state.previousIcon
70+
? this.state.fade.interpolate({
71+
inputRange: [0, 1],
72+
outputRange: [1, 0],
73+
})
74+
: 1;
75+
76+
const rotatePrev = this.state.fade.interpolate({
77+
inputRange: [0, 1],
78+
outputRange: ['-90deg', '0deg'],
79+
});
80+
81+
const rotateNext = this.state.previousIcon
82+
? this.state.fade.interpolate({
83+
inputRange: [0, 1],
84+
outputRange: ['0deg', '-180deg'],
85+
})
86+
: '0deg';
87+
88+
return (
89+
<View
90+
style={[
91+
styles.content,
92+
{
93+
height: size,
94+
width: size,
95+
},
96+
]}
97+
>
98+
{this.state.previousIcon ? (
99+
<Animated.View
100+
style={[
101+
styles.icon,
102+
{
103+
opacity: opacityPrev,
104+
transform: [{ rotate: rotatePrev }],
105+
},
106+
]}
107+
>
108+
<Icon name={this.state.previousIcon} size={size} color={color} />
109+
</Animated.View>
110+
) : null}
111+
<Animated.View
112+
style={[
113+
styles.icon,
114+
{
115+
opacity: opacityNext,
116+
transform: [{ rotate: rotateNext }],
117+
},
118+
]}
119+
>
120+
<Icon name={this.state.currentIcon} size={size} color={color} />
121+
</Animated.View>
122+
</View>
123+
);
124+
}
125+
}
126+
127+
polyfill(CrossFadeIcon);
128+
129+
export default CrossFadeIcon;
130+
131+
const styles = StyleSheet.create({
132+
content: {
133+
alignItems: 'center',
134+
justifyContent: 'center',
135+
},
136+
icon: {
137+
position: 'absolute',
138+
top: 0,
139+
left: 0,
140+
right: 0,
141+
bottom: 0,
142+
},
143+
});

src/components/FAB.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22

33
import color from 'color';
44
import * as React from 'react';
5-
import { StyleSheet, View } from 'react-native';
5+
import { StyleSheet, View, Animated } from 'react-native';
66
import Paper from './Paper';
7-
import Icon from './Icon';
7+
import CrossFadeIcon from './CrossFadeIcon';
88
import Text from './Typography/Text';
99
import TouchableRipple from './TouchableRipple';
1010
import { white } from '../styles/colors';
1111
import withTheme from '../core/withTheme';
1212
import type { Theme } from '../types';
1313
import type { IconSource } from './Icon';
1414

15+
const AnimatedPaper = Animated.createAnimatedComponent(Paper);
16+
1517
type Props = {
1618
/**
1719
* Icon to display for the `FAB`.
@@ -80,7 +82,8 @@ class FAB extends React.Component<Props> {
8082
...rest
8183
} = this.props;
8284

83-
const backgroundColor = theme.colors.accent;
85+
const { backgroundColor = theme.colors.accent } =
86+
StyleSheet.flatten(style) || {};
8487
const isDark =
8588
typeof dark === 'boolean' ? dark : !color(backgroundColor).light();
8689
const textColor = iconColor || (isDark ? white : 'rgba(0, 0, 0, .54)');
@@ -90,7 +93,7 @@ class FAB extends React.Component<Props> {
9093
.string();
9194

9295
return (
93-
<Paper
96+
<AnimatedPaper
9497
{...rest}
9598
style={[{ backgroundColor, elevation: 12 }, styles.container, style]}
9699
>
@@ -105,8 +108,9 @@ class FAB extends React.Component<Props> {
105108
styles.content,
106109
label ? styles.extended : small ? styles.small : styles.standard,
107110
]}
111+
pointerEvents="none"
108112
>
109-
<Icon name={icon} size={24} color={textColor} />
113+
<CrossFadeIcon source={icon} size={24} color={textColor} />
110114
{label ? (
111115
<Text
112116
style={[
@@ -119,7 +123,7 @@ class FAB extends React.Component<Props> {
119123
) : null}
120124
</View>
121125
</TouchableRipple>
122-
</Paper>
126+
</AnimatedPaper>
123127
);
124128
}
125129
}

0 commit comments

Comments
 (0)