diff options
author | Ivan Chen <ivan@tagg.id> | 2021-05-21 18:41:31 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-21 18:41:31 -0400 |
commit | 70ae8e59b0b3ee8b2516a4220d21f3e3afdc8776 (patch) | |
tree | 4c4c0581038738885fb0ad05e73cec1070a9aa56 /src | |
parent | 87333873c8e47bccd99198a974a40df57619df22 (diff) | |
parent | 6f571cb92a2948952a7fa5ea0843bdbc4affde64 (diff) |
Merge pull request #437 from grusuTagg/tma854-tagging-draggable-component
[TMA854] Tagging Draggable Component
Diffstat (limited to 'src')
-rw-r--r-- | src/assets/icons/tag-icon.png | bin | 0 -> 1708 bytes | |||
-rw-r--r-- | src/assets/images/Tagg-Triangle.png | bin | 0 -> 561 bytes | |||
-rw-r--r-- | src/assets/images/draggableX.png | bin | 0 -> 1780 bytes | |||
-rw-r--r-- | src/assets/images/tagg-rectangle.png | bin | 0 -> 521 bytes | |||
-rw-r--r-- | src/assets/images/tagg-tip.png | bin | 0 -> 233 bytes | |||
-rw-r--r-- | src/assets/images/tagg-x-button.png | bin | 0 -> 549 bytes | |||
-rw-r--r-- | src/components/common/Draggable.tsx | 364 | ||||
-rw-r--r-- | src/components/taggs/TaggDraggable.tsx | 129 | ||||
-rw-r--r-- | src/screens/profile/CaptionScreen.tsx | 68 |
9 files changed, 560 insertions, 1 deletions
diff --git a/src/assets/icons/tag-icon.png b/src/assets/icons/tag-icon.png Binary files differnew file mode 100644 index 00000000..9111b036 --- /dev/null +++ b/src/assets/icons/tag-icon.png diff --git a/src/assets/images/Tagg-Triangle.png b/src/assets/images/Tagg-Triangle.png Binary files differnew file mode 100644 index 00000000..dfe97282 --- /dev/null +++ b/src/assets/images/Tagg-Triangle.png diff --git a/src/assets/images/draggableX.png b/src/assets/images/draggableX.png Binary files differnew file mode 100644 index 00000000..b99c5dfc --- /dev/null +++ b/src/assets/images/draggableX.png diff --git a/src/assets/images/tagg-rectangle.png b/src/assets/images/tagg-rectangle.png Binary files differnew file mode 100644 index 00000000..24bfd67e --- /dev/null +++ b/src/assets/images/tagg-rectangle.png diff --git a/src/assets/images/tagg-tip.png b/src/assets/images/tagg-tip.png Binary files differnew file mode 100644 index 00000000..eb3c5bad --- /dev/null +++ b/src/assets/images/tagg-tip.png diff --git a/src/assets/images/tagg-x-button.png b/src/assets/images/tagg-x-button.png Binary files differnew file mode 100644 index 00000000..5ce3846d --- /dev/null +++ b/src/assets/images/tagg-x-button.png diff --git a/src/components/common/Draggable.tsx b/src/components/common/Draggable.tsx new file mode 100644 index 00000000..edd29b78 --- /dev/null +++ b/src/components/common/Draggable.tsx @@ -0,0 +1,364 @@ +/** + * * https://github.com/tongyy/react-native-draggable + * + */ + +import React from 'react'; +import { + Animated, + Dimensions, + GestureResponderEvent, + Image, + PanResponder, + PanResponderGestureState, + StyleProp, + StyleSheet, + Text, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native'; + +function clamp(number: number, min: number, max: number) { + return Math.max(min, Math.min(number, max)); +} + +interface IProps { + /**** props that should probably be removed in favor of "children" */ + renderText?: string; + isCircle?: boolean; + renderSize?: number; + imageSource?: number; + renderColor?: string; + /**** */ + children?: React.ReactNode; + shouldReverse?: boolean; + disabled?: boolean; + debug?: boolean; + animatedViewProps?: object; + touchableOpacityProps?: object; + onDrag?: ( + e: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => void; + onShortPressRelease?: (event: GestureResponderEvent) => void; + onDragRelease?: ( + e: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => void; + onLongPress?: (event: GestureResponderEvent) => void; + onPressIn?: (event: GestureResponderEvent) => void; + onPressOut?: (event: GestureResponderEvent) => void; + onRelease?: (event: GestureResponderEvent, wasDragging: boolean) => void; + onReverse?: () => {x: number; y: number}; + x?: number; + y?: number; + // z/elevation should be removed because it doesn't sync up visually and haptically + z?: number; + minX?: number; + minY?: number; + maxX?: number; + maxY?: number; + onDragStart?: () => void; +} + +export default function Draggable(props: IProps) { + const { + renderText, + isCircle, + renderSize, + imageSource, + renderColor, + children, + shouldReverse, + disabled, + debug, + animatedViewProps, + touchableOpacityProps, + onDrag, + onShortPressRelease, + onDragRelease, + onLongPress, + onPressIn, + onPressOut, + onRelease, + x, + y, + z, + minX, + minY, + maxX, + maxY, + onDragStart, + } = props; + + // The Animated object housing our xy value so that we can spring back + const pan = React.useRef(new Animated.ValueXY()); + // Always set to xy value of pan, would like to remove + const offsetFromStart = React.useRef({x: 0, y: 0}); + // Width/Height of Draggable (renderSize is arbitrary if children are passed in) + const childSize = React.useRef({x: renderSize, y: renderSize}); + // Top/Left/Right/Bottom location on screen from start of most recent touch + const startBounds = React.useRef({top: 0, bottom: 0, left: 0, right: 0}); + // Whether we're currently dragging or not + const isDragging = React.useRef(false); + + // const [zIndex, setZIndex] = React.useState(z); + const zIndex = z; + + const getBounds = React.useCallback(() => { + const left = x + offsetFromStart.current.x; + const top = y + offsetFromStart.current.y; + return { + left, + top, + right: left + childSize.current.x, + bottom: top + childSize.current.y, + }; + }, [x, y]); + + const shouldStartDrag = React.useCallback( + (gs) => { + return !disabled && (Math.abs(gs.dx) > 2 || Math.abs(gs.dy) > 2); + }, + [disabled], + ); + + const reversePosition = React.useCallback(() => { + Animated.spring(pan.current, { + toValue: {x: 0, y: 0}, + useNativeDriver: false, + }).start(); + }, [pan]); + + const onPanResponderRelease = React.useCallback( + (e: GestureResponderEvent, gestureState: PanResponderGestureState) => { + isDragging.current = false; + if (onDragRelease) { + onDragRelease(e, gestureState); + onRelease(e, true); + } + if (!shouldReverse) { + pan.current.flattenOffset(); + } else { + reversePosition(); + } + }, + [onDragRelease, shouldReverse, onRelease, reversePosition], + ); + + const onPanResponderGrant = React.useCallback( + (_: GestureResponderEvent) => { + startBounds.current = getBounds(); + isDragging.current = true; + if (!shouldReverse) { + pan.current.setOffset(offsetFromStart.current); + pan.current.setValue({x: 0, y: 0}); + } + onDragStart(); + }, + [getBounds, shouldReverse], + ); + + const handleOnDrag = React.useCallback( + (e: GestureResponderEvent, gestureState: PanResponderGestureState) => { + const {dx, dy} = gestureState; + const {top, right, left, bottom} = startBounds.current; + const far = 999999999; + const changeX = clamp( + dx, + Number.isFinite(minX) ? minX - left : -far, + Number.isFinite(maxX) ? maxX - right : far, + ); + const changeY = clamp( + dy, + Number.isFinite(minY) ? minY - top : -far, + Number.isFinite(maxY) ? maxY - bottom : far, + ); + pan.current.setValue({x: changeX, y: changeY}); + onDrag(e, gestureState); + }, + [maxX, maxY, minX, minY, onDrag], + ); + + const panResponder = React.useMemo(() => { + return PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => + shouldStartDrag(gestureState), + onMoveShouldSetPanResponderCapture: (_, gestureState) => + shouldStartDrag(gestureState), + onPanResponderGrant, + // onPanResponderStart, + onPanResponderMove: Animated.event([], { + // Typed incorrectly https://reactnative.dev/docs/panresponder + listener: handleOnDrag, + useNativeDriver: false, + }), + // onPanResponderRelease: (_) => { + // // console.log('end'); + // // setZIndex(1); + // }, + }); + }, [ + handleOnDrag, + onPanResponderGrant, + onPanResponderRelease, + shouldStartDrag, + ]); + + // TODO Figure out a way to destroy and remove offsetFromStart entirely + React.useEffect(() => { + const curPan = pan.current; // Using an instance to avoid losing the pointer before the cleanup + if (!shouldReverse) { + curPan.addListener((c) => (offsetFromStart.current = c)); + } + return () => { + // Typed incorrectly + curPan.removeAllListeners(); + }; + }, [shouldReverse]); + + const positionCss: StyleProp<ViewStyle> = React.useMemo(() => { + const Window = Dimensions.get('window'); + + return { + position: 'absolute', + top: 0, + left: 0, + width: Window.width, + height: Window.height, + elevation: zIndex, + zIndex: zIndex, + }; + }, [zIndex]); + + const dragItemCss = React.useMemo(() => { + const style: StyleProp<ViewStyle> = { + top: y, + left: x, + elevation: zIndex, + zIndex: zIndex, + }; + if (renderColor) { + style.backgroundColor = renderColor; + } + if (isCircle) { + style.borderRadius = renderSize; + } + + if (children) { + return { + ...style, + alignSelf: 'baseline', + }; + } + return { + ...style, + justifyContent: 'center', + width: renderSize, + height: renderSize, + }; + }, [children, isCircle, renderColor, renderSize, x, y, zIndex]); + + const touchableContent = React.useMemo(() => { + if (children) { + return children; + } else if (imageSource) { + return ( + <Image + style={{width: renderSize, height: renderSize}} + source={imageSource} + /> + ); + } else { + return <Text style={styles.text}>{renderText}</Text>; + } + }, [children, imageSource, renderSize, renderText]); + + const handleOnLayout = React.useCallback((event) => { + const {height, width} = event.nativeEvent.layout; + childSize.current = {x: width, y: height}; + }, []); + + // const handlePressOut = React.useCallback( + // (event: GestureResponderEvent) => { + // onPressOut(event); + // if (!isDragging.current) { + // onRelease(event, false); + // } + // }, + // [onPressOut, onRelease], + // ); + + const getDebugView = React.useCallback(() => { + const {width, height} = Dimensions.get('window'); + const far = 9999; + const constrained = minX || maxX || minY || maxY; + if (!constrained) { + return null; + } // could show other debug info here + const left = minX || -far; + const right = maxX ? width - maxX : -far; + const top = minY || -far; + const bottom = maxY ? height - maxY : -far; + return ( + <View + pointerEvents="box-none" + style={{left, right, top, bottom, ...styles.debugView}} + /> + ); + }, [maxX, maxY, minX, minY]); + + return ( + <View pointerEvents="box-none" style={positionCss}> + {debug && getDebugView()} + <Animated.View + pointerEvents="box-none" + {...animatedViewProps} + {...panResponder.panHandlers} + style={pan.current.getLayout()}> + <TouchableOpacity + {...touchableOpacityProps} + onLayout={handleOnLayout} + style={dragItemCss} + disabled={true} + onPress={onShortPressRelease} + onLongPress={onLongPress} + onPressIn={onPressIn} + onPressOut={onPressOut}> + {touchableContent} + </TouchableOpacity> + </Animated.View> + </View> + ); +} + +/***** Default props and types */ + +Draggable.defaultProps = { + renderText: '+', + renderSize: 36, + shouldReverse: false, + disabled: false, + debug: false, + onDrag: () => {}, + onShortPressRelease: () => {}, + onDragRelease: () => {}, + onLongPress: () => {}, + onPressIn: () => {}, + onPressOut: () => {}, + onRelease: () => {}, + x: 0, + y: 0, + z: 1, +}; + +const styles = StyleSheet.create({ + text: {color: '#fff', textAlign: 'center'}, + debugView: { + backgroundColor: '#ff000044', + position: 'absolute', + borderColor: '#fced0ecc', + borderWidth: 4, + }, +}); diff --git a/src/components/taggs/TaggDraggable.tsx b/src/components/taggs/TaggDraggable.tsx new file mode 100644 index 00000000..a6ffb1ef --- /dev/null +++ b/src/components/taggs/TaggDraggable.tsx @@ -0,0 +1,129 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {useEffect, useRef} from 'react'; +import { + Image, + StyleSheet, + Text, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; +import {useDispatch, useSelector} from 'react-redux'; +import {RootState} from 'src/store/rootReducer'; +import Avatar from '../../components/common/Avatar'; +import {ProfilePreviewType, ScreenType, UserType} from '../../types'; +import {normalize} from '../../utils'; +import {navigateToProfile} from '../../utils/users'; + +interface TaggDraggableProps { + taggedUser: ProfilePreviewType; + editingView: boolean; + deleteFromList: () => void; + setStart: Function; +} + +const TaggDraggable: React.FC<TaggDraggableProps> = ( + props: TaggDraggableProps, +) => { + const navigation = useNavigation(); + const dispatch = useDispatch(); + const state = useSelector((rs: RootState) => rs); + const {taggedUser, editingView, deleteFromList, setStart} = props; + let uriX = require('../../assets/images/draggableX.png'); + let uriTip = require('../../assets/images/Tagg-Triangle.png'); + + const draggableRef = useRef(null); + + useEffect(() => { + draggableRef.current.measure((width: number, height: number) => { + setStart([width, height]); + }); + }, []); + + const user: UserType = { + userId: taggedUser.id, + username: taggedUser.username, + }; + + return ( + <TouchableWithoutFeedback> + <View style={styles.container}> + <Image style={styles.imageTip} source={uriTip} /> + <TouchableOpacity + onPressIn={() => + navigateToProfile( + state, + dispatch, + navigation, + ScreenType.Profile, + user, + ) + } + disabled={editingView} + style={styles.content} + ref={draggableRef}> + <Avatar style={styles.avatar} uri={taggedUser.thumbnail_url} /> + <Text style={editingView ? styles.buttonTitle : styles.buttonTitleX}> + @{taggedUser.username} + </Text> + {editingView && ( + <TouchableOpacity + disabled={false} + onPressIn={deleteFromList} + style={styles.imageX}> + <Image style={styles.imageX} source={uriX} /> + </TouchableOpacity> + )} + </TouchableOpacity> + </View> + </TouchableWithoutFeedback> + ); +}; + +const styles = StyleSheet.create({ + imageTip: { + height: normalize(12), + aspectRatio: 12 / 8, + }, + container: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + buttonTitle: { + color: 'white', + fontSize: normalize(11), + fontWeight: '700', + lineHeight: normalize(13.13), + letterSpacing: normalize(0.6), + paddingHorizontal: '1%', + }, + buttonTitleX: { + color: 'white', + fontSize: normalize(11), + fontWeight: '700', + lineHeight: normalize(13.13), + letterSpacing: normalize(0.6), + paddingHorizontal: '1%', + }, + avatar: { + height: normalize(20), + width: normalize(20), + borderRadius: normalize(20) / 2, + }, + imageX: { + width: normalize(15), + height: normalize(15), + }, + content: { + justifyContent: 'space-evenly', + alignItems: 'center', + flexDirection: 'row', + borderRadius: 20, + paddingVertical: normalize(5), + paddingHorizontal: normalize(8), + backgroundColor: 'rgba(0, 0, 0, 0.8)', + }, +}); + +export default TaggDraggable; diff --git a/src/screens/profile/CaptionScreen.tsx b/src/screens/profile/CaptionScreen.tsx index a41abba6..8e972e07 100644 --- a/src/screens/profile/CaptionScreen.tsx +++ b/src/screens/profile/CaptionScreen.tsx @@ -1,6 +1,6 @@ import {RouteProp} from '@react-navigation/native'; import {StackNavigationProp} from '@react-navigation/stack'; -import React, {Fragment, useState} from 'react'; +import React, {Fragment, useEffect, useRef, useState} from 'react'; import { Alert, Image, @@ -16,7 +16,9 @@ import {Button} from 'react-native-elements'; import {useDispatch, useSelector} from 'react-redux'; import {SearchBackground} from '../../components'; import {CaptionScreenHeader} from '../../components/'; +import Draggable from '../../components/common/Draggable'; import TaggLoadingIndicator from '../../components/common/TaggLoadingIndicator'; +import TaggDraggable from '../../components/taggs/TaggDraggable'; import {TAGG_LIGHT_BLUE_2} from '../../constants'; import {ERROR_UPLOAD, SUCCESS_PIC_UPLOAD} from '../../constants/strings'; import {MainStackParams} from '../../routes'; @@ -50,6 +52,39 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { const dispatch = useDispatch(); const [caption, setCaption] = useState(''); const [loading, setLoading] = useState(false); + const imageRef = useRef(null); + + // state variables used to position draggables + const [offset, setOffset] = useState([0, 0]); + const [curStart, setCurStart] = useState([0, 0]); + const [imageDimensions, setImageDimensions] = useState([0, 0]); + + const [taggList, setTaggList] = useState([ + { + first_name: 'Ivan', + id: 'cee45bf8-7f3d-43c8-99bb-ec04908efe58', + last_name: 'Chen', + thumbnail_url: + 'https://tagg-dev.s3.us-east-2.amazonaws.com/thumbnails/smallProfilePicture/spp-cee45bf8-7f3d-43c8-99bb-ec04908efe58-thumbnail.jpg', + username: 'ivan.tagg', + }, + { + first_name: 'Ankit', + id: '3bcf6947-bee6-46b0-ad02-6f4d25eaeac3', + last_name: 'Thanekar', + thumbnail_url: + 'https://tagg-dev.s3.us-east-2.amazonaws.com/thumbnails/smallProfilePicture/spp-3bcf6947-bee6-46b0-ad02-6f4d25eaeac3-thumbnail.jpg', + username: 'ankit.thanekar', + }, + { + first_name: 'Ankit', + id: '3bcf6947-bee6-46b0-ad02-6f4d25eaeac3', + last_name: 'Thanekar', + thumbnail_url: + 'https://tagg-dev.s3.us-east-2.amazonaws.com/thumbnails/smallProfilePicture/spp-3bcf6947-bee6-46b0-ad02-6f4d25eaeac3-thumbnail.jpg', + username: 'ankit.thanekar', + }, + ]); const navigateToProfile = () => { //Since the logged In User is navigating to own profile, useXId is not required @@ -59,6 +94,16 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { }); }; + /** + * need a handler to take care of creating a tagged user object, append that object to the taggList state variable. + */ + useEffect(() => { + imageRef.current.measure((fx, fy, width, height, px, py) => { + setOffset([px, py]); + setImageDimensions([width, height]); + }); + }, []); + const handleShare = async () => { setLoading(true); if (!image.filename) { @@ -105,7 +150,9 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { /> </View> <CaptionScreenHeader style={styles.header} {...{title: title}} /> + {/* this is the image we want to center our tags' initial location within */} <Image + ref={imageRef} style={styles.image} source={{uri: image.path}} resizeMode={'cover'} @@ -118,6 +165,25 @@ const CaptionScreen: React.FC<CaptionScreenProps> = ({route, navigation}) => { onChange={setCaption} partTypes={mentionPartTypes('blue')} /> + {taggList.map((user) => ( + <Draggable + x={imageDimensions[0] / 2 - curStart[0] / 2 + offset[0]} + y={offset[1] + imageDimensions[1] / 2 - curStart[1] / 2} + minX={offset[0]} + minY={offset[1]} + maxX={imageDimensions[0] + offset[0]} + maxY={imageDimensions[1] + offset[1]} + onDragStart={() => null}> + <TaggDraggable + taggedUser={user} + editingView={true} + deleteFromList={() => + setTaggList(taggList.filter((u) => u.id !== user.id)) + } + setStart={setCurStart} + /> + </Draggable> + ))} </View> </KeyboardAvoidingView> </TouchableWithoutFeedback> |