modal-animation

dev bourgeois·2024년 8월 16일

React-Native 개발

목록 보기
11/16
post-thumbnail

onBackdropPress

react-native-modal 라이브러리에 내장된 기능으로 모달의 배경을 클릭했을 때 호출되는 이벤트 핸들러이다.

onBackdropPress={() => setModalVisible(false)}를 사용해서 모달의 배경을 클릭하며 모달을 닫을 수 있다.

✅ 애니메이션

useNativeDriver={true}
: 애니메이션을 네이티브 드라이버를 사용하여 실행할지 여부를 결정한다. true로 설정되면 애니메이션이 더 원활하고 성능이 좋게 실행된다.
주로 애니메이션의 효율성과 성능에 영향을 준다.


hideModalContentWhileAnimating={true}
: 모달이 애니메이션 중일 때 모달의 내용을 숨길지 여부를 결정한다. true로 설정하면 애니메이션이 진행되는 동안 모달의 내용이 보이지 않는다.
이는 콘텐츠가 애니메이션 중에 깜빡이는 것을 방지하고, 보다 부드러운 애니메이션을 제공한다.

적용된 코드

 {currentImage && (
          <Modal
            isVisible={modalVisible}
            onBackdropPress={() => setModalVisible(false)}
            useNativeDriver={true}
            hideModalContentWhileAnimating={true}>
            <View style={styles.modalContainer}>
              <TouchableOpacity
                style={{alignItems: 'flex-end', marginRight: 20}}
                onPress={() => setModalVisible(false)}>
                <Image
                  style={{width: 15, height: 15, marginTop: 9}}
                  source={require('../../assets/icon/cancel2.png')}
                />
              </TouchableOpacity>
              <Image
                source={{uri: currentImage}}
                style={styles.fullImage}
                resizeMode="contain"
              />
            </View>
          </Modal>
        )}

커스텀 애니메이션

import { Modal } from 'react-native';
  const handleGroupPress = groupId => {
    setCurrentGroupId(groupId); // 선택한 그룹 ID 저장
    setCurrentGroupImages(groupedImages[groupId]); // 현재 그룹의 모든 이미지 저장
    setModalVisible(true);
    Animated.timing(animatedValue, {
      toValue: 1,
      duration: 200,
      useNativeDriver: true,
    }).start();
  };

  // 모달 닫기 함수
  const closeModal = () => {
    Animated.timing(animatedValue, {
      toValue: 0,
      duration: 120,
      useNativeDriver: true,
    }).start(() => setModalVisible(false));
  };

모달을 열고 닫는 함수에서 각각 애니메이션을 커스텀 해준다.
여기서는 모달이 켜지고 닫히는 지속성(시간)을 지정했다.

  const modalTranslateY = animatedValue.interpolate({
    inputRange: [0, 1],
    outputRange: [300, 0], // 모달이 열릴 때 애니메이션
  });

추가적으로 modalTranslateY 를 통해 모달의 위치 변화를 정의했다. animatedValue의 변화에 따라 모달이 아래에서 위로 부드럽게 이동하게 된다.

animatedValue는 애니메이션에 의해 변하는 값이고
interpolate 메서드는 animatedValue가 변화하는 동안, 그 값에 따라 다른 값을 계산하는 데 사용된다.

즉, 애니메이션이 진행됨에 따라 inputRange에 대응하는 outputRange 값을 생성한다.

inputRange: [0, 1] : animatedValue가 0에서 1 사이의 값을 가질 때의 범위를 지정합니다. 예를 들어, 애니메이션이 시작될 때는 animatedValue가 0일 것이고, 애니메이션이 끝날 때는 1이 됩니다.

outputRange: [300, 0] : inputRange 값에 따라 출력될 값을 지정합니다. animatedValue가 0일 때는 출력값이 300이 되고, animatedValue가 1일 때는 출력값이 0이 됩니다.

✅ 정리하면,
handleGroupPress에서 애니메이션을 실행시키고, modalTranslateY를 사용해 그 애니메이션이 모달의 위치에 반영되도록 한 것이다!


