Skip to content

Instantly share code, notes, and snippets.

@efstathiosntonas
Last active February 16, 2024 17:19
Show Gist options
  • Save efstathiosntonas/1a8c0ff35e57cd9e7d668e8d6304a0ca to your computer and use it in GitHub Desktop.
Save efstathiosntonas/1a8c0ff35e57cd9e7d668e8d6304a0ca to your computer and use it in GitHub Desktop.
react-native CollapsibleView with reanimated@3
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);
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
})
}));
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);
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
)
})
}));
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;
};
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