React Native에서 배경색, 폰트 색상 등의 기본값(default)은 app.json 파일에서 설정할 수 있다.

 

그러나 Stack.Navigator 를 사용하여 라우팅 구조를 만들면 이 기본값이 무시된다.

 

어떻게 수정할 수 있을까?

 

app.json에서 배경색을 설정해주었지만-
Stack.Navigator를 적용하자 app.json의 배경색이 무시된다.

 

 

 

 

 

Stack.Screen 태그의 속성에 주목해보자.

   <Stack.Screen
      name="Profile"  // 이 스크린의 이름
      component={ProfileScreen}	// 표시할 내용
      options={{
        title: "User Profile",	// 화면에 표시될 제목
        headerStyle: { backgroundColor: "#6200EA" },
        headerTintColor: "#FFFFFF",
        contentStyle: { backgroundColor: "#FAFAFA" },
        headerTitleAlign: "center",
        gestureEnabled: true
      }}
    />

 

주석에 표기한 것과 같이, Screen의 대표적인 속성을 알아두면 편리하다.

 

  • name : 해당 스크린의 이름. 다른 Screen에서 Navigating 할 때 작성할 좌표.
  • component : 표시할 컴포넌트의 내용
  • options : 객체 형태로 받는다. 전반적인 디자인 관련 속성을 모아둔 객체.
    • title : 헤더에 표시될 제목
    • headerStyle: 하위 객체에서 헤더의 스타일 결정
    • headerTintColor: 헤더 색. 디폴트는 #007AFF 
    • contentStyle: 스크린의 콘텐츠 영역에 대한 스타일을 하위 객체에서 결정. (전역적이지는 않음)
    • gestureEnabled: 스와이프를 통해 이전 화면으로 돌아가는 기능의 on, off

 

 

그러나 이렇게 스타일링을 하고 다른 스크린으로 넘어가면 이 스타일이 모든 스크린에 적용되지 않은 것을 확인할 수 있다.

이 때는 일일이 복사 + 붙여넣기 하기보다, Stack.Screen을 감싸고 있는 상위 Stack.Navigator에 이 options를 옮겨줄 수 있다.

<Stack.Navigator
  screenOptions={{
    headerStyle: { backgroundColor: "#6200EA" },  // 헤더 스타일
    headerTintColor: "#FFFFFF",  // 헤더 글씨 및 아이콘 색상
    contentStyle: { backgroundColor: "#FAFAFA" },  // 화면 배경 색상
    headerTitleAlign: "center",  // 헤더 제목 정렬
    gestureEnabled: true  // 스와이프 제스처 활성화
  }}
>

  <Stack.Screen 
    name="Profile" 
    component={ProfileScreen} 
    options={{ title: "User Profile" }}  // 특정 화면별 옵션 추가
  />
  
</Stack.Navigator>

 

이렇게 하면 화면을 옮겨다니더라도 스타일이 모두 적용된다.

 

 

 

 

에뮬레이터, 또는 Expo로 연결된 모바일 기기를 가로로 기울인다고 해서 화면이 자동으로 돌아가지는 않는다.

landscape 모드를 적용하고자 한다면, 이에 따라 설정과 스타일링을 바꿔줘야 한다.

 

 

 

우선 app.json파일을 열고, orientation 속성을 "default"로 바꿔준다.

  "expo": {
    "name": "navigate",
    "slug": "navigate",
    "version": "1.0.0",
    "orientation": "default",	//기본값은 portrait
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },

 

 

참고로 orientation은 아래 값을 가질 수 있다.

  • default : 가로, 세로 모드 모두 지원
  • portrait : 세로 모드로 고정
  • landscape : 가로 모드로 고정
  • portrait_up : 세로 모드이되, 상단이 항상 위쪽을 향함
  • portrait_down : 세로 모드이되, 상단이 아래쪽을 향함
  • landscape_left : 가로 모드이되, 기기의 왼쪽이 위쪽을 향함
  • landscape_right : 가로 모드이되, 기기의 오른쪽이 위쪽을 향함

 

 

app.json의 변경 사항을 저장했다면, 에뮬레이터를 회전시켰을 때 정상적으로 반응하는 것을 확인할 수 있다.

컴포넌트로 돌아가 스타일링을 해보자.

 

 


 

 

 

현재 모드가 가로 모드인지 세로 모드인지 어떻게 추적할 수 있을까?

기본적으로 현재 화면의 가로, 세로 길이를 측정하고, 길이를 비교하여 조건을 적용하면 된다.

 

 

 

가로, 세로 길이 재는 방법은 두가지가 있다.

Dimensions

import { Dimensions, Text, View } from 'react-native';	//임포트 잊지 않을 것

const ScreenInfo = () => {
 const { width, height } = Dimensions.get('window');
  const isWideView = width > height;


  return (
    <View>
      <Text>{ isWideView ? 가로 모드 : 세로 모드 }</Text>
    </View>
  )
}

 

 

Dimensions의 get 메소드를 통해 가로와 세로의 길이를 구한다.

  • Dimensions.get('window') : 상태바나 네비게이션 바를 무시하고, 사용자가 상호작용할 수 있는 앱의 영역을 측정
  • Dimensions.get('screen') : 상태바와 네비게이션 바를 모두 포함한 전체 화면의 길이 측정
    • 다만 초기값만 가져온다는 단점이 있다.
    • 즉, 기기를 반복적으로 뒤집더라도 이 상태 변화를 감지하지 못하기 때문에 별도의 eventListener를 생성해야 한다.

 

 

 

useWindowDimensions()

import { useWindowDimensions, Text, View } from 'react-native';	//임포트 잊지 않을 것

const ScreenInfo = () => {
 const { width, height } = useWindowDimensions();
  const isWideView = width > height;


  return (
    <View>
      <Text>{ isWideView ? 가로 모드 : 세로 모드 }</Text>
    </View>
  )
}

 

useWindowDimenstions() 는 Dimensions와 유사하지만, 가로와 세로 변화를 실시간으로 감지하고 업데이트 해준다는 장점이 있다.

 

 

 


 

 

 

이것을 현재 실습 중인 프로젝트에 적용해보았다.

스타일링은 우선 StyleSheet (내장 라이브러리)를 사용했다.

 

import { StyleSheet, View, Text, Pressable, useWindowDimensions } from "react-native";

interface ProjectProps {
  title: string;
  color: string;
}

export default function Project({ title, color }: ProjectProps) {
    const { width, height } = useWindowDimensions()
    const isWideView = width > height;
  
    return (
    <View style={[styles.gridItem, isWideView && styles.gridItemWide]}>
      <Pressable>
        <View>
          <Text>{title}</Text>
        </View>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  gridItem: {
    flex: 1,
    margin: 16,
    height: 150,
    borderWidth: 1,
    borderColor: 'red',
  },

  gridItemWide: {
    height: 180,
    margin: 20,
  }
});

 

 

에뮬레이터를 회전할 때마다 덮어쓴 height와 margin이 잘 적용 및 해제되는 것을 확인할 수 있다.

 

 

 

 


 

 

 

작은 문제가 생겼다.

위 영상처럼 현재 데이터 리스트를 카드 형태로 FlatList에 순서대로 뿌려주고 있었다.

세로뷰에서는 2줄 씩 출력되던 것을 가로뷰에서는 3줄로 출력되도록 수정하고 싶었다.

따라서 아래와 같이 해당 출력 컴포넌트의 코드를 수정했다.

import { StyleSheet, FlatList, useWindowDimensions } from "react-native";
import { CATEGORIES } from "../assets/data/dummy";
import CategoryGridTile from "../components/CategoryGridTile";

function renderCategoryItem(itemData: any) {
  return (
    <CategoryGridTile title={itemData.item.title} color={itemData.item.color} />
  );
}

function CategoryScreen() {
    const { width, height } = useWindowDimensions()
    const isWideView = width > height;
  

  return (
    <FlatList
      data={CATEGORIES}
      keyExtractor={(item) => item.id}
      renderItem={renderCategoryItem}
      numColumns={isWideView ? 3 : 2}	// 출력될 줄 수에 삼항연산자를 적용했다.
    />
  );
}

export default CategoryScreen;

 

 

 

그러나 저장 후 가로뷰로 에뮬레이터를 뒤집는 순간, 아래 에러가 발생했다.

더보기

Invariant Violation: Changing numColumns on the fly is not supported. Change the key prop on FlatList when changing the number of columns to force a fresh render of the component.

위 에러는 FlatList의 numColumns를 실시간으로 변경할 수 없다는 뜻이다.

 

 

 

이런 경우는 해당 FlatList를 재렌더링을 하는 방식으로 줄 수를 바꿔야 한다.

 

 

 

그렇다면 어떻게 재렌더링을 트리거할 수 있을까?

기기를 회전했을 때 해당 리스트의 key를 바꿔서 다시 받아오는 방법으로 재렌더링이 가능하다.

 

//전략
  return (
    <FlatList
      data={CATEGORIES}
      keyExtractor={(item) => item.id}
      renderItem={renderCategoryItem}
      numColumns={isWideView ? 3 : 2}	// 삼항연산자 그대로 사용
      key={isWideView ? 'wide' : 'portrait'}  // FlatList의 key를 바꿔서 강제 리렌더링
    />
  );

 

 

위처럼 item 각각의 key는 이전과 마찬가지로 keyExtractor로 부여한다.

그리고 FlatList 덩어리 자체에 key를 추가하고, 이것을 가로, 세로 모드가 바뀔 때 다르게 적용하여 다시 렌더링되게 한다.

 

 

 

 

 

 

위 방법은 유저가 화면을 반복적으로 돌리는 경우 계속해서 컴포넌트 리렌더링이 일어나기 때문에 성능에 영향을 줄 수 있다.

 

다만, FlatList는 많은 데이터를 열거하더라도 보여지는 화면 영역에 대한 데이터를 우선 처리하기 때문에 성능 문제를 조금 줄일 수 있다.

만일 해당 컴포넌트가 복잡한 계산을 요하는 함수로 이루어져 있다면, useMemo() useCallback() 사용을 고려해보아도 좋겠다.

 

'React Native' 카테고리의 다른 글

Navigator 스타일링  (0) 2024.10.15
숫자 맞추기 게임 - 히스토리 상태 받아오기  (1) 2024.10.03
숫자 맞추기 게임  (8) 2024.09.30
React Native 기초  (0) 2024.09.26

 

프로그램이 이전에 던진 정수를 화면에 나타내어 쌓아볼 수 있을까?

이 추측 히스토리(?)를 배열 형태로 만들고, 상태 관리를 해보자.

 

const [rounds, setRounds] = useState([initialGuess]) // 배열에 최초 정수 하나로 시작

 

참고로 initialGuess를 설정하는 방법은 바로 이전 글에 메모해두었다.

 

 

 

이 상태를 유저가 up, down 버튼을 눌렀을 때, 즉 nextGuessHandler가 동작할 때마다 추가되도록 설정해준다.

const nextGuessHandler = () => {

// 중략

  const newRandomNum = generatFeNumBetween(
    minBoundary,
    maxBoundary,
    currentGuess
  );

  setCurrentGuess(newRandomNum);
    
  setRounds((prevRound) => [newRandomNum, ...prevRound]);
};

 

 

다시 말해 initialGuess가 50이라고 가정하고, 차례로 5, 12, 80, 40 이라는 랜덤 정수가 생성됐다면 배열은 아래처럼 변한다.

  1. [50] 
  2. [5, 50]
  3. [12, 5, 50]
  4. [80, 12, 5, 50]
  5. [40, 80, 12, 5, 50]

 

 

막연히 unshift 메소드를 사용하려 시도했는데, 오히려 스프레드 연산자를 사용하는 편이 직관적이어 보인다.

 

 

 

+

아래처럼 순서를 바꾼다면 배열의 맨 뒤에 추가되도록 만들 수도 있다.

  setRounds((prevRound) => [...prevRound], newRandomNum]);

 

 

 

이제 화면에 배열이 업데이트될 때마다 추가되도록 작성한다.

  <View>
   {rounds.map(rounds => <Text key={rounds}>{rounds}</Text>)}
  </View>

 

 

 

 

 

유저가 입력한 1 이상 99 이하의 정수 를 컴퓨터가 맞춰내는 게임을 만들고 있다.

 

  1. 유저가 입력한 정수를 제외하고 (즉, 프로그램은 첫번째 시도에서 정답을 맞출 수는 없다)
  2. 랜덤 정수를 생성한다.
  3. 이에 따라 유저가 Up or Down 을 입력하면
  4. 프로그램은 그 랜덤 정수를 최대값 or 최소값 으로 업데이트하여
  5. 새로운 랜덤 정수를 생성한다.
  6. 이를 정답을 맞출 때까지 반복한다.

 

(임포트 문은 생략했다)

// min, max : 최소값, 최대값
// exclude: 제외할 정수 (최초 입력값 & 이미 생성한 랜덤값)
const generateNumBetween = ( min: number, max: number, exclude: number): number => {
  const randomNum = Math.floor(Math.random() * (max - min)) + min; // 랜덤 정수 생성

  //컴퓨터는 첫 시도에서 답을 맞출 수 없음.
  if (randomNum === exclude) {
    //생성된 숫자가 제외돼야하는 정수와 같다면 다시 생성
    return generateNumBetween(min, max, exclude);
  } else {
    return randomNum;
  }
};

 

 

매개변수 min, max, exclude는 숫자형이고, 이들을 통해 실행된 함수 generateNumBetween은 숫자형을 반환함을 명시한다.

 

 

랜덤 정수를 생성한 방법을 보다 자세히 설명해보자면 아래와 같다.

 const randomNum = Math.floor(Math.random() * (max - min)) + min
 
 
 
 max - min // 범위 크기 설정 (예: 1부터 100이라면 범위는 99)
 
 Math.random() // 0부터 1사이의 난수 생성
 
 Math.random() * (max - min) // 범위 내의 난수 생성
 
 Math.floor() // 소수점 아래 자리 반올림
 
 Math.floor(Math.random() * (max - min)) // 생성한 랜덤 난수의 소수점 아래 자리 반올림
 
 + min // min 기준으로 원하는 범위로 이동 (현재는 100 - 1 = 99 -> 0부터 99 이므로, 1부터 100으로 교정)
 
 Math.floor(Math.random() * (max - min)) + min // 완성

 

 

이제 게임 화면을 구성한다.

// 타입 선언
interface GameScreenProps {
  userNumber: number;
  onGameOver: () => void;
}

  // 최소값, 최대값 변수 설정
  let minBoundary = 1;
  let maxBoundary = 100;

export default function GameScreen({ userNumber, onGameOver }: GameScreenProps) {
  // 첫번째로 추측할 랜덤 정수
  const initialGuess = generateNumBetween(minBoundary, maxBoundary, userNumber);

  const [currentGuess, setCurrentGuess] = useState(initialGuess);

  useEffect(() => {
    if (currentGuess === userNumber) {
      onGameOver()	// 정답을 맞춘다면 게임오버 화면으로
    }
  }, [currentGuess, userNumber, onGameOver]);	

  //유저가 +,-를 올바른 방향으로 사용하도록 유도
  const nextGuessHandler = (direction: string) => {
    if (
      (direction === "lower" && currentGuess < userNumber) ||
      (direction === "greater" && currentGuess > userNumber)
    ) {
      Alert.alert("정말인가요?", "다시 한 번 생각해보세요.", [
        { text: "아차차", style: "cancel" },
      ]);
      return;	// return을 쓰지 않으면 alert창이 뜨는 동시에 새로운 정수가 생성된다.
    }

    //수를 올릴 방향. 키울지 줄일지?
    if (direction === "lower") {
      maxBoundary = currentGuess;
    } else {
      minBoundary = currentGuess + 1;
    }
    console.log(minBoundary, maxBoundary); //범위값이 계속 업데이트 되는 것을 확인할 수 있다
    
    // 새로운 랜덤 정수 생성
    const newRandomNum = generateNumBetween( minBoundary, maxBoundary, currentGuess );
    setCurrentGuess(newRandomNum); //업데이트된 최소값, 최대값, 최근에 썼던 정수를 배제
  };

 

 

useEffect문의 의존성 배열에 currentGuess, userNumber, onGameOver 가 들어가는 이유?

currentGuess

  • currentGuess와 userNumber가 일치하는지 여부를 확인하여 게임을 종료해야 한다.
  • 따라서 currentGuess가 바뀔 때마다 if (currentGuess === userNumber) 를 검사한다.

userNumber

  • userNumber가 바뀐다는 것은 새로운 게임이 시작되었다는 것을 의미하므로, (유저가 게임 도중 바꾸는 경우 포함( 이 로직을 다시 실행해야한다

onGameOver

  • onGameOver 는 정답 맞췄을 때 실행되는 콜백함수이다.
  • 따라서 onGameOver의 함수 구현이 변경되었다면 useEffect문을 수행해야 한다.

 

 

 

 

마지막으로 리턴문을 작성한다.

  return (
    <View>
      <NumberContainer>{currentGuess}</NumberContainer>
      <View>
        <Text>Higher or Lower?</Text>
        <View>
          <Button onPress={nextGuessHandler.bind(this, "lower")}>
            -
          </Button>
          <Button onPress={nextGuessHandler.bind(this, "greater")}>
            +
          </Button>
        </View>
      </View>
    </View>
  );
}

 

 

 

 


 

 

 

 

그런데 여기서 프로그램이 정답을 맞춘 순간 Render Error: Maximun call stack size exceeded 가 반환된다.

 

 

 

 

이 문제는 렌더링 순서와 관련이 있다.

 

기본적으로 React의 렌더링 순서는 아래와 같다.

  1. 컴포넌트의 모든 함수를 실행
  2. useState 초기화
  3. useEffect 실행

 

이 경우에 대입하여 설명하자면, 렌더링은 아래와 같은 순서로 실행된다.

  1. generateNumBetween 함수를 실행하여 initialGuess 생성
  2. initialGuess 값을 받아 상태를 초기화
  3. initialGuess 와 currentGuess 에 초기값을 설정한 후 useEffect 함수 실행

 

결론

  1. useEffect문은 currentGuess가 변경될 때마다 실행되어 게임 종료 조건을 확인하는 것이 최종 목적인데-
  2. 정답을 고른 순간, nextGuessHandler가 실행되고 setCurrentGuess를 호출하여 상태를 업데이트하고 리렌더링을 준비한다.
  3. 그러나 정답은 minBoundary와 maxBoundary가 같은 경우이므로, 유효한 범위가 없기 때문에 nextGuessHandler는 새로운 랜덤 정수를 생성할 수 없다.
  4. 이 때 generateNumBetween는 유효한 범위를 찾을 때까지 재귀적으로 계속 호출되게 된다.
  5. 따라서 useEffect문보다 먼저 실행되는 generateNumBetween함수가 무한 루프에 걸려 useEffect문은 실행되지 못하고, 이에 따라 재렌더링이 미처 이루어지지 못한다.

 

 

 

해결 방법

절대적으로 안전한 방법일 것 같지는 않아, 추후 더 고민해보기

//이전 코드
const initialGuess = generateNumBetween(minBoundary, maxBoundary, userNumber);

//바꾼 코드
const initialGuess = generateNumBetween(1, 100, userNumber);

 

위처럼 초기 최소값, 최대값을 고정하여 하드코딩 하는 방법이 있다.

이렇게 하면 generateNumBetween은 항상 유효한 범위를 가질 수 있다.

따라서 유효한 범위는 지키는 선에서 에러를 반환하던 함수를 해결하고, useEffect문을 수행해 화면 전환을 이룰 수 있다.

 

React.ts 프로젝트를 거의 마무리하며, 비로소 타입, 컴포넌트, 상태 관리 등 웹 개발에서의 가장 기본적인 개념을 알게 됐다. 

다음 프로젝트는 또다른 프레임워크를 경험해보기 위해 React Native를 익히려 한다.

 

 

지금까지 사용해온 React.ts는 웹 애플리케이션을 만들 수 있지만, 모바일 애플리케이션을 직접 만들 수는 없다. 

모바일 애플리케이션을 개발하기 위해서는 추가적인 프레임워크가 필요한데, React Native가 대표적인 예이다.

주요 특징은 아래와 같다.

 

  • 브라우저가 아닌 네이티브 모바일 UI 컴포넌트 (Android, iOS)를 사용한다. 
  • 두가지 OS를 동시에 개발할 수 있다.
  • React와 코드 작성 방식이 유사하다.
  • 네이티브 모듈을 통한 성능 최적화가 가능하다.

 

 

그렇다면 회사에서 본 QA팀처럼 거의 모든 디바이스를 가지고 있어야 하나...? 나는 아이폰 한대 뿐인데...

 

 

 

 

 

 

이러한 문제점을 해결하기 위해 다양한 디바이스 환경을 쉽게 확인하도록 해주는 상위 프레임워크가 있다.

기본적으로 React Native CLI(Command Line Interface)와 Expo로 나뉜다.

 

 

React Native CLI

장점

  • 네이티브 코드에 접근 및 커스터마이징이 가능하다.
  • 더 많은 네이티브 기능(카메라, 지도 등) 및 모듈을 사용할 수 있다.

단점

  • 초기 설정이 복잡하다.
  • 별도의 Android와 iOS 빌드를 설정하고 배포해야 한다.

 

Expo

장점

  • 초기 구성이 이미 구성되어 있어 가벼운 앱을 빠르게 만들기에 유용하다.
  • 앱 푸쉬, 애플 로그인 등 기능이 탑재되어 있다.
  • Expo Go를 통해 실제 디바이스 환경을 빠르게 확인할 수 있다.

단점

  • 네이티브 코드를 수정할 수 없어 개발의 한계가 있다.
  • Expo에서 지원하지 않는 라이브러리를 사용할 수 없다.

 

 

 


 

 

 

 

이제 프로젝트를 생성해보자.

npm install -g expo-cli

 

이번에도 타입스크립트 탬플릿을 사용하려 한다.

npx create-expo-app MyNewApp --template blank-typescript

 

생성된 프로젝트를 실행시키기 위해서는 아래 명령어를 입력한다.

npx expo start

 

 


 

 

 

로컬 서버를 실행시키니 EMFILE: too many open files 에러가 발생했다. 

 brew update
 brew install watchman

 

위 방법으로 해결할 수 있다는 글이 많았으나, 나의 경우 해결되지 않았다.

프로젝트 폴더로 이동하여 node_modules 폴더를 삭제하고, 아래 명령어로 다시 설치했더니 정상적으로 실행할 수 있었다.

Stack Overflow

npm install

 

 

 

 

 

+ Recent posts