Skip to content

Instantly share code, notes, and snippets.

@gorkemozkan
Last active April 11, 2025 08:02
Show Gist options
  • Save gorkemozkan/c1bcae7deefea1d2a0f2577dcc7883ef to your computer and use it in GitHub Desktop.
Save gorkemozkan/c1bcae7deefea1d2a0f2577dcc7883ef to your computer and use it in GitHub Desktop.
ReadMore Without Library
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { View, TouchableOpacity, StyleSheet, LayoutAnimation, Platform, UIManager, Text, LayoutChangeEvent, Animated } from 'react-native';
import theme from '@/theme';
import HTMLRenderer from './HTMLRenderer';
import StyledText from '@/components/UI/StyledText';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
interface ReadMoreProps {
children: string | React.ReactNode;
style?: {
typography?: {
fontFamily?: string;
fontSize?: number;
lineHeight?: number;
color?: string;
};
container?: object;
content?: object;
readMoreButton?: object;
readMoreText?: object;
skeleton?: object;
};
config?: {
maxHeight?: number;
readMoreText?: string;
readLessText?: string;
hideShowButton?: boolean;
animationDuration?: number;
skeletonColor?: string;
skeletonHighlightColor?: string;
skeletonLines?: number;
skeletonHeight?: number;
skeletonLineHeight?: number;
skeletonLineSpacing?: number;
};
isHtml?: boolean;
}
const sanitizeHtml = (html: string): string => {
return html
// Remove script tags and their content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// Remove style tags and their content
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
// Remove empty paragraphs
.replace(/<p>\s*(&nbsp;|\u00A0|\s)*<\/p>/gi, '')
// Replace <br> tags with spaces
.replace(/<br\s*\/?>/gi, ' ')
// Remove all tags except allowed ones
.replace(/<(?!\/?(p|b|i|em|strong|a|ul|ol|li|span|div|img|h[1-4])\b)[^>]+>/gi, '')
// Remove javascript: URLs
.replace(/javascript:/gi, '')
// Remove event handlers
.replace(/on\w+="[^"]*"/gi, '')
// Replace non-breaking spaces with regular spaces
.replace(/&nbsp;|&#160;|&#xA0;/gi, ' ')
.replace(/\u00A0/g, ' ')
.replace(/\u2007/g, ' ')
.replace(/\u202F/g, ' ')
// Replace HTML entities
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
// Normalize whitespace
.replace(/\s+/g, ' ')
.trim();
};
const ReadMore: React.FC<ReadMoreProps> = ({
children,
style = {},
config = {},
isHtml = false,
}) => {
const {
maxHeight = 115,
readMoreText = 'Show More',
readLessText = 'Show Less',
hideShowButton = false,
animationDuration = 300,
skeletonColor = theme.colorBg.Elevated,
skeletonHighlightColor = theme.colorBg.Secondary,
skeletonLines = 3,
skeletonHeight,
skeletonLineHeight = 16,
skeletonLineSpacing = 8,
} = config;
const {
typography = {},
container: containerStyle = {},
content: contentStyle = {},
readMoreButton: readMoreButtonStyle = {},
readMoreText: readMoreTextStyle = {},
skeleton: skeletonStyle = {},
} = style;
const {
fontFamily = 'system-ui',
fontSize = 16,
lineHeight = 24,
color = theme.colorText.Secondary,
} = typography;
// State
const [isExpanded, setIsExpanded] = useState(false);
const [contentHeight, setContentHeight] = useState(0);
const [shouldShowButton, setShouldShowButton] = useState(false);
const [isReady, setIsReady] = useState(false);
const [skeletonAnimValue] = useState(new Animated.Value(0));
const contentRef = useRef<View>(null);
const measureContainerRef = useRef<View>(null);
const fadeAnim = useRef(new Animated.Value(0)).current;
// Process content
const processedContent = React.useMemo(() => {
if (isHtml && typeof children === 'string') {
return sanitizeHtml(children);
}
return children;
}, [children, isHtml]);
const renderContent = useCallback(() => {
if (isHtml && typeof processedContent === 'string') {
return (
<HTMLRenderer
html={processedContent}
style={{
container: { width: '100%' },
text: { fontFamily, fontSize, lineHeight, color }
}}
/>
);
} else if (typeof processedContent === 'string') {
return (
<StyledText style={{ fontFamily, fontSize, lineHeight, color }}>
{processedContent}
</StyledText>
);
} else {
return processedContent;
}
}, [processedContent, isHtml, fontFamily, fontSize, lineHeight, color]);
const toggleExpanded = useCallback(() => {
if (Platform.OS === 'ios' || Platform.OS === 'android') {
LayoutAnimation.configureNext({
duration: animationDuration,
update: { type: 'easeInEaseOut' },
});
}
setIsExpanded(prev => !prev);
}, [animationDuration]);
const onLayout = useCallback((event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;
setContentHeight(height);
setShouldShowButton(height > maxHeight);
setIsReady(true);
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
}, [maxHeight, fadeAnim]);
useEffect(() => {
setIsExpanded(false);
setIsReady(false);
setShouldShowButton(false);
fadeAnim.setValue(0);
Animated.loop(
Animated.sequence([
Animated.timing(skeletonAnimValue, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(skeletonAnimValue, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
])
).start();
}, [processedContent, fadeAnim, skeletonAnimValue]);
const renderSkeleton = () => {
const lines = [];
const calculatedHeight = skeletonHeight || Math.min(maxHeight, 150);
for (let i = 0; i < skeletonLines; i++) {
const width = i === skeletonLines - 1 ? '60%' : `${Math.random() * 40 + 60}%`;
const opacity = skeletonAnimValue.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0.3, 0.7, 0.3],
});
lines.push(
<Animated.View
key={i}
style={[
styles.skeletonLine,
{
width,
backgroundColor: skeletonColor,
height: skeletonLineHeight,
marginBottom: i < skeletonLines - 1 ? skeletonLineSpacing : 0,
opacity
},
skeletonStyle
]}
/>
);
}
return (
<View
style={[
styles.skeletonContainer,
{
minHeight: calculatedHeight,
paddingVertical: 10,
}
]}
>
{lines}
</View>
);
};
if (!processedContent) return null;
return (
<View style={[styles.container, containerStyle]}>
<View
ref={measureContainerRef}
style={styles.measureContainer}
onLayout={onLayout}
>
{renderContent()}
</View>
{!isReady && renderSkeleton()}
{isReady && (
<Animated.View
ref={contentRef}
style={[
styles.content,
{ maxHeight: isExpanded ? undefined : maxHeight },
contentStyle,
{ opacity: fadeAnim }
]}
>
{renderContent()}
</Animated.View>
)}
{isReady && shouldShowButton && !hideShowButton && (
<TouchableOpacity
onPress={toggleExpanded}
style={[styles.readMoreButton, readMoreButtonStyle]}
activeOpacity={0.7}
>
<StyledText
button='md'
colorVariable={isExpanded ? 'Secondary' : 'Active'}
style={[styles.readMoreText, readMoreTextStyle]}
>
{isExpanded ? readLessText : readMoreText}
</StyledText>
</TouchableOpacity>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
width: '100%',
},
measureContainer: {
position: 'absolute',
opacity: 0,
width: '100%',
zIndex: -1,
},
content: {
width: '100%',
overflow: 'hidden',
},
readMoreButton: {
marginTop: 12,
alignItems: 'flex-start',
},
readMoreText: {
color: theme.colorText.Primary,
},
skeletonContainer: {
width: '100%',
minHeight: 50,
paddingVertical: 10,
},
skeletonLine: {
height: 16,
borderRadius: 4,
marginBottom: 8,
},
});
export default React.memo(ReadMore);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment