From cc5af93279bd6f13569daac5f9d83c4ef8c0e419 Mon Sep 17 00:00:00 2001 From: bang9 Date: Sat, 26 Feb 2022 07:51:20 +0900 Subject: [PATCH] feat(foundation): implement gesture to slide type modal --- .../component/GroupChannelListChannelMenu.tsx | 13 +- .../GroupChannelListTypeSelector.tsx | 2 +- .../src/ui/ActionMenu/index.tsx | 42 +++--- .../src/ui/Alert/index.tsx | 5 +- .../src/ui/Modal/index.tsx | 140 +++++++++++++++--- sample/stories/Dialog.stories.tsx | 10 +- 6 files changed, 161 insertions(+), 51 deletions(-) diff --git a/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListChannelMenu.tsx b/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListChannelMenu.tsx index 299b8ac53..56ceb7710 100644 --- a/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListChannelMenu.tsx +++ b/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListChannelMenu.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { ActionMenu } from '@sendbird/uikit-react-native-foundation'; @@ -12,10 +12,17 @@ const GroupChannelListChannelMenu: React.FC { + if (channelMenu.selectedChannel) setVisible(true); + }, [channelMenu.selectedChannel]); + return ( setVisible(false)} + onDismiss={channelMenu.selectChannel} title={ channelMenu.selectedChannel && LABEL.GROUP_CHANNEL_LIST.CHANNEL_MENU.TITLE(currentUser?.userId ?? '', channelMenu.selectedChannel) diff --git a/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListTypeSelector.tsx b/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListTypeSelector.tsx index 534fee987..49260f038 100644 --- a/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListTypeSelector.tsx +++ b/packages/uikit-react-native-core/src/domain/groupChannelList/component/GroupChannelListTypeSelector.tsx @@ -76,7 +76,7 @@ const GroupChannelListTypeSelector: React.FC + {renderHeader()} ); diff --git a/packages/uikit-react-native-foundation/src/ui/ActionMenu/index.tsx b/packages/uikit-react-native-foundation/src/ui/ActionMenu/index.tsx index 2e626d999..71ff6db4b 100644 --- a/packages/uikit-react-native-foundation/src/ui/ActionMenu/index.tsx +++ b/packages/uikit-react-native-foundation/src/ui/ActionMenu/index.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { ActivityIndicator, Pressable, View } from 'react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; import createStyleSheet from '../../styles/createStyleSheet'; import useHeaderStyle from '../../styles/useHeaderStyle'; @@ -24,19 +24,17 @@ type Props = { const ActionMenu: React.FC = ({ visible, onHide, onError, onDismiss, title, items }) => { const { statusBarTranslucent } = useHeaderStyle(); const { colors } = useUIKitTheme(); - const [pending, setPending] = useState(false); - - const _onHide = () => { - if (!pending) onHide(); - }; + // const [pending, setPending] = useState(false); + // const _onHide = () => { + // if (!pending) onHide(); + // }; return ( @@ -45,17 +43,18 @@ const ActionMenu: React.FC = ({ visible, onHide, onError, onDismiss, titl h1 color={colors.ui.dialog.default.none.text} numberOfLines={1} - style={{ maxWidth: pending ? '86%' : '100%' }} + style={{ flex: 1 }} + // style={{ maxWidth: pending ? '86%' : '100%' }} > {title} - {pending && ( + {/*{pending && ( - )} + )}*/} {items.map(({ title, onPress }, index) => { @@ -63,17 +62,24 @@ const ActionMenu: React.FC = ({ visible, onHide, onError, onDismiss, titl { - setPending(true); try { - await onPress?.(); - onHide(); - } catch (e: unknown) { + onPress?.(); + } catch (e) { onError?.(e); } finally { - setPending(false); + onHide(); } + // setPending(true); + // try { + // await onPress?.(); + // onHide(); + // } catch (e: unknown) { + // onError?.(e); + // } finally { + // setPending(false); + // } }} > diff --git a/packages/uikit-react-native-foundation/src/ui/Alert/index.tsx b/packages/uikit-react-native-foundation/src/ui/Alert/index.tsx index 194c5ed94..2a090a444 100644 --- a/packages/uikit-react-native-foundation/src/ui/Alert/index.tsx +++ b/packages/uikit-react-native-foundation/src/ui/Alert/index.tsx @@ -18,10 +18,10 @@ export type AlertItem = { type Props = { visible: boolean; onHide: () => void; + onDismiss?: () => void; title: AlertItem['title']; message: AlertItem['message']; buttons: AlertItem['buttons']; - onDismiss?: () => void; }; const Alert: React.FC = ({ onDismiss, @@ -36,11 +36,10 @@ const Alert: React.FC = ({ return ( diff --git a/packages/uikit-react-native-foundation/src/ui/Modal/index.tsx b/packages/uikit-react-native-foundation/src/ui/Modal/index.tsx index aaa41e70c..ae8d6b2ab 100644 --- a/packages/uikit-react-native-foundation/src/ui/Modal/index.tsx +++ b/packages/uikit-react-native-foundation/src/ui/Modal/index.tsx @@ -1,64 +1,166 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { + Animated, + Dimensions, ModalProps, + PanResponder, Platform, + Pressable, Modal as RNModal, StyleProp, StyleSheet, TouchableWithoutFeedback, - View, ViewStyle, } from 'react-native'; import createStyleSheet from '../../styles/createStyleSheet'; import useUIKitTheme from '../../theme/useUIKitTheme'; -type Props = { backgroundStyle?: StyleProp; onPressBackground?: () => void } & ModalProps; +type Props = { type?: 'slide' | 'fade'; onClose: () => void; backgroundStyle?: StyleProp } & Omit< + ModalProps, + 'animationType' | 'onRequestClose' +>; + +/** + * Modal Open: Triggered by Modal.props.visible state changed to true + * - visible true -> modalVisible true -> animation start + * + * Modal Close: Triggered by Modal.props.onClose() call + * - Modal.props.onClose() -> visible false -> animation start -> modalVisible false + * */ const Modal: React.FC = ({ children, + onClose, backgroundStyle, - onPressBackground, onDismiss, + type = 'fade', visible = false, ...props }) => { const { palette } = useUIKitTheme(); + const { content, backdrop, showTransition, hideTransition } = useModalAnimation(type); + const panResponder = useModalPanResponder(type, content.translateY, showTransition, onClose); + + const [modalVisible, setModalVisible] = useState(false); + const showAction = () => setModalVisible(true); + const hideAction = () => hideTransition(() => setModalVisible(false)); + + useEffect(() => { + if (visible) showAction(); + else hideAction(); + }, [visible]); - useOnDismiss(visible, onDismiss); + useOnDismiss(modalVisible, onDismiss); return ( showTransition()} onDismiss={onDismiss} supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']} - animationType={'fade'} + animationType={'none'} {...props} > - - + + - - {children} - + + {/* NOTE: https://github.com/facebook/react-native/issues/14295 */} + {children} + ); }; +const useModalPanResponder = ( + type: 'slide' | 'fade', + translateY: Animated.Value, + show: () => void, + hide: () => void, +) => { + if (type === 'fade') return { panHandlers: {} }; + return React.useRef( + PanResponder.create({ + onMoveShouldSetPanResponderCapture: (_, { dy }) => dy > 8, + // @ts-ignore + onPanResponderGrant: () => translateY.setOffset(translateY.__getValue()), + onPanResponderMove: Animated.event([null, { dy: translateY }], { useNativeDriver: false }), + onPanResponderRelease: (_, { dy, vy }) => { + const isHideGesture = dy > 125 || (dy > 0 && vy > 0.1); + if (isHideGesture) hide(); + else show(); + }, + }), + ).current; +}; + +const useModalAnimation = (type: 'slide' | 'fade') => { + const initialY = type === 'slide' ? Dimensions.get('window').height : 0; + const baseAnimationVal = useRef(new Animated.Value(0)).current; + const baseTranslateVal = useRef(new Animated.Value(initialY)).current; + + const content = { + opacity: baseAnimationVal.interpolate({ + inputRange: [0, 1], + outputRange: [type === 'slide' ? 1 : 0, 1], + }), + translateY: baseTranslateVal, + }; + const backdrop = { + opacity: baseAnimationVal.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }), + }; + const createTransition = (toValue: 0 | 1) => { + const config = { duration: 250, useNativeDriver: false }; + return Animated.parallel([ + Animated.timing(baseAnimationVal, { toValue, ...config }), + Animated.timing(baseTranslateVal, { toValue: toValue === 0 ? initialY : 0, ...config }), + ]).start; + }; + return { + content, + backdrop, + showTransition: createTransition(1), + hideTransition: createTransition(0), + }; +}; + // NOTE: onDismiss is supports iOS only const useOnDismiss = (visible: boolean, onDismiss?: () => void) => { - const prevVisibleState = useRef(false); + const prevVisible = usePrevProp(visible); useEffect(() => { if (Platform.OS === 'ios') return; - if (prevVisibleState.current && !visible) onDismiss?.(); - prevVisibleState.current = visible; - }, [visible]); + if (prevVisible && !visible) onDismiss?.(); + }, [prevVisible, visible]); +}; + +const usePrevProp = (prop: T) => { + const prev = useRef(prop); + const curr = useRef(prop); + useEffect(() => { + prev.current = curr.current; + curr.current = prop; + }); + return prev.current; }; const styles = createStyleSheet({ - background: { - flex: 1, - }, + background: { flex: 1 }, }); export default Modal; diff --git a/sample/stories/Dialog.stories.tsx b/sample/stories/Dialog.stories.tsx index ec1dfd627..c3b5bf084 100644 --- a/sample/stories/Dialog.stories.tsx +++ b/sample/stories/Dialog.stories.tsx @@ -38,12 +38,8 @@ const WrappedActionMenu: React.FC = () => { items: [ { title: 'Loooooooooooong ActionMenu button title', onPress: () => {} }, { - title: 'Close after 3 seconds', - onPress: () => { - return new Promise((resolve) => { - setTimeout(() => resolve(0), 3000); - }); - }, + title: 'Close', + onPress: () => {}, }, ], }) @@ -54,7 +50,7 @@ const WrappedActionMenu: React.FC = () => { title={'Open multiple times (Queued)'} onPress={() => openMenu({ - title: 'Action Menu', + title: 'Action Menu Title title title title title title', items: [ { title: 'Open menu 2 times',