Last active
February 16, 2024 17:19
-
-
Save efstathiosntonas/1a8c0ff35e57cd9e7d668e8d6304a0ca to your computer and use it in GitHub Desktop.
react-native CollapsibleView with reanimated@3
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { memo, ReactNode } from "react"; | |
import { LayoutChangeEvent, StyleProp, StyleSheet, ViewStyle } from "react-native"; | |
import Animated from "react-native-reanimated"; | |
import isEqual from "react-fast-compare"; | |
import type { State } from "./types"; | |
type Props = { | |
animatedHeight: any; | |
children: ReactNode; | |
onLayout: (event: LayoutChangeEvent) => void; | |
state: State; | |
style?: StyleProp<Animated.AnimateStyle<ViewStyle>>; | |
}; | |
const AnimatedSection = ({ children, onLayout, animatedHeight, state, style }: Props) => { | |
return ( | |
<Animated.View | |
pointerEvents={state === "expanded" ? "auto" : "none"} | |
style={[{ height: animatedHeight }, styles.overflowHidden]} | |
> | |
<Animated.View onLayout={onLayout} style={[styles.container, style]}> | |
{children} | |
</Animated.View> | |
</Animated.View> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
container: { | |
left: 0, | |
position: "absolute", | |
right: 0, | |
top: 0 | |
}, | |
overflowHidden: { | |
overflow: "hidden" | |
} | |
}); | |
export default memo(AnimatedSection, isEqual); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { | |
ComponentType, | |
memo, | |
ReactNode, | |
useCallback, | |
useEffect, | |
useState | |
} from "react"; | |
import { | |
StyleProp, | |
TextStyle, | |
TouchableOpacity, | |
TouchableOpacityProps, | |
View, | |
ViewStyle | |
} from "react-native"; | |
import { Text } from "react-native-fast-text"; | |
import isEqual from "react-fast-compare"; | |
import { createStyleSheet, useStyles } from "react-native-unistyles"; | |
import AnimatedSection from "./helpers/AnimatedSection"; | |
import RepliesLines from "@components/CollapsibleView/components/RepliesLines"; | |
import { COMMENT_LINES_WIDTH } from "@flows/posts/components/comments/PostComment/utils"; | |
import { createDeleteQuery } from "@store/sqlite/sqlite-utils/createDeleteQuery"; | |
import { createUpsertQuery } from "@store/sqlite/sqlite-utils/createUpsertQuery"; | |
import { QuickSqliteClient } from "@store/sqlite/QuickSqliteClient"; | |
import { mvs } from "react-native-size-matters"; | |
import { TopRowLeftBorderInnerView } from "@flows/posts/components/comments/PostComment/components"; | |
import { useCollapsible } from "./helpers/useCollapsible"; | |
interface CollapsibleProps { | |
TouchableComponent?: ComponentType<TouchableOpacityProps>; | |
activeOpacityFeedback?: number; | |
arrowStyling?: StyleProp<any>; | |
children?: ReactNode[]; | |
collapsedTitle?: string; | |
collapsibleContainerStyle?: StyleProp<ViewStyle>; | |
commentId: string | null; | |
duration?: number; | |
expanded?: boolean; | |
initExpanded?: boolean; | |
isParentLast?: boolean; | |
lastCommentIndex?: number; | |
length?: number; | |
nested?: number; | |
noArrow?: boolean; | |
parentCommentLength?: number; | |
title?: string; | |
titleProps?: TouchableOpacityProps; | |
titleStyle?: StyleProp<TextStyle>; | |
touchableWrapperProps?: TouchableOpacityProps; | |
touchableWrapperStyle?: StyleProp<ViewStyle>; | |
unmountOnCollapse?: boolean; | |
} | |
const CollapsibleView = ({ | |
activeOpacityFeedback = 0.9, | |
children = [], | |
collapsedTitle = "", | |
commentId = null, | |
expanded = false, | |
initExpanded = false, | |
isParentLast = false, | |
lastCommentIndex = -1, | |
length = 0, | |
nested = 0, | |
parentCommentLength = 0, | |
title = "", | |
titleProps = {}, | |
titleStyle = {}, | |
TouchableComponent = TouchableOpacity, | |
touchableWrapperProps = {}, | |
touchableWrapperStyle = {} | |
}: CollapsibleProps) => { | |
const [show, setShow] = useState<boolean | null | undefined>(initExpanded); | |
const { styles } = useStyles(stylesheet); | |
const { animatedHeight, onPress, onLayout, state, mounted, setMounted } = | |
useCollapsible({ | |
state: initExpanded ? "expanded" : "collapsed" | |
}); | |
if (!mounted && expanded) { | |
setMounted(true); | |
} | |
const hasChildren = length > 0; | |
const handleToggleShow = useCallback(() => { | |
if (!mounted) { | |
if (!show) { | |
onPress(); | |
setMounted(true); | |
// handleArrowRotate(true); | |
if (commentId) { | |
const query = createUpsertQuery( | |
"expanded_comments", | |
{ | |
id: commentId, | |
expanded: true | |
}, | |
["id"] | |
); | |
QuickSqliteClient.executeSql.apply(null, query); | |
} | |
} | |
} else { | |
onPress(); | |
// handleArrowRotate(false); | |
setShow((prev) => !prev); | |
setMounted(false); | |
if (commentId) { | |
const query = createDeleteQuery("expanded_comments", { | |
id: commentId | |
}); | |
QuickSqliteClient.executeSql.apply(null, query); | |
} | |
} | |
}, [commentId, mounted, onPress, setMounted, show]); | |
useEffect(() => { | |
// this part is to trigger collapsible animation only after he has been fully mounted so animation would | |
// not be interrupted. | |
if (mounted) { | |
setShow(true); | |
} | |
}, [mounted]); | |
useEffect(() => { | |
if (show !== expanded) { | |
setShow(expanded); | |
} | |
}, [expanded, mounted, show]); | |
return ( | |
<View style={styles.container}> | |
<TouchableComponent | |
activeOpacity={activeOpacityFeedback} | |
onPress={handleToggleShow} | |
style={touchableWrapperStyle} | |
{...touchableWrapperProps} | |
> | |
{/*@ts-ignore*/} | |
<View | |
// eslint-disable-next-line react-native/no-inline-styles | |
style={{ | |
flexDirection: "row", | |
alignItems: "center", | |
// @ts-ignore | |
...titleStyle, | |
marginLeft: mvs(nested === 1 ? 34 : 62) | |
}} | |
{...titleProps} | |
> | |
{state === "expanded" ? <View style={styles.line(state, nested)} /> : null} | |
{state !== "expanded" ? ( | |
<TopRowLeftBorderInnerView | |
hasChildren={hasChildren} | |
isLast={false} | |
isLastParent={isParentLast} | |
isParent={false} | |
nested={nested} | |
special | |
/> | |
) : null} | |
<RepliesLines | |
isParentLast={isParentLast} | |
lastCommentIndex={lastCommentIndex} | |
length={length} | |
nested={nested} | |
parentCommentLength={parentCommentLength} | |
state={state} | |
/> | |
<Text style={styles.title(nested, hasChildren)}> | |
{state === "collapsed" ? `${title} ` : `${collapsedTitle} `} | |
</Text> | |
</View> | |
</TouchableComponent> | |
{mounted ? ( | |
<AnimatedSection | |
animatedHeight={animatedHeight} | |
onLayout={onLayout} | |
state={state} | |
style={styles.animatedSection} | |
> | |
{children} | |
</AnimatedSection> | |
) : null} | |
</View> | |
); | |
}; | |
export default memo(CollapsibleView, isEqual); | |
const stylesheet = createStyleSheet((theme) => ({ | |
animatedSection: { | |
width: "100%" | |
}, | |
container: { | |
flex: 1 | |
}, | |
line: (state: string, nested: number) => ({ | |
backgroundColor: theme.special.line, | |
height: state === "expanded" ? "160%" : "70%", | |
left: mvs(nested === 1 ? -2 : 2), | |
paddingBottom: theme.spacing.mvs14, | |
position: "absolute", | |
width: COMMENT_LINES_WIDTH | |
}), | |
title: (nested: number, hasChildren: boolean) => ({ | |
color: theme.colors.neutrals["1000"], | |
paddingBottom: theme.spacing.mvs6, | |
paddingTop: theme.spacing.mvs10, | |
paddingLeft: mvs( | |
nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? 0 | |
: hasChildren && nested === 1 | |
? 26 | |
: !hasChildren && nested === 1 | |
? 33 | |
: !hasChildren && nested === 2 | |
? 66 | |
: 32 | |
), | |
...theme.typography.paragraph.medium.semibold | |
}) | |
})); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { memo } from "react"; | |
import { View } from "react-native"; | |
import isEqual from "react-fast-compare"; | |
import { createStyleSheet, useStyles } from "react-native-unistyles"; | |
import { mvs } from "react-native-size-matters"; | |
import { COMMENT_LINES_WIDTH } from "@flows/posts/components/comments/PostComment/utils"; | |
const RepliesLines = ({ | |
isParentLast, | |
lastCommentIndex, | |
length, | |
nested, | |
parentCommentLength, | |
state | |
}: { | |
isParentLast: boolean; | |
lastCommentIndex: number; | |
length: number; | |
nested: number; | |
parentCommentLength: number; | |
state: "expanded" | "collapsed"; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<> | |
{nested === 1 && state === "expanded" ? <View style={styles.lineThree} /> : null} | |
{nested > 1 && | |
lastCommentIndex > -1 && | |
parentCommentLength === 1 && | |
!isParentLast ? ( | |
<View style={styles.lines} /> | |
) : null} | |
{nested === 2 && | |
parentCommentLength === length - 1 && | |
length > 1 && | |
!isParentLast ? ( | |
<View style={styles.lines} /> | |
) : null} | |
{nested === 2 && parentCommentLength > 1 && !isParentLast ? ( | |
<View style={styles.lineTwo} /> | |
) : null} | |
{nested === 2 && parentCommentLength > length && length > 1 && !isParentLast ? ( | |
<View style={styles.lines} /> | |
) : null} | |
{nested === 2 && length > 1 && parentCommentLength > length && !isParentLast ? ( | |
<View style={styles.lines} /> | |
) : null} | |
</> | |
); | |
}; | |
const stylesheet = createStyleSheet((theme) => ({ | |
lines: { | |
backgroundColor: theme.special.line, | |
// backgroundColor: "yellow", | |
height: "190%", | |
left: mvs(-30), | |
marginBottom: theme.spacing.mvs10, | |
position: "absolute", | |
width: COMMENT_LINES_WIDTH | |
}, | |
lineTwo: { | |
backgroundColor: theme.special.line, | |
// backgroundColor: "brown", | |
height: "200%", | |
left: mvs(-30), | |
position: "absolute", | |
width: COMMENT_LINES_WIDTH | |
}, | |
lineThree: { | |
backgroundColor: theme.special.line, | |
// backgroundColor: "red", | |
height: "170%", | |
left: mvs(-2), | |
marginBottom: theme.spacing.mvs10, | |
position: "absolute", | |
width: COMMENT_LINES_WIDTH | |
} | |
})); | |
export default memo(RepliesLines, isEqual); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { ReactNode } from "react"; | |
import { View } from "react-native"; | |
import { createStyleSheet, useStyles } from "react-native-unistyles"; | |
import { mvs } from "react-native-size-matters"; | |
import CommentLineCircle from "@assets/svg/comments/CommentLineCircle"; | |
import CommentLineCircleSmall from "@assets/svg/comments/CommentLineCircleSmall"; | |
import { COMMENT_LINES_WIDTH } from "@flows/posts/components/comments/PostComment/utils"; | |
export const TopRowWrapper = (props: { | |
children: ReactNode; | |
hasChildren: boolean; | |
nested: number; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<View style={styles.topRowWrapper(props.hasChildren, props.nested)}> | |
{props.children} | |
</View> | |
); | |
}; | |
export const ActionRowLeftBorderInnerView = (props: { | |
hasChildren: boolean; | |
nested: number; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<View style={styles.actionRowLeftBorderInnerView(props.hasChildren, props.nested)} /> | |
); | |
}; | |
export const ActionRowLeftBorderOuterView = (props: { | |
hasChildren: boolean; | |
nested: number; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<View style={styles.actionRowLeftBorderOuterView(props.hasChildren, props.nested)} /> | |
); | |
}; | |
export const ContentLeftBorderInnerView = (props: { | |
hasChildren: boolean; | |
nested: number; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<View style={styles.contentLeftBorderInnerView(props.hasChildren, props.nested)} /> | |
); | |
}; | |
export const ContentLeftBorderOuterView = (props: { | |
hasChildren: boolean; | |
nested: number; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<View style={styles.contentLeftBorderOuterView(props.hasChildren, props.nested)} /> | |
); | |
}; | |
export const TopRowLeftBorderInnerView = (props: { | |
hasChildren: boolean; | |
isLast: boolean; | |
isLastParent: boolean | undefined; | |
isParent: boolean | undefined; | |
nested: number; | |
special?: boolean; | |
}) => { | |
const { styles, theme } = useStyles(stylesheet); | |
return props.nested === 1 || props.special ? ( | |
<CommentLineCircle | |
stroke={theme.special.line} | |
style={styles.topRowLeftBorderInnerView( | |
props.hasChildren, | |
props.isLast, | |
props.isLastParent, | |
props.isParent, | |
props.nested, | |
props.special | |
)} | |
/> | |
) : ( | |
<CommentLineCircleSmall | |
stroke={theme.special.line} | |
style={styles.topRowLeftBorderInnerView( | |
props.hasChildren, | |
props.isLast, | |
props.isLastParent, | |
props.isParent, | |
props.nested, | |
props.special | |
)} | |
/> | |
); | |
}; | |
export const TopRowLeftBorderOuterView = (props: { | |
hasChildren: boolean; | |
isLast: boolean; | |
isLastParent: boolean; | |
nested: number; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<View | |
style={styles.topRowLeftBorderOuterView( | |
props.hasChildren, | |
props.isLast, | |
props.isLastParent, | |
props.nested | |
)} | |
/> | |
); | |
}; | |
export const AvatarBorderOuterView = (props: { | |
hasChildren: boolean; | |
isLast: boolean; | |
isLastParent: boolean; | |
nested: number; | |
}) => { | |
const { styles } = useStyles(stylesheet); | |
return ( | |
<View | |
style={styles.avatarBorderOuterView( | |
props.hasChildren, | |
props.isLast, | |
props.isLastParent, | |
props.nested | |
)} | |
/> | |
); | |
}; | |
const stylesheet = createStyleSheet((theme) => ({ | |
topRowWrapper: (hasChildren: boolean, nested: number) => ({ | |
zIndex: 9999, | |
marginLeft: mvs( | |
nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? 0 | |
: hasChildren && nested === 1 | |
? 38 | |
: !hasChildren && nested === 1 | |
? 38 | |
: !hasChildren && nested === 2 | |
? 66 | |
: 0 | |
) | |
}), | |
topRowLeftBorderOuterView: ( | |
hasChildren: boolean, | |
isLast: boolean, | |
isLastParent: boolean | undefined, | |
nested: number | |
) => ({ | |
backgroundColor: theme.special.line, | |
// backgroundColor: "red", | |
position: "absolute", | |
width: COMMENT_LINES_WIDTH, | |
height: isLast && nested !== 0 ? "100%" : !isLastParent ? "200%" : "50%", | |
left: mvs( | |
nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? 16 | |
: hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 2 | |
? 16 | |
: 0 | |
) | |
}), | |
avatarBorderOuterView: ( | |
hasChildren: boolean, | |
isLast: boolean, | |
isLastParent: boolean | undefined, | |
nested: number | |
) => ({ | |
backgroundColor: theme.special.line, | |
// backgroundColor: "red", | |
position: "absolute", | |
width: COMMENT_LINES_WIDTH, | |
height: isLast && nested !== 0 ? "100%" : !isLastParent ? "100%" : "50%", | |
left: mvs( | |
nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? 16 | |
: hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 2 | |
? 48 | |
: 0 | |
) | |
}), | |
// circle | |
topRowLeftBorderInnerView: ( | |
hasChildren: boolean, | |
isLast: boolean, | |
isLastParent: boolean | undefined, | |
isParent: boolean | undefined, | |
nested: number, | |
special: boolean | undefined | |
) => ({ | |
position: "absolute", | |
width: COMMENT_LINES_WIDTH, | |
top: special ? mvs(-18) : mvs(nested === 1 ? -27 : -9), | |
left: mvs( | |
special && nested === 1 | |
? -2 | |
: special && nested === 2 | |
? 2 | |
: nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? 16 | |
: hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 2 | |
? 48 | |
: 0 | |
) | |
}), | |
contentLeftBorderOuterView: (hasChildren: boolean, nested: number) => ({ | |
backgroundColor: theme.special.line, | |
// backgroundColor: "red", | |
zIndex: 1, | |
position: "absolute", | |
height: "100%", | |
top: mvs(5), | |
width: COMMENT_LINES_WIDTH, | |
left: mvs( | |
nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? -26 | |
: hasChildren && nested === 1 | |
? -18 | |
: !hasChildren && nested === 1 | |
? -50 | |
: !hasChildren && nested === 2 | |
? -78 | |
: 0 | |
) | |
}), | |
contentLeftBorderInnerView: (hasChildren: boolean, nested: number) => ({ | |
backgroundColor: theme.special.line, | |
// backgroundColor: "brown", | |
position: "absolute", | |
height: "100%", | |
top: theme.spacing.mvs28, | |
width: COMMENT_LINES_WIDTH, | |
left: mvs( | |
nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? -26 | |
: hasChildren && nested === 1 | |
? -18 | |
: !hasChildren && nested === 1 | |
? -24 | |
: !hasChildren && nested === 2 | |
? -46 | |
: -46 | |
) | |
}), | |
actionRowLeftBorderOuterView: (hasChildren: boolean, nested: number) => ({ | |
backgroundColor: theme.special.line, | |
// backgroundColor: "orange", | |
position: "absolute", | |
height: "140%", | |
width: COMMENT_LINES_WIDTH, | |
left: mvs( | |
nested === 0 && !hasChildren | |
? 16 | |
: hasChildren && nested === 0 | |
? 16 | |
: hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 1 | |
? 16 | |
: !hasChildren && nested === 2 | |
? 16 | |
: 24 | |
) | |
}), | |
actionRowLeftBorderInnerView: (hasChildren: boolean, nested: number) => ({ | |
backgroundColor: theme.special.line, | |
// backgroundColor: "black", | |
position: "absolute", | |
height: "140%", | |
width: COMMENT_LINES_WIDTH, | |
left: mvs( | |
nested === 0 && !hasChildren | |
? 0 | |
: hasChildren && nested === 0 | |
? 12 | |
: hasChildren && nested === 1 | |
? 48 | |
: !hasChildren && nested === 1 | |
? 22 | |
: !hasChildren && nested === 2 | |
? 48 | |
: 24 | |
) | |
}) | |
})); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { EasingFunction } from "react-native/Libraries/Animated/Easing"; | |
import { SharedValue } from "react-native-reanimated"; | |
export declare type State = "expanded" | "collapsed"; | |
export declare type Config = { | |
duration?: number; | |
easing?: EasingFunction; | |
mounted?: SharedValue<boolean>; | |
show?: boolean | null | undefined; | |
state?: string; | |
unmountOnCollapse?: boolean | null | undefined; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useCallback, useEffect, useState } from "react"; | |
import type { LayoutChangeEvent } from "react-native"; | |
import { Easing, runOnJS, useSharedValue, withTiming } from "react-native-reanimated"; | |
import type { Config, State } from "./types"; | |
export function useCollapsible(config: Config) { | |
const [height, setHeight] = useState(0); | |
const [state, setState] = useState<State>( | |
config.state === "collapsed" ? "collapsed" : "expanded" | |
); | |
const [mounted, setMounted] = useState(config.state === "expanded"); | |
const animatedHeight = useSharedValue(0); | |
useEffect(() => { | |
if (state === "collapsed") { | |
animatedHeight.value = withTiming( | |
0, | |
{ | |
duration: 150, | |
easing: Easing.out(Easing.ease) | |
}, | |
() => runOnJS(setMounted)(false) | |
); | |
} else { | |
animatedHeight.value = withTiming( | |
height, | |
{ | |
duration: 150, | |
easing: Easing.out(Easing.ease) | |
}, | |
() => runOnJS(setMounted)(true) | |
); | |
} | |
}, [state, height, animatedHeight]); | |
const onPress = useCallback(() => { | |
setState((prev) => (prev === "collapsed" ? "expanded" : "collapsed")); | |
}, []); | |
const onLayout = useCallback( | |
(event: LayoutChangeEvent) => { | |
const measuredHeight = event.nativeEvent.layout.height; | |
if (height !== measuredHeight) { | |
setHeight(measuredHeight); | |
} | |
}, | |
[height] | |
); | |
return { | |
animatedHeight, | |
height, | |
mounted, | |
onLayout, | |
onPress, | |
setMounted, | |
state | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment