Frontend/React-Native

[React Native] Collapsible View 만들기

w00se 2021. 7. 11. 12:25

https://pixabay.com/ko/photos/놀이터-회전-신장-매력-5188459/

 

Collapsible view는 '접을 수 있는 요소'로 사용자의 터치에 따라 접히고 펼쳐지는 요소를 말합니다.

collapsible view bootstrap 예시

 

React native에는 react-native-collapsible라는 라이브러리가 존재합니다.

해당 라이브러리는 github start가 2.1k이며 npm에서 weekly downloads가 5만이 넘을 만큼 많은 사람들이 이용하는 라이브러리인 거 같습니다. 빠르게 기능을 도입해야 한다면 해당 라이브러리를 이용하는 것도 좋을 거 같아요😊

 

https://github.com/oblador/react-native-collapsible

 

저는 해당 기능을 직접 구현하고 싶은 마음이 있어서 구현해봤습니다.

제가 구현한 예시는 아래와 같습니다.

 

* 구현을 위해 react-native-reanimted 라이브러리를 사용했습니다.

라이브러리 설치가 필요하신 분은 아래 링크를 참고하면 좋을 거 같아요☺️

https://coding-w00se.tistory.com/39

 

구현 예시

 

구현 예시

저는 view를 펼치기 전에 간략히 두 줄의 정보를 보여줄 필요가 있어서 위와 같은 collapsible view를 구현했습니다.

 

구현해야 할 효과 및 기능

위 예시를 위해 구현해야할 기능은 아래와 같습니다.

 

1. collapsbile 효과

2. icon animation 효과

 

0. View 구조 만들기

 

CollapsibleViewTestScreen.js

collapsible View가 사용될 screen입니다.

import React from 'react';
import {
    View,
    StyleSheet,
} from 'react-native';
import CollapsibleView from '../components/CollapsibleView';

// 린스타트업 개념 출처: https://ko.wikipedia.org/wiki/린스타트업
const SECTION_TITLE = "자세한 정보"
const CONTENT = "린 스타트업(Lean Startup)은 제품이나 시장을 발달시키기 위해 기업가들이 사용하는 프로세스 모음 중 하나로서, 애자일 소프트웨어 개발과, 고객 개발(Customer Development), 그리고 기존의 소프트웨어 플랫폼 (주로 오픈소스) 등을 활용한다.\n\n린 스타트업은 우선 시장에 대한 가정(market assumptions)을 테스트하기 위해 빠른 프로토타입(rapid prototype)을 만들도록 권한다. 그리고 고객의 피드백을 받아 기존의 소프트웨어 엔지니어링 프랙티스(폭포수 모델 같은)보다 훨씬 빠르게 프로토타입을 진화시킬 것을 주장한다. 린 스타트업에서 하루에도 몇 번씩 새로운 코드를 릴리즈하는 것은 드문 일이 아니다. 이를 위해서 지속적 배포(Continuous Deployment)라는 기법을 사용한다.\n\n린 스타트업은 때로 린 사고방식(Lean Thinking)을 창업 프로세스에 적용한 것으로 설명되기도 한다. 린 사고방식의 핵심은 낭비를 줄이는 것이다. 린 스타트업 프로세스는 고객 개발(Customer Development)을 사용하여, 실제 고객과 접촉하는 빈도를 높여서 낭비를 줄인다. 이를 통해 시장에 대한 잘못된 가정을 최대한 빨리 검증하고 회피한다. 이 방식은 역사적인 기업가들의 전략을 발전시킨 것이다. 시장에 대한 가정들을 검증하기 위한 작업들을 줄이고, 시장 선도력(market traction)을 가지는 비즈니스를 찾는데 걸리는 시간을 줄인다. 이것을 최소 기능 제품 (Minimum Viable Product)이라고도 한다. 다른 말로는 최소 기능 셋 (Minimum Features Set) 이라고 불린다"
const INITIAL_MAX_LINE = 2;

function CollapsibleViewTestScreen (props) {
    return (
        <View  style={styles.rootContainer}>
            <CollapsibleView sectionTitle={SECTION_TITLE} content={CONTENT} maxline={INITIAL_MAX_LINE}/>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
        alignItems: "center",
        backgroundColor: "#ffffff"
    }
})
export default CollapsibleViewTestScreen

 

CollapsibleView.js

collapsible 효과를 구현할 컴포넌트입니다.

import React, { useCallback, useState, useRef } from 'react';
import {
    View,
    StyleSheet,
    Text,
    TouchableWithoutFeedback
} from 'react-native';
import Animated from "react-native-reanimated";
import MIcon from 'react-native-vector-icons/MaterialIcons';

function CollapsibleView (props) {
    const { sectionTitle, content, maxLine } = props;
 
    const onPress = useCallback(()=>{
    }, [ ]);

    return (
        <View style={styles.rootContainer}>
            <TouchableWithoutFeedback onPress={onPress}>
                <View style={styles.sectionContainer}>
                    <Text style={styles.sectionTItle}>{sectionTitle}</Text>
                    <Animated.View>
                        <MIcon name="expand-more" size={30}/>
                    </Animated.View>
                </View>
            </TouchableWithoutFeedback>
            <Animated.View style={[ styles.contentContainer, ]}>
                <Text style={styles.content}>
                    {content}
                </Text>
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        width: "100%",
        marginTop: 50,
        paddingHorizontal: 20,
    },
    sectionContainer: {
        minHeight: 30,
        flexDirection: "row",
        justifyContent: "space-between",
        alignItems: "center",
    },
    sectionTItle: {
        fontFamily: "AppleSDGothicNeo-SemiBold",
        fontSize: 18,
        lineHeight: 20,
        color: "rgb(26, 26, 26)"
    },
    contentContainer: {
        overflow: "hidden"
    },
    content: {
        fontFamily: "AppleSDGothicNeo-Regular",
        fontSize: 15,
        lineHeight: 17,
        color: "rgb(26, 26, 26)"
    }
});

export default CollapsibleView;

 

여기까지 코드를 작성하면 아래와 같이 화면이 구현이 됩니다.

0. View 구조 만들기 예시

 

1. Collapsible 효과 구현하기

이제 section 부분을 터치했을 때 content 부분이 접히고 펼쳐지는 효과를 구현할 단계입니다.

해당 효과를 구현하기 위한 단계는 아래와 같습니다.

 

1. content를 감싸는 Text 컴포넌트에서 onTextLayout를 통해 content의 행 수, 각 행의 높이를 state에 저장하며, content의 행 수 >= maxLine 이면 section의 터치 이벤트를 disable 해줍니다.

 

해당 부분의 코드는 아래와 같습니다.

...
const onTextLayout = useCallback((event) => {
        if(isFirst) {
            const { lines } = event.nativeEvent;

            if(lines.length >= maxLine) {
                setIsEnableCollapsible(false);
            }

            setContentLineCnt(lines.length);
            setContentLineHeight(lines[0].height);
            contentContainerHeight.value = lines[0].height*maxLine;
            setIsFirst(false);
        }
    }, [ isFirst ]);
    
...

return (
        <View style={styles.rootContainer}>
            ...
                <Text style={styles.content} onTextLayout={onTextLayout} numberOfLines={isFirst? undefined: contentNumberOfLines} ellipsizeMode={"tail"}>
                    {content}
                </Text>
            ...
        </View>
    );

 

2. section의 터치 이벤트에 따라 content를 감싸는 Animated.View의 높이를 조절합니다.

 

해당 부분의 코드는 아래와 같습니다.

...

const contentContainerAnimatedStyle = useAnimatedStyle(()=>{ // Ainmated View에 적용될 style
        return {
            height: isFirst? undefined: contentContainerHeight.value,
        }
    }, [ isFirst ]);
    
const onPress = useCallback(()=>{ // section을 터치했을 때 발생하는 터치 이벤트
        if(isOpen.current) {
            contentContainerHeight.value = withTiming(contentLineHeight*maxLine+5, { duration: 250 }, () => { runOnJS(setContentNumberOfLines)(maxLine) });
        } else {
            setContentNumberOfLines(contentLineCnt);
            contentContainerHeight.value = withTiming(contentLineHeight*contentLineCnt+5, { duration: 250 });
        }
        isOpen.current = !isOpen.current;
    }, [ contentLineCnt, contentLineHeight ]);
...

return (
        <View style={styles.rootContainer}>
            <TouchableWithoutFeedback onPress={onPress} disabled={isEnableCollapsible}>
                <View style={styles.sectionContainer}>
                    <Text style={styles.sectionTItle}>{sectionTitle}</Text>
                    <Animated.View>
                        <MIcon name="expand-more" size={30}/>
                    </Animated.View>
                </View>
            </TouchableWithoutFeedback>
            <Animated.View style={[ styles.contentContainer, contentContainerAnimatedStyle]}>
                <Text style={styles.content} onTextLayout={onTextLayout} numberOfLines={isFirst? undefined: contentNumberOfLines} ellipsizeMode={"tail"}>
                    {content}
                </Text>
            </Animated.View>
        </View>
    );

 

collapsible 효과를 구현한 전체 코드는 아래와 같습니다.

 

CollapsibleView.js

import React, { useCallback, useState, useRef } from 'react';
import {
    View,
    StyleSheet,
    Text,
    TouchableWithoutFeedback
} from 'react-native';
import Animated, {
    useSharedValue,
    useAnimatedStyle,
    withTiming,
    runOnJS
} from "react-native-reanimated";
import MIcon from 'react-native-vector-icons/MaterialIcons';

function CollapsibleView (props) {
    const { sectionTitle, content, maxLine } = props;
    const [ isEnableCollapsible, setIsEnableCollapsible ] = useState(true);
    const [ isFirst, setIsFirst ] = useState(true);
    const [ contentLineHeight, setContentLineHeight ] = useState(0);
    const [ contentLineCnt, setContentLineCnt ] = useState(0);
    const [ contentNumberOfLines, setContentNumberOfLines ] = useState(maxLine);
    const isOpen = useRef(false);

    const contentContainerHeight = useSharedValue(0);

    const contentContainerAnimatedStyle = useAnimatedStyle(()=>{
        return {
            height: isFirst? undefined: contentContainerHeight.value,
        }
    }, [ isFirst ]);

    const onPress = useCallback(()=>{
        if(isOpen.current) {
            contentContainerHeight.value = withTiming(contentLineHeight*maxLine+5, { duration: 250 }, () => { runOnJS(setContentNumberOfLines)(maxLine) });
        } else {
            setContentNumberOfLines(contentLineCnt);
            contentContainerHeight.value = withTiming(contentLineHeight*contentLineCnt+5, { duration: 250 });
        }
        isOpen.current = !isOpen.current;
    }, [ contentLineCnt, contentLineHeight ]);

    const onTextLayout = useCallback((event) => {
        if(isFirst) {
            const { lines } = event.nativeEvent;

            if(lines.length >= maxLine) {
                setIsEnableCollapsible(false);
            }

            setContentLineCnt(lines.length);
            setContentLineHeight(lines[0].height);
            contentContainerHeight.value = lines[0].height*maxLine;
            setIsFirst(false);
        }
    }, [ isFirst ]);

    return (
        <View style={styles.rootContainer}>
            <TouchableWithoutFeedback onPress={onPress} disabled={isEnableCollapsible}>
                <View style={styles.sectionContainer}>
                    <Text style={styles.sectionTItle}>{sectionTitle}</Text>
                    <Animated.View>
                        <MIcon name="expand-more" size={30}/>
                    </Animated.View>
                </View>
            </TouchableWithoutFeedback>
            <Animated.View style={[ styles.contentContainer, contentContainerAnimatedStyle]}>
                <Text style={styles.content} onTextLayout={onTextLayout} numberOfLines={isFirst? undefined: contentNumberOfLines} ellipsizeMode={"tail"}>
                    {content}
                </Text>
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        width: "100%",
        marginTop: 20,
        paddingHorizontal: 20,
    },
    sectionContainer: {
        minHeight: 30,
        flexDirection: "row",
        justifyContent: "space-between",
        alignItems: "center",
    },
    sectionTItle: {
        fontFamily: "AppleSDGothicNeo-SemiBold",
        fontSize: 18,
        lineHeight: 20,
        color: "rgb(26, 26, 26)"
    },
    contentContainer: {
        overflow: "hidden"
    },
    content: {
        fontFamily: "AppleSDGothicNeo-Regular",
        fontSize: 15,
        lineHeight: 17,
        color: "rgb(26, 26, 26)"
    }
});

export default CollapsibleView;

 

Collaspible 효과까지 구현한 예시는 아래와 같습니다.

 

1. collaspible 효과 구현 예시

 

2. icon animation 효과 구현하기

collapsible 효과를 구현했으므로 펼치고 접히는 효과에 따라 section에 있는 icon에도 변화를 주려합니다.

해당 효과는 펼쳐짐/접힘에 따라 icon을 감싸는 Animated View의 rotate 값을 조절하여 구현했습니다.

 

해당 효과의 코드는 아래와 같습니다.

...
import Animated, {
    ...
    withSpring,
    ...
} from "react-native-reanimated";

const moreButtonDeg = useSharedValue(0);

const moreButtonAnimatedStyle = useAnimatedStyle(()=>{ // icon을 감싸는 Animated.View에 적용될 style
        return {
            transform: [ 
                { 
                    rotate: `${moreButtonDeg.value}deg`,
                },
            ]
        }
    }, []);
    

const onPress = useCallback(()=>{
        if(isOpen.current) {
            moreButtonDeg.value = withSpring(0, {}); // 펼쳐진 상태로 눌렸을 때 아이콘을 0도로 돌린다.
            contentContainerHeight.value = withTiming(contentLineHeight*maxLine+5, { duration: 250 }, () => { runOnJS(setContentNumberOfLines)(maxLine) });
        } else {
            moreButtonDeg.value = withSpring(180, {}); // 닫힌 상태에서 눌렸을 때 아이콘을 180도로 돌린다.
            setContentNumberOfLines(contentLineCnt);
            contentContainerHeight.value = withTiming(contentLineHeight*contentLineCnt+5, { duration: 250 });
        }
        isOpen.current = !isOpen.current;
    }, [ contentLineCnt, contentLineHeight ]);
    
    
 ...
 
 return (
        <View style={styles.rootContainer}>
            ...
                    <Animated.View style={moreButtonAnimatedStyle}>
                        <MIcon name="expand-more" size={30}/>
                    </Animated.View>
            ...
        </View>
    );

 

모든 효과를 구현한 전체 코드는 아래와 같습니다.

 

import React, { useCallback, useState, useRef } from 'react';
import {
    View,
    StyleSheet,
    Text,
    TouchableWithoutFeedback
} from 'react-native';
import Animated, {
    useSharedValue,
    useAnimatedStyle,
    withTiming,
    withSpring,
    runOnJS
} from "react-native-reanimated";
import MIcon from 'react-native-vector-icons/MaterialIcons';

function CollapsibleView (props) {
    const { sectionTitle, content, maxLine } = props;
    const [ isEnableCollapsible, setIsEnableCollapsible ] = useState(true);
    const [ isFirst, setIsFirst ] = useState(true);
    const [ contentLineHeight, setContentLineHeight ] = useState(0);
    const [ contentLineCnt, setContentLineCnt ] = useState(0);
    const [ contentNumberOfLines, setContentNumberOfLines ] = useState(maxLine);
    const isOpen = useRef(false);
    
    const contentContainerHeight = useSharedValue(0);
    const moreButtonDeg = useSharedValue(0);

    const contentContainerAnimatedStyle = useAnimatedStyle(()=>{
        return {
            height: isFirst? undefined: contentContainerHeight.value,
        }
    }, [ isFirst ]);

    const moreButtonAnimatedStyle = useAnimatedStyle(()=>{
        return {
            transform: [ 
                { 
                    rotate: `${moreButtonDeg.value}deg`,
                },
            ]
        }
    }, []);

    const onPress = useCallback(()=>{
        if(isOpen.current) {
            moreButtonDeg.value = withSpring(0, {});
            contentContainerHeight.value = withTiming(contentLineHeight*maxLine+5, { duration: 250 }, () => { runOnJS(setContentNumberOfLines)(maxLine) });
        } else {
            moreButtonDeg.value = withSpring(180, {});
            setContentNumberOfLines(contentLineCnt);
            contentContainerHeight.value = withTiming(contentLineHeight*contentLineCnt+5, { duration: 250 });
        }
        isOpen.current = !isOpen.current;
    }, [ contentLineCnt, contentLineHeight ]);

    const onTextLayout = useCallback((event) => {
        if(isFirst) {
            const { lines } = event.nativeEvent;

            if(lines.length >= maxLine) {
                setIsEnableCollapsible(false);
            }

            setContentLineCnt(lines.length);
            setContentLineHeight(lines[0].height);
            contentContainerHeight.value = lines[0].height*maxLine;
            setIsFirst(false);
        }
    }, [ isFirst ]);

    return (
        <View style={styles.rootContainer}>
            <TouchableWithoutFeedback onPress={onPress} disabled={isEnableCollapsible}>
                <View style={styles.sectionContainer}>
                    <Text style={styles.sectionTItle}>{sectionTitle}</Text>
                    <Animated.View style={moreButtonAnimatedStyle}>
                        <MIcon name="expand-more" size={30}/>
                    </Animated.View>
                </View>
            </TouchableWithoutFeedback>
            <Animated.View style={[ styles.contentContainer, contentContainerAnimatedStyle]}>
                <Text style={styles.content} onTextLayout={onTextLayout} numberOfLines={isFirst? undefined: contentNumberOfLines} ellipsizeMode={"tail"}>
                    {content}
                </Text>
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        width: "100%",
        marginTop: 20,
        paddingHorizontal: 20,
    },
    sectionContainer: {
        minHeight: 30,
        flexDirection: "row",
        justifyContent: "space-between",
        alignItems: "center",
    },
    sectionTItle: {
        fontFamily: "AppleSDGothicNeo-SemiBold",
        fontSize: 18,
        lineHeight: 20,
        color: "rgb(26, 26, 26)"
    },
    contentContainer: {
        overflow: "hidden"
    },
    content: {
        fontFamily: "AppleSDGothicNeo-Regular",
        fontSize: 15,
        lineHeight: 17,
        color: "rgb(26, 26, 26)"
    }
});

export default CollapsibleView;

 

여기까지 구현을 하면 위의 구현 예시처럼 구현이 됩니다!

 


읽어 주셔서 감사합니다 :)

잘못된 부분이 있다면 댓글로 편히 알려주세요😊