전체 코드

import React, {useState, useEffect, useRef} from 'react';
import {
  View,
  Text,
  StyleSheet,
  Image,
  TouchableOpacity,
  ScrollView,
  Modal,
  TouchableWithoutFeedback,
  Alert,
  Animated,
} from 'react-native';
import {useNavigation, useRoute} from '@react-navigation/native';
import {useSelector, useDispatch} from 'react-redux';
import {assignHashTag} from '../../api/HashTagAssign';
import {HashTagListCheck} from '../../api/HashTagListCheck';

const Filter4 = () => {
  const navigation = useNavigation();
  const route = useRoute(); // useRoute 훅을 사용하여 경로 정보 가져오기
  const {groupedImages} = route.params; // Filter3에서 전달된 데이터(여러 그룹 이미지들)
  const [modalVisible, setModalVisible] = useState(false);
  const [currentGroupId, setCurrentGroupId] = useState(null);
  const [currentGroupImages, setCurrentGroupImages] = useState([]); // 현재 그룹의 모든 이미지 저장
  const [selectedTags, setSelectedTags] = useState({}); // 각 그룹의 선택된 해시태그 저장
  const hashtagList = useSelector(state => state.HashTagReducer.hashtagList); // 해시태그 목록을 가져옴
  const dispatch = useDispatch();
  const animatedValue = useRef(new Animated.Value(0)).current; // 애니메이션 값을 초기화

  const handleNavigation = () => {
    navigation.navigate('Filter5', {
      groupedImages,
    });
  };

  // 해시태그 목록 요청
  useEffect(() => {
    dispatch(HashTagListCheck());
  }, [dispatch]);

  // 이미지 그룹 클릭할 때 호출 -> 현재 그룹 id, 이미지 저장
  const handleGroupPress = groupId => {
    setCurrentGroupId(groupId); // 선택한 그룹 ID 저장
    setCurrentGroupImages(groupedImages[groupId]); // 현재 그룹의 모든 이미지 저장
    setModalVisible(true);
    Animated.timing(animatedValue, {
      toValue: 1,
      duration: 200,
      useNativeDriver: true,
    }).start();
  };

  // 모달 닫기 함수
  const closeModal = () => {
    Animated.timing(animatedValue, {
      toValue: 0,
      duration: 120,
      useNativeDriver: true,
    }).start(() => setModalVisible(false));
  };

  // 해시태그를 선택할 때 호출 -> 선택한 해시태그 저장 및 API 호출
  const handleSelectTag = async tag => {
    const imageUrls = currentGroupImages.map(url => url.split('?')[0]);
    try {
      const requestBody = {
        imageUrls,
        hashtagId: tag.id,
      };
      const data = await assignHashTag(requestBody);
      console.log('해시태그 저장 성공:', data);

      // 해시태그 선택 상태 업데이트
      setSelectedTags(prevState => ({
        ...prevState,
        [currentGroupId]: tag.text,
      }));
    } catch (error) {
      console.error('해시태그 저장 중 오류 발생:', error);
      Alert.alert(
        'Error',
        `해시태그 저장 중 오류가 발생했습니다: ${error.message}`,
      );
    }
  };

  const modalTranslateY = animatedValue.interpolate({
    inputRange: [0, 1],
    outputRange: [300, 0], // 모달이 열릴 때 애니메이션
  });

  return (
    <View style={styles.outerContainer}>
      <ScrollView contentContainerStyle={styles.container}>
        <View style={styles.header}>
          <Image
            style={styles.step_3}
            source={require('../../assets/icon/step_3.png')}
            resizeMode="contain"
          />
          <Text style={styles.text}>
            그룹을 클릭하고 해시태그를 지정하세요!
          </Text>
        </View>

        <View style={styles.gridContainer}>
          {groupedImages.map((group, index) => (
            <React.Fragment key={index}>
              {index % 2 === 0 && index !== 0 && (
                <View style={styles.separator} />
              )}
              <View style={styles.imageContainer}>
                <TouchableOpacity
                  style={styles.imageWrapper}
                  onPress={() => handleGroupPress(index)}>
                  <Image
                    source={{uri: group[0]}} // 첫 번째 이미지를 썸네일로 사용
                    style={styles.image}
                  />
                  <Text style={styles.imageCount}>
                    {`${group.length}`} {/* 이미지 개수*/}
                  </Text>
                </TouchableOpacity>
              </View>
            </React.Fragment>
          ))}
        </View>
        <Modal
          visible={modalVisible}
          transparent={true}
          animationType="none"
          onRequestClose={closeModal}>
          <TouchableWithoutFeedback onPress={closeModal}>
            <View style={styles.modalBackground}>
              <TouchableWithoutFeedback>
                <Animated.View
                  style={[
                    styles.modalContainer,
                    {transform: [{translateY: modalTranslateY}]},
                  ]}>
                  <ScrollView contentContainerStyle={styles.hashList}>
                    {hashtagList.map(item => (
                      <TouchableOpacity
                        key={item.id.toString()}
                        style={[
                          styles.hashItem,
                          selectedTags[currentGroupId] === item.text &&
                            styles.selectedHashItem,
                        ]}
                        onPress={() => handleSelectTag(item)}>
                        <Text
                          style={[
                            styles.hashText,
                            selectedTags[currentGroupId] === item.text,
                          ]}>
                          {`#${item.text}`}
                        </Text>
                      </TouchableOpacity>
                    ))}
                  </ScrollView>
                </Animated.View>
              </TouchableWithoutFeedback>
            </View>
          </TouchableWithoutFeedback>
        </Modal>
      </ScrollView>
      <View style={styles.fixedFooter}>
        <TouchableOpacity onPress={handleNavigation}>
          <Image
            style={styles.next}
            source={require('../../assets/icon/next2.png')}
            resizeMode="contain"
          />
        </TouchableOpacity>
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  outerContainer: {
    flex: 1,
    backgroundColor: 'white',
  },
  container: {
    alignItems: 'center',
    padding: 20,
  },
  header: {
    width: '100%',
    alignItems: 'center',
    marginBottom: 20,
  },
  step_3: {
    width: 300,
    margin: 10,
  },
  text: {
    fontSize: 18,
    textAlign: 'center',
    color: 'black',
  },
  gridContainer: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    justifyContent: 'space-between',
  },
  imageContainer: {
    width: '48%', // 2개씩 배치
    marginBottom: 20,
  },
  imageWrapper: {
    position: 'relative',
  },
  image: {
    width: '95%',
    height: 150,
    borderRadius: 10,
  },
  separator: {
    width: '100%',
    height: 1,
    backgroundColor: '#ccc',
    marginVertical: 10,
  },
  modalBackground: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
  },
  modalContainer: {
    width: '55%',
    backgroundColor: 'white',
    borderRadius: 10,
    borderColor: '#F7F8CB',
    borderWidth: 5,
    shadowColor: '#000',
    shadowOffset: {width: 0, height: 2},
    shadowOpacity: 0.8,
    shadowRadius: 2,
    elevation: 5,
    paddingVertical: 5,
    justifyContent: 'center',
    alignItems: 'center',
  },
  hashList: {
    flexDirection: 'column',
    paddingVertical: 15,
    justifyContent: 'flex-start',
    alignItems: 'center',
  },
  hashItem: {
    marginVertical: 10,
    alignItems: 'center',
    paddingVertical: 10,
    paddingHorizontal: 20,
  },
  selectedHashItem: {
    backgroundColor: '#F7F8CB',
    borderRadius: 8,
  },
  hashText: {
    fontSize: 18,
  },
  selectedHashText: {
    color: 'white',
  },
  next: {
    width: 120,
    top: 15,
  },
  fixedFooter: {
    position: 'absolute',
    bottom: 20,
    width: '100%',
    alignItems: 'center',
  },
  imageCount: {
    position: 'absolute',
    bottom: 5,
    right: 13,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    color: 'white',
    fontSize: 12,
    padding: 5,
    borderRadius: 5,
  },
});

export default Filter4;

0개의 댓글