Skip to content

Commit

Permalink
Implement Floating Action Button (#67)
Browse files Browse the repository at this point in the history
  • Loading branch information
Juyeong-Byeon committed Sep 19, 2021
1 parent 4592a6e commit ba9009e
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 1 deletion.
133 changes: 133 additions & 0 deletions main/FAB/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {Animated, Easing, StyleProp, View, ViewStyle} from 'react-native';
import {ButtonSize, IconButton} from '../IconButton';
import {DoobooTheme, withTheme} from '../theme';
import {Icon, IconName} from '../Icon';
import React, {ReactElement, useLayoutEffect, useMemo, useRef} from 'react';

import styled from '@emotion/native';

export const StyledIcon = styled(Icon)`
color: ${({theme}) => theme.textContrast};
`;
export interface FABItem {
icon: IconName;
id: string;
}

export interface FABProps<Item extends FABItem> {
isActive: boolean;
FABItems: Item[];
onPressFAB: () => void;
onPressFABItem: (item?: Item) => void;
renderFAB?: () => ReactElement;
renderFABItem?: (item: Item, idx: number) => ReactElement;
buttonSize: ButtonSize;
iconSize?: number;
style?: StyleProp<ViewStyle>;
buttonWrapperStyle?: StyleProp<ViewStyle>;
}

function FloatingActionButtons<Item extends FABItem = FABItem>({
isActive,
FABItems,
onPressFAB,
onPressFABItem,
renderFAB,
renderFABItem,
buttonSize = 'large',
iconSize = 24,
style,
buttonWrapperStyle,
}: FABProps<Item> & {
theme: DoobooTheme;
}): ReactElement {
const spinValue = useRef(new Animated.Value(0));
const positionValue = useRef(new Animated.Value(0));

useLayoutEffect(() => {
const config = {
toValue: isActive ? 1 : 0,
duration: 300,
easing: Easing.linear,
useNativeDriver: true,
};

Animated.parallel([
Animated.timing(spinValue.current, config),
Animated.timing(positionValue.current, config),
]).start();
}, [isActive]);

const rotate = spinValue.current.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '45deg'],
});

const offsets = useMemo(
() =>
FABItems?.map((_, idx) =>
positionValue.current.interpolate({
inputRange: [0, 1],
outputRange: ['0%', `-${(idx + 1) * 80}%`],
}),
),
[FABItems],
);

return (
<View
style={[
{
position: 'absolute',
right: 10,
bottom: 10,
zIndex: 999,
flex: 1,
},
style,
]}>
{FABItems.map((item, idx) => {
const {id, icon} = item;

return (
<Animated.View
key={id}
style={[
{
margin: 10,
position: 'absolute',
transform: [{translateY: offsets[idx]}],
},
buttonWrapperStyle,
]}>
{renderFABItem ? (
renderFABItem(item, idx)
) : (
<IconButton
testID={id}
size={buttonSize}
icon={<StyledIcon size={iconSize} name={icon} />}
onPress={() => onPressFABItem(item)}
/>
)}
</Animated.View>
);
})}
<Animated.View
style={[{transform: [{rotate}], margin: 10}, buttonWrapperStyle]}>
{renderFAB ? (
renderFAB()
) : (
<IconButton
testID={'main_fab'}
size={buttonSize}
icon={<StyledIcon size={iconSize} name="add-light" />}
onPress={onPressFAB}
/>
)}
</Animated.View>
</View>
);
}

export const FAB = withTheme(FloatingActionButtons);
3 changes: 2 additions & 1 deletion main/IconButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ type Styles = {
};

type ButtonType = 'primary' | 'secondary' | 'danger' | 'warning' | 'info';
type ButtonSize = 'small' | 'medium' | 'large';

export type ButtonSize = 'small' | 'medium' | 'large';

