고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

Expo 갤러리와 카메라 구현하기

고윤태 2023. 7. 4. 16:47

개요

안녕하세요 이번에 작성하게 될 내용은 제가 react-native Expo 환경에서 갤러리와 카메라를 어떤 식으로 구현을 했는지 작성해 보겠습니다.


개발 환경

React-native(expo) + TypeScript로 진행되었습니다.


구현하게 된 계기

요즘 진행 중인 토이 프로젝트에서 프로필 사진을 선택하거나 유저가 글을 작성할 때 사진을 선택하거나 촬영하는 기능이 필요해서 구현하게 되었습니다.

 

제가 구현했어야 하는 UI, UX는 이렇습니다.

 

사진을 보시면 아시겠지만 사진 목록이 나오고 사진을 선택할 수 있어야 했습니다.

또한 선택 가능한 사진의 개수가 제한이 걸려있다면 Modal을 통해 사용자에게 안내를 해줘야 합니다.

 

처음에는 expo 환경에서 사람들이 자주 사용하는 라이브러리 중 하나인 Expo ImagePicker를 사용하려 했습니다.

하지만 아쉽게도 제가 원하는 디자인의 커스텀이 불가능하다는 점과 가장 큰 문제는 limit를 제한을 할 수 있는 플랫폼이 IOS만 가능했습니다.

 

