티스토리 뷰

반응형

 

안녕하세요. 오늘은 React native로 공통/커스텀 헤더를 관리하기 위한 리팩토링 과정을 공유하고자 합니다. 

 


React Native Header

 

App에서의 헤더는 빨간 테두리 영역입니다.

(* 시간, 와이파이 배터리상태가 있는 최상단 부분 역시 헤더에 속하며, 이 부분을 StatusBar라고 합니다. )

 

Web에서 Router와 비슷한 역할을 하는 React-native Navigator/stack 에서는 screenOptions이라는 property를 제공하는데 이 property는 header를 커스텀할 수 있는 다양한 옵션들을 제공합니다.


headerBackImage : 헤더 왼쪽 부분을 그려주는 영역으로 뒤로 가기 액션이 포함되어 있습니다.
headerRight: 헤더 우측에 해당하는 영역
headerShown: 헤더가 보여질지 아닐지에 대한 여부
…more options >> Docs

이러한 옵션을 사용하지 않더라도 스크린옵션에 headerShown===false로 둔 후 해당 스크린에서 헤더 컴포넌트를 따로 그릴 수도 있습니다.

 

 


Problems.

기존 OOM에서 헤더를 관리하던 방식입니다.

공통 헤더는 screenOptions에서 제어하고, 커스텀이 필요한 부분만 따로 컴포넌트화 하여 사용하고 있었습니다.

 

import React from 'react';

function ExampleStackNavigation() {
	...
  return (
    <Example.Navigator
      screenOptions={({ route }) => ({
        headerBackImage: () =>
          route.name !== 'ExampleHome' && (
            <TouchableOpacity style={{ marginLeft: isAndroid ? 0 : 16 }}>
              <CustomIcon name="ArrowLeftWhiteIcon" size={24} color={'black'} />
            </TouchableOpacity>
          ),
        headerRight: () => (
          <HStack>
            <TouchableOpacity
              onPress={() => navigation.navigate('Search')}
              style={{ marginRight: 16 }}
            >
              <CustomIcon name="Search24Icon" color={'black'} size={24} />
            </TouchableOpacity>
          </HStack>
        ),
        headerBackTitleVisible: false,
        headerShadowVisible: false,
        headerTitleAlign: 'center',
        headerTitleStyle: {
          fontWeight: '700',
          fontSize: 18,
          lineHeight: 27,
        },
      })}
    >

	 ...
    </Example.Navigator>
  );
}

export default ExampleStackNavigation;

 

 

진행하고 있는 프로젝트는 5-6개의 Navigation Stack을 가지고 모두 위와 같은 방식으로 공통 헤더가 관리되고 있었습니다. 모아 두고 보니 아래의 옵션들이 각 Stack마다 불필요하게 반복되고 있었습니다.

    headerBackTitleVisible: false,
    headerShadowVisible: false,
    headerTitleAlign: 'center',
    headerTitleStyle: {
       fontWeight: '700',
       fontSize: 18,
       lineHeight: 27,
    },

 

 

또 각 스크린에 직접 헤더를 그리는 경우에 각기 다른 디자인의 Header가 따로 관리되고 있다보니 IOS / Android 별로 혹은 Tab별로 헤더의 높이나 크기가 미세하게 다르게 되는 이슈가 있었습니다.

 

아래처럼 다른 UI이더라도 screenOptions에서 제공하는 properties처럼

동일한 사이즈 안에서 Left, Text(Center) , Right 영역의 구분이 되는 컴포넌트 제어가 필요해 보였습니다.

 

올바른 Header UI

 

 


Refactoring

헤더를 공통으로 관리하는 일은 꼭 필요하다는 판단이 들었고, 어떤 방식이 좋을지 고민하였습니다.

 

크게 공통 헤더와 커스텀 헤더로 나누었으며 공통적으로 들어가는 속성들은 useScreenOptions라는 hook을 만들어 Navigator의 screenOptions에 적용하였고, CustomHeader는 @Layout내에 폴더를 생성하여 관리하였습니다.

 

1. CommonHeader

1-1 useScreenOptions.ts

공통적으로 반복되던 옵션들을 getScreenOptions 함수로 리턴 시켜줍니다. 타입은 사용할 Stack 컴포넌트에서 해당되는 타입을 받아옵니다.

import { Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { ParamListBase, RouteProp } from '@react-navigation/native';
import { StackNavigationOptions } from '@react-navigation/stack';

export type StackNavigationOptionFn<T extends ParamListBase> = (props: {
  route?: RouteProp<T, keyof T>;
  navigation?: any;
}) => StackNavigationOptions | undefined;

export type StackOptionsType<T extends ParamListBase> =
  | StackNavigationOptions
  | StackNavigationOptionFn<T>;

const useScreenOptions = <T extends ParamListBase>() => {
  const { top } = useSafeAreaInsets();
  const isAndroid = Platform.OS === 'android';

  const getScreenOptions: StackNavigationOptionFn<T> = ({ route }) => {
    return {
      headerBackTitleVisible: false,
      headerShadowVisible: false,
      headerTitleAlign: 'center',
      headerTitleStyle: {
        paddingLeft: isAndroid ? 16 : 0,
        fontWeight: '700',
        fontSize: 18,
        lineHeight: 27,
      },
      headerStyle: {
        height: 60 + top,
      },
      headerLeftContainerStyle: { paddingLeft: isAndroid ? 0 : 16 },
      headerRightContainerStyle: { paddingRight: 16 },
    };
  };

  return { getScreenOptions };
};

export default useScreenOptions;

 

 

1-2 Usage

스프레드 연산자로 객체를 뿌려주고 각 Stack마다 필요한 옵션들이 있다면 추가해주면 됩니다.

import React from 'react';

// import common header fragments
import BackButtonHeaderLeft from '@components/common/@Layout/CustomHeader/_fragments/BackButtonHeaderLeft';
import SearchHeaderRight from '@components/common/@Layout/CustomHeader/_fragments/SearchHeaderRight';

//import hook
import useScreenOptions from '@hooks/useScreenOptions';

const Example = createStackNavigator<ExampleStackParamList>();

function ExampleStackNavigation() {
  const navigation = useNavigation<ExampleScreenNavigationProp>();

  /** 해당 Stack ParamList를 타입으로 보내줍니다. */
  const { getScreenOptions } = useScreenOptions<ExampleStackParamList>();

  const navigateToSearchScreen = () => {
    navigation.navigate('ExampleTab', {
      screen: 'Search',
      params: {
        type: 'example',
      },
    });
  };

  return (
    <Example.Navigator
      initialRouteName="ExampleHome"
      screenOptions={({ route }) => ({
        ...getScreenOptions({ route }),
        headerBackImage: () =>
          route.name !== 'ExampleHome' && <BackButtonHeaderLeft />,
        headerRight: () => (
          <SearchHeaderRight
            type={'example'}
            onPressSearchIcon={navigateToSearchScreen}
          />
        ),
      })}
    >
      . . .
    </Example.Navigator>
  );
}

export default ExampleStackNavigation;

 

 

2. CustomHeader

 

2-1 CustomHeader.tsx

 

Props 설명

isBackButton : 이전 버튼 있을 경우
headerRightEl: 우측 헤더 영역
children: header text영역
onlyChildren: header 전체 영역 커스텀 필요할 경우

 

 

import React, { PropsWithChildren } from 'react';
import { Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { Box, HStack, View } from 'native-base';

import { IHStackProps } from 'native-base/lib/typescript/components/primitives/Stack/HStack';

import { StackNavigationOptions } from '@react-navigation/stack';

import BackButtonHeaderLeft from './_fragments/BackButtonHeaderLeft';

interface CustomHeaderProps extends IHStackProps {
  options?: StackNavigationOptions;
  headerText?: string | number;
  isBackButton?: boolean;
  headerRightEl?: JSX.Element;
  onlyChildren?: boolean;
}
function CustomHeader({
  children,
  isBackButton,
  headerRightEl,
  onlyChildren,
  ...basisProps
}: PropsWithChildren<CustomHeaderProps>) {
  const { top } = useSafeAreaInsets();
  const isAndroid = Platform.OS === 'android';
  console.log('top', top);
  return (
    <View
      w={'100%'}
      px={'16px'}
      h={'60px'}
      mt={isAndroid ? `${top}px` : 0}
      mb={'10px'}
    >
      <HStack
        w={'100%'}
        justifyContent={'space-between'}
        alignItems={'center'}
        h={'60px'}
        {...basisProps}
      >
        {onlyChildren ? (
          children
        ) : (
          <>
            {isBackButton ? <BackButtonHeaderLeft /> : <EmptyBox />}
            {children}
            {headerRightEl ? headerRightEl : <EmptyBox />}
          </>
        )}
      </HStack>
    </View>
  );
}

export function EmptyBox() {
  return <Box w="24px" h="24px" />;
}

export default CustomHeader;

 

 

2-2 BackButtonHeaderLeft.tsx

import React from 'react';
import { Platform, TouchableOpacity } from 'react-native';

import { useNavigation } from '@react-navigation/native';

import CustomIcon from '@components/common/CustomIcon';

const BackButtonHeaderLeft = ({
  onPressBackBtn,
}: {
  onPressBackBtn?: () => void;
}) => {
  const isAndroid = Platform.OS === 'android';
  const navigation = useNavigation();

  return (
    <>
      <TouchableOpacity
        style={{ marginRight: 16 }}
        onPress={onPressBackBtn ? onPressBackBtn : () => navigation.goBack()}
      >
        <CustomIcon name="ArrowLeftWhiteIcon" size={24} color={'black'} />
      </TouchableOpacity>
    </>
  );
};

export default BackButtonHeaderLeft;

 

2-3 Usage

import React from 'react';

import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';

import CustomHeader from '@components/common/@Layout/CustomHeader/CustomHeader';
import SearchHeaderRight from '@components/common/@Layout/CustomHeader/_fragments/SearchHeaderRight';

type LoungeStackNavigationProp = StackNavigationProp<CommunityStackParamList>;

function LoungeSelector() {
  ...
	const navigation = useNavigation<LoungeStackNavigationProp>();

  //해당 element에 필요한 함수 생성
  const navigateToSearchWithCloseModal = () => {
    setIsLoungeSelectorModalVisible(false);
    navigation.navigate('Search', { type: 'community' });
  };

  return (
    //헤더에 해당하는 부분을 CustomHeader로 감싸줍니다.
    <CustomHeader
      headerRightEl={
        <SearchHeaderRight
          type="community"
          onPressSearchIcon={navigateToSearchWithCloseModal}
        />
      }
    >
      // header text 영역(center 영역)
      <Pressable onPress={toggleLoungeSelector}>
       ...
      </Pressable>
    </CustomHeader>
  );
}

export default React.memo(LoungeSelector);

 

 


#Note

CommonLayout.tsx

혹시 스크린마다 적용되는 공통 레이아웃이 있으신가요? 공통관리를 위해서 레이아웃 생성은 필수라고 생각이 드는데요. 저희는 이런 식으로 공통 레이아웃을 그렸습니다. 각 플랫폼별로 필요한 값이 조건별로 필요하다면 해당 prop만 생성 후 선언해주면 되겠죠? 

* Prop

withCustomHeader 
공통적으로 헤더 밑으로 10px의 margin이 있어 적용시켰으나 CustomHeader.tsx는 commonLayout의 children에 속하기 때문에 해당 컴포넌트 밑으로 marginBottom =”10px”을 따로 주었습니다.

때문에 CommonLayout.tsx에서 CustomHeader을 사용할 경우 paddingBottom을 설정해주지 않는 props가 필요합니다.

 

// CommonLayout.tsx
..
import CustomStatusBar from '../CustomStatusBar';

interface CommonLayoutProps extends IViewProps {
  bgColor?: IColors | string;
  p?: number | string;
  h?: string | number;
  withCustomHeader?: boolean;
}

export function CommonLayout({
  bgColor,
  p,
  children,
  withCustomHeader,
  ...basisProps
}: PropsWithChildren<CommonLayoutProps>) {
  const isAndroid = Platform.OS === 'android';
  return (
    <SafeAreaView
      style={{
        width: '100%',
        backgroundColor: bgColor,
      }}
    >
      <CustomStatusBar
        topZero
        barStyle={bgColor === '#FFFFFF' ? 'dark-content' : 'light-content'}
        backgroundColor="transparent"
      />
      <View
        p={p}
        h={'100%'}
        pt={withCustomHeader ? undefined : '10px'}
        {...basisProps}
      >
        {children}
      </View>
    </SafeAreaView>
  );
}

export default React.memo(CommonLayout);

 

 

 


여기까지 리액트 네이티브에서 공통 / 커스텀 헤더를 관리에 대해서 알아보았습니다. 기존에 헤더가 따로 놀거나 중복되는 코드가 없어지니 속이 정말 시원해졌습니다. 

 

초기 개발에 혹은 이렇게 중간에라도 공통관리의 필요성이 느껴진다면, 시간이 들더라도 리펙토링 과정을 거치는 것이 유지보수에도 도움이 될 것이라 생각됩니다. 

반응형