const ButtonContainer = styled(ButtonWrapper)<{
type: ButtonType;
Expand Down
53 changes: 53 additions & 0 deletions main/__tests__/FAB.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {FAB, FABItem, FABProps} from '../../main';
import React, {ReactElement} from 'react';
import {fireEvent, render} from '@testing-library/react-native';

import {View} from 'react-native';
import {createComponent} from '../../test/testUtils';

const Component = (props: FABProps<FABItem>): ReactElement =>
createComponent(<FAB {...props} />);

describe('[FAB]', () => {
it('should render', async () => {
let count = 0;
let item: FABItem = {icon: 'bell-solid', id: 'item1'};
let resItem: FABItem;

const {getByTestId} = render(
Component({
FABItems: [item],
isActive: true,
buttonSize: 'large',
onPressFABItem: (item) => {
count += 1;
resItem = item;
},
onPressFAB: () => {},
}),
);

expect(count).toBe(0);
fireEvent.press(getByTestId('item1'));
expect(count).toBe(1);
expect(resItem.id).toBe(item.id);
});

it('should render customFAB', async () => {
const testingLib = render(
Component({
FABItems: [{icon: 'bell-solid', id: 'item1'}],
isActive: true,
buttonSize: 'large',
onPressFABItem: (item1) => {},
onPressFAB: () => {},
renderFAB: () => <View />,
renderFABItem: (item, idx) => <View />,
}),
);

const json = testingLib.toJSON();

expect(json).toBeTruthy();
});
});
1 change: 1 addition & 0 deletions main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './StatusBarBrightness';
export * from './SwitchToggle';
export * from './theme';
export * from './SelectBox';
export * from './FAB';
45 changes: 45 additions & 0 deletions stories/dooboo-ui/FloatingActionButton/FABStory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React, {FC, useState} from 'react';
import {SafeAreaView, View} from 'react-native';

import {FAB} from '../../../main';
import styled from '@emotion/native';
import {useFonts} from 'expo-font';
import {withTheme} from '../../../main/theme/ThemeProvider';

const StoryContainer = styled.View`
flex: 1;
align-self: stretch;
background-color: ${({theme}) => theme.background};
`;

const FABContainer: FC = () => {
const [active, setActive] = useState<boolean>(false);

const [fontsLoaded] = useFonts({
IcoMoon: require('../../../main/Icon/doobooui.ttf'),
});

if (!fontsLoaded) return <View />;

return (
<StoryContainer>
<SafeAreaView style={{display: 'flex', width: '100%', height: '100%'}}>
<FAB
buttonSize="medium"
isActive={active}
onPressFAB={() => setActive((prev) => !prev)}
FABItems={[
{id: 'search', icon: 'home-light'},
{id: 'like', icon: 'like-light'},
]}
onPressFABItem={(item) => {
console.log(item);
}}
iconSize={25}
/>
</SafeAreaView>
</StoryContainer>
);
};

export const FABStory = withTheme(FABContainer);
35 changes: 35 additions & 0 deletions stories/dooboo-ui/FloatingActionButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, {ReactElement} from 'react';

import {ContainerDeco} from '../../../storybook/decorators';
import {FABStory} from './FABStory';
import {ThemeProvider} from '../../../main/theme';
import {storiesOf} from '@storybook/react-native';

/**
* Below are stories for web
*/
export default {
title: 'FAB',
};

export const toStorybook = (): ReactElement => <FABStory />;

toStorybook.story = {
name: 'FAB',
};

/**
* Below are stories for app
*/
storiesOf('FAB', module)
.addDecorator(ContainerDeco)
.add('FAB - light', () => (
<ThemeProvider initialThemeType="light">
<FABStory />
</ThemeProvider>
))
.add('FAB - dark', () => (
<ThemeProvider initialThemeType="dark">
<FABStory />
</ThemeProvider>
));
1 change: 1 addition & 0 deletions stories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ import './dooboo-ui/AccordionStories';
import './packages/AlertDialogStories';
import './packages/CalendarCarouselStories';
import './packages/PinzoomStories';
import './dooboo-ui/FloatingActionButton';

0 comments on commit ba9009e

Please sign in to comment.