출처(https://docs.expo.dev/versions/latest/sdk/imagepicker/)

현재 제 환경에서 기획과 디자인의 요구사항 대로 맞춰 개발하기 위해 직접 구현을 택하게 되었습니다.

 

디자이너 분과 기획자분에게 양해를 구하는 것도 괜찮은 선택이었지만
이 프로젝트의 목적성 자체가 Expo 환경을 경험해 보고자 진행한 프로젝트로서 Expo의 기능들을 활용하여 진행해 보고 싶었습니다.

기능 추출

 

기능을 크게 분리해 봤을 

  • 사진 목록
  • 사진 촬영

이렇게 2개로 나눌 수 있습니다. 여기서 각각의 세분화를 해본다면

  • 사진 목록 (main)
    • 사진 권한 묻기
    • 사진 목록 불러오기
    • 사진 선택하기
      • 사진 선택 제한 개수가 1개인 경우
        • 사진 선택 시 이미 선택된 사진이 존재해도 replace(변환)이 된다.
      • 사진 선택 제한 개수가 여러 개인 경우
        • 사진 선택 제한 개수와 선택된 사진의 개수가 같은 경우 다른 사진을 선택하려면 사용자에게 알림을 보여준다.
    • 완료 버튼 클릭 시 선택된 사진 callback으로 반환
  • 사진 촬영 (main)
    • 카메라 권한 묻기
    • 카메라 전면 후면 변경
    • 사진 촬영
    • 사진 촬영 시 삭제 또는 저장하기 선택
      • 저장 : 갤러리에 저장
      • 삭제 : 삭제

정리한 이 기능에 맞춰 개발을 진행해 보겠습니다.


구현

Gallery.tsx (screen)

import React, { useState, useEffect } from 'react';
import {
  View,
  FlatList,
  Image,
  Dimensions,
  Pressable,
  Text,
  Linking,
} from 'react-native';
import * as MediaLibrary from 'expo-media-library';
import useDidUpdate from '../hooks/useDidUpdate';

import Header from '../components/ui/Header';
import Color from '../constants/color';
import TYPOS from '../components/ui/typo';
import Camera from '../components/ui/icons/Camera';
import { StackScreenProps } from '@react-navigation/stack';
import { RootStackParamList } from '../types/navigation';
import { useRecoilState } from 'recoil';
import { LoadingState } from '../store/atoms';
import Check from '../components/ui/icons/Check';
import useModal from '../hooks/useModal';
import Dialog from '../components/ui/Dialog';

export type GalleryScreenProps = StackScreenProps<
  RootStackParamList,
  'Gallery'
>;

const Gallery = ({ navigation, route }: GalleryScreenProps) => {
  const [photos, setPhotos] = useState<MediaLibrary.Asset[]>([]);
  const [isLoading, setIsLoading] = useRecoilState(LoadingState);
  const [hasNextPage, setHasNextPage] = useState<boolean>(false);
  const [endCursor, setEndCursor] = useState<string | undefined>(undefined);
  const { limit, callback, selectedPhotoIds = [] } = route.params;

  const { isVisible, openModal, closeModal } = useModal();

  const [selectedPhotos, setSelectedPhotos] = useState<MediaLibrary.Asset[]>([
    ...selectedPhotoIds,
  ]);

  const requestMediaLibraryPermissions = async () => {
    const { status } = await MediaLibrary.requestPermissionsAsync();

    if (status !== 'granted') {
      console.log('Media library permission denied');
      Linking.openSettings();
      return;
    }

    fetchPhotos();
  };

  useEffect(() => {
    requestMediaLibraryPermissions();
  }, []);

  useDidUpdate(() => {
    if (!photos.length) {
      fetchPhotos();
    }
  }, [photos]);

  const initPhotos = () => {
    setPhotos([]);
  };

  const fetchPhotos = async (cursor?: string | undefined) => {
    setIsLoading(true);

    try {
      const {
        assets,
        endCursor: newEndCursor,
        hasNextPage,
      } = await MediaLibrary.getAssetsAsync({
        mediaType: MediaLibrary.MediaType.photo,
        first: 20,
        after: cursor,
      });

      setPhotos((prevPhotos) => [...prevPhotos, ...assets]);
      setEndCursor(newEndCursor);
      setHasNextPage(hasNextPage);
      setIsLoading(false);
    } catch (error) {
      console.log('Error fetching photos:', error);
    }
  };

  const decideSelectedIndicator = (index: number) => {
    if (limit === 1) {
      return <Check size={16} color={Color.white} />;
    } else {
      return (
        <Text style={[TYPOS.body3, { color: Color.white }]}>{index + 1}</Text>
      );
    }
  };

  const renderPhotoItem: React.FC<{ item: MediaLibrary.Asset }> = ({
    item,
  }) => {
    const windowWidth = Dimensions.get('window').width;
    const numColumns = 3;
    const itemWidth = windowWidth / numColumns;
    const selectedIndex = [...selectedPhotos].findIndex(
      ({ id }) => item.id === id
    );
    const isSelected = selectedIndex !== -1;

    return (
      <Pressable
        style={{ width: itemWidth, aspectRatio: 1, position: 'relative' }}
        onPress={() => {
          if (limit === 1) {
            setSelectedPhotos([item]);
            return;
          }

          if (isSelected) {
            const clone = [...selectedPhotos];
            clone.splice(selectedIndex, 1);
            setSelectedPhotos(clone);
          } else {
            if (selectedPhotos.length < limit) {
              const clone = [...selectedPhotos];
              clone.push(item);
              setSelectedPhotos(clone);
            } else {
              openModal();
            }
          }
        }}
      >
        <View
          style={{
            position: 'absolute',
            width: 16,
            height: 16,
            zIndex: 1,
            elevation: 1,
            borderRadius: 16,
            backgroundColor: isSelected ? Color.primary700 : Color.white,
            borderColor: isSelected ? Color.primary700 : Color.neutral3,
            borderWidth: 1,
            right: 12,
            top: 12,
            justifyContent: 'center',
            alignItems: 'center',
          }}
        >
          {isSelected && decideSelectedIndicator(selectedIndex)}
        </View>
        <Image
          source={{ uri: item.uri }}
          style={{ flex: 1, resizeMode: 'cover' }}
        />
      </Pressable>
    );
  };

  const renderCameraButton = () => (
    <Pressable
      onPress={handleTakePhoto}
      style={{
        width: itemWidth,
        aspectRatio: 1,
        backgroundColor: Color.white,
        flexDirection: 'column',
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <Camera size={32} />
      <Text style={[TYPOS.body3, { color: Color.black }]}>사진 촬영</Text>
    </Pressable>
  );

  const onCompleteHandler = () => {
    callback([...selectedPhotos]);
    navigation.goBack();
  };

  const handleEndReached = () => {
    if (hasNextPage) {
      fetchPhotos(endCursor);
    }
  };

  const handleTakePhoto = () => {
    navigation.navigate('Camera', { callback: initPhotos });
  };

  const windowWidth = Dimensions.get('window').width;
  const numColumns = 3;
  const itemWidth = windowWidth / numColumns;

  return (
    <>
      <Header
        title='최근 항목'
        rightContent={
          <Pressable onPress={onCompleteHandler}>
            <Text
              style={[
                TYPOS.small,
                {
                  color:
                    selectedPhotos.length > 0
                      ? Color.primary900
                      : Color.neutral2,
                },
              ]}
            >
              완료
            </Text>
          </Pressable>
        }
      />
      <FlatList
        data={[null, ...photos]}
        keyExtractor={(item, index) => index.toString()}
        numColumns={numColumns}
        renderItem={({ item, index }) =>
          item
            ? renderPhotoItem({ item })
            : index === 0
            ? renderCameraButton()
            : null
        }
        onEndReached={handleEndReached}
        onEndReachedThreshold={0.1}
      />
      <Dialog isOpened={isVisible}>
        <Dialog.Content
          content={`사진은 최대 ${limit}장만 선택할 수 있습니다.`}
        />
        <Dialog.Buttons
          buttons={[
            {
              label: '확인',
              onPressHandler: () => {
                closeModal();
              },
            },
          ]}
        />
      </Dialog>
    </>
  );
};

export default Gallery;

Gallery.tsx 설명

 

앨범 권한 요청하기

  const requestMediaLibraryPermissions = async () => {
    const { status } = await MediaLibrary.requestPermissionsAsync();

    if (status !== 'granted') {
      console.log('Media library permission denied');
      Linking.openSettings();
      return;
    }

    fetchPhotos();
  };

  useEffect(() => {
    requestMediaLibraryPermissions();
  }, []);

useEffect를 이용하여 requestMediaLibraryPermissions라는 함수를 호출하여 사진에 대한 권한을 요청 확인합니다.
사진 권한을 허용하지 않은 상태라면 openSettings를 활용하여 설정으로 사용자를 이동시킵니다.
권한이 허용이 되어 있다면 fetchPhotos라는 함수를 호출하여 사진을 불러오게 합니다.


사진 불러오기

  const fetchPhotos = async (cursor?: string | undefined) => {
    setIsLoading(true);

    try {
      const {
        assets,
        endCursor: newEndCursor,
        hasNextPage,
      } = await MediaLibrary.getAssetsAsync({
        mediaType: MediaLibrary.MediaType.photo,
        first: 20,
        after: cursor,
      });

      setPhotos((prevPhotos) => [...prevPhotos, ...assets]);
      setEndCursor(newEndCursor);
      setHasNextPage(hasNextPage);
    } catch (error) {
      console.log('Error fetching photos:', error);
    } finally {
      setIsLoading(false);
    }
  };

endCursor와 hasNextPage는 MediaLibrary.getAssetAsync에서 반환해 주는 값입니다.

현재 사용자의 갤러리에 사진이 총 몇 장 있는지 알 수 없는 상태이기에 hasNextPage, endCursor를 이용하여 infinite scroll을 구현했습니다.


infinite scroll 구현

  const handleEndReached = () => {
    if (hasNextPage) {
      fetchPhotos(endCursor);
    }
  };


      <FlatList
        data={[null, ...photos]}
        keyExtractor={(item, index) => index.toString()}
        numColumns={numColumns}
        renderItem={({ item, index }) =>
          item
            ? renderPhotoItem({ item })
            : index === 0
            ? renderCameraButton()
            : null
        }
        onEndReached={handleEndReached}
        onEndReachedThreshold={0.1}
      />

좀 전에 fetchPhotos에서 받아온 hasNextPage 값과 cursor 값을 사용하여 이런 식으로 구현했습니다.
hasNextPage가 true 면 더 불러올 사진이 존재하는 것임으로 endCursor 값을 넘겨서 마지막 커서 위치부터 받아오도록 했습니다.


카메라를 통해 사진 촬영 시 찍은 사진을 불러오는 방법

  useDidUpdate(() => {
    if (!photos.length) {
      fetchPhotos();
    }
  }, [photos]);

  const initPhotos = () => {
    setPhotos([]);
  };
  
  const handleTakePhoto = () => {
    navigation.navigate('Camera', { callback: initPhotos });
  };
useDidUpdate는 제가 만든 customHook입니다. useEffect와 같은 동작을 하지만 최초 마운트 시에는 effect가 실행되지 않습니다.

Camera(Screen)으로 이동 시 callback 함수를 사진 목록을 초기화하는 함수를 넘겨서 Camera(Screen)이 unMount 시 callback 함수를 요청하여 사진 목록을 초기화하고 useDidUpdate를 이용하여 사진 목록을 새로 불러와서 추가된 사진을 불러올 수 있도록 구현했습니다.


사진 선택하기

        onPress={() => {
          if (limit === 1) {
            setSelectedPhotos([item]);
            return;
          }

          if (isSelected) {
            const clone = [...selectedPhotos];
            clone.splice(selectedIndex, 1);
            setSelectedPhotos(clone);
          } else {
            if (selectedPhotos.length < limit) {
              const clone = [...selectedPhotos];
              clone.push(item);
              setSelectedPhotos(clone);
            } else {
              openModal();
            }
          }
        }}

사진의 선택 limit가 1이면 선택된 사진이 존재하든 안 하든 덮어쓰도록 구현했습니다.
limit가 1이 아니라면 선택이 되어 있다면 선택된 사진 목록에서 삭제
선택이 되어 있지 않다면 선택된 사진의 개수와 limit를 비교하여 limit보다 작다면 선택된 사진 목록에 추가
아니라면 사용자에게 limit 개수만큼 선택이 가능하다 Modal을 보여주도록 구현했습니다.


Camera.tsx (screen)

import React, { useEffect, useRef, useState } from 'react';
import { View, Pressable, Image, Text } from 'react-native';
import { Camera, CameraCapturedPicture, CameraType } from 'expo-camera';
import * as MediaLibrary from 'expo-media-library';
import Header from '../components/ui/Header';
import Container from '../components/layout/Container';
import Button from '../components/ui/buttons/Button';
import { RootStackParamList } from '../types/navigation';
import { StackScreenProps } from '@react-navigation/stack';
import ArrowSwap from '../components/ui/icons/ArrowSwap';
import Color from '../constants/color';
import TYPOS from '../components/ui/typo';

export type CameraScreenProps = StackScreenProps<RootStackParamList, 'Camera'>;

const CameraScreen = ({ navigation, route }: CameraScreenProps) => {
  const cameraRef = useRef<Camera | null>(null);
  const [hasPermission, setHasPermission] = useState<boolean | null>(null);
  const [type, setType] = useState<CameraType>(CameraType.back);
  const [photo, setPhoto] = useState<CameraCapturedPicture | null>(null);
  const { callback } = route.params;

  const changeType = () => {
    if (type === CameraType.front) {
      setType(CameraType.back);
    } else {
      setType(CameraType.front);
    }
  };

  const takePicture = async () => {
    if (hasPermission) {
      if (cameraRef.current) {
        const photo = await cameraRef.current.takePictureAsync();
        setPhoto(photo);
      }
    } else {
      const { status } = await Camera.requestCameraPermissionsAsync();
      setHasPermission(status === 'granted');
    }
  };

  const savePicture = async () => {
    if (photo) {
      const { status } = await MediaLibrary.requestPermissionsAsync();

      if (status === 'granted') {
        const asset = await MediaLibrary.createAssetAsync(photo.uri);
        retakePicture();
        try {
          await MediaLibrary.createAlbumAsync('Camera', asset, false);
        } catch (error) {
          console.error(
            'An error occurred while saving the photo to camera roll:',
            error
          );
        }
      }
    }
  };

  const retakePicture = () => {
    setPhoto(null);
  };

  useEffect(() => {
    const getCameraPermission = async () => {
      const { status } = await Camera.requestCameraPermissionsAsync();
      setHasPermission(status === 'granted');
    };

    getCameraPermission();
  }, []);

  useEffect(() => {
    return () => {
      callback();
    };
  }, []);

  return (
    <>
      <Header title='사진 촬영' />
      <Container>
        {hasPermission && (
          <>
            {!photo ? (
              <>
                <Camera
                  style={{ flex: 1, position: 'relative' }}
                  ref={cameraRef}
                  type={type}
                >
                  <View
                    style={{
                      position: 'absolute',
                      right: 16,
                      top: 16,
                    }}
                  >
                    <Pressable style={{}} onPress={changeType}>
                      <ArrowSwap size={36} color={Color.white} />
                    </Pressable>
                  </View>
                </Camera>
                <Button label='촬영' onPressHandler={takePicture} />
              </>
            ) : (
              <>
                <View
                  style={{
                    flex: 1,
                    justifyContent: 'center',
                    alignItems: 'center',
                  }}
                >
                  <Image
                    source={{ uri: photo.uri }}
                    style={{ flex: 1, width: '100%' }}
                    resizeMode='contain'
                  />
                  <View
                    style={{
                      flexDirection: 'row',
                      width: '100%',
                      justifyContent: 'space-between',
                      marginTop: 16,
                      paddingHorizontal: 16,
                    }}
                  >
                    <Pressable onPress={retakePicture}>
                      <Text style={TYPOS.headline3}>다시 찍기</Text>
                    </Pressable>
                    <Pressable onPress={savePicture}>
                      <Text style={TYPOS.headline3}>저장</Text>
                    </Pressable>
                  </View>
                </View>
              </>
            )}
          </>
        )}
      </Container>
    </>
  );
};
export default CameraScreen;

Camera.tsx 설명

 

카메라 권한 묻기

  useEffect(() => {
    const getCameraPermission = async () => {
      const { status } = await Camera.requestCameraPermissionsAsync();
      setHasPermission(status === 'granted');
    };

    getCameraPermission();
  }, []);

useEffect를 이용하여 getCamraPermission이라는 함수를 호출하여 카메라에 대한 권한을 요청 확인합니다.

카메라 권한을 허용한 상태라면 hasPermission이라는 state의 true를 할당하여 화면에 Camera를 렌더링 합니다.


카메라 전면 후면 변경

  const [type, setType] = useState<CameraType>(CameraType.back);
  
  const changeType = () => {
    if (type === CameraType.front) {
      setType(CameraType.back);
    } else {
      setType(CameraType.front);
    }
  };

type이라는 state를 정의하여 카메라의 전환 아이콘을 press 시 changeType 함수를 호출하여 카메라 전면 사용, 후면 사용을 변경해주고 있습니다.


사진 촬영

  const takePicture = async () => {
    if (hasPermission) {
      if (cameraRef.current) {
        const photo = await cameraRef.current.takePictureAsync();
        setPhoto(photo);
      }
    } else {
      const { status } = await Camera.requestCameraPermissionsAsync();
      setHasPermission(status === 'granted');
    }
  };

카메라 권한을 확인하여 Camera에 넘긴 ref를 이용하여 사진을 촬영 후 미리 보기를 위해 photo라는 state에 값을 set 해주고 있습니다.


사진 촬영 시 삭제 또는 저장하기 선택

  const [photo, setPhoto] = useState<CameraCapturedPicture | null>(null);

  const savePicture = async () => {
    if (photo) {
      const { status } = await MediaLibrary.requestPermissionsAsync();

      if (status === 'granted') {
        const asset = await MediaLibrary.createAssetAsync(photo.uri);
        retakePicture();
        try {
          await MediaLibrary.createAlbumAsync('Camera', asset, false);
        } catch (error) {
          console.error(
            'An error occurred while saving the photo to camera roll:',
            error
          );
        }
      }
    }
  };

  const retakePicture = () => {
    setPhoto(null);
  };

//JSX
              <>
                <View
                  style={{
                    flex: 1,
                    justifyContent: 'center',
                    alignItems: 'center',
                  }}
                >
                  <Image
                    source={{ uri: photo.uri }}
                    style={{ flex: 1, width: '100%' }}
                    resizeMode='contain'
                  />
                  <View
                    style={{
                      flexDirection: 'row',
                      width: '100%',
                      justifyContent: 'space-between',
                      marginTop: 16,
                      paddingHorizontal: 16,
                    }}
                  >
                    <Pressable onPress={retakePicture}>
                      <Text style={TYPOS.headline3}>다시 찍기</Text>
                    </Pressable>
                    <Pressable onPress={savePicture}>
                      <Text style={TYPOS.headline3}>저장</Text>
                    </Pressable>
                  </View>
                </View>
              </>

사진 촬영 시 photo에 값을 set을 하게 된다면 이미지를 미리 보기를 제공하고 유저는 다시 찍기 또는 저장을 선택할 수 있다.

다시 찍기를 press 시 retakePicture를 통해 photo 값을 null로 초기화하여 미리 보기 사진이 아닌 카메라가 보이게 된다.

저장하기를 press 시 권한이 허용되어 있다면 MediaLibrary를 통하여 앨범에 저장하게 된다.


구현 화면

사진 목록
사진 선택
카메라
카메라 촬영


후기

이 글을 작성 시 나름 개인적인 생각으로는 무게감(?)이 있는 기능이라 판단하여 글이 길어질 것을 예상을 하고 있었지만 이렇게까지 길어질 줄은 몰랐습니다.

실제로 이 부분을 개발할 때 당시에 개발 도중 + 완료 시에도 기획자님과 디자이너님의 피드백에 의하여 수정사항이 많이 생겨서 변경이 많이 일어나곤 했습니다.

다행스럽게도 제가 이런 큰 코드(?)를 구현할 때는 단일책임의 원칙을 더더욱 잘 지키려고 하는 편이기에 새로운 기능들을 기존 기능에 피해 없이 유연하게 추가할 수 있었습니다.

 

나름 글도 깔끔하게 정리해서 공유드리고자 작성을 해봤는데 제가 봤을 때도 가독성이 떨어진다 생각하여 글을 잘 쓰게 되는 시점에서 수정해 볼 생각입니다.

이런 긴 글일수록 읽어주시는 분들이 막힘 없이 재밌게 읽으실 수 있게 작성을 해야 한다고 생각하는데 아직까지 어색한 부분이 많네요

더 노력해 보겠습니다.

 

좋은 글일지는 모르겠지만 누군가에게는 도움이 되는 글을 적고 싶습니다.

항상 읽어주셔서 감사합니다.