본문 바로가기

트러블슈팅

[React Native] GoToTop 버튼 이슈 - forwardRef

이슈 배경

사이드 프로젝트 작업을 하면서 FE 시스템을 개편하였다.

 

React Native를 사용하고 있었고 기존에 View, SafeAreaView, ScrollView를 직접 사용하고 있었는데

공통화된 속성을 쉽게 적용할 수 있는 ScreenContainer, ContentContainer, ScrollContentContainer를 사용해 화면을 그리도록 수정하였다.

 

ScreenContainer, ContentContainer, ScrollContentContainer 코드는 글 하단에 두었다.

이슈 내용 

문제는 ScrollContentContainer를 사용할 때 문제가 발생했다.

스크롤 상황에서 화면 최상단으로 이동하는 Go To Top 버튼에서 ref를 사용하는데

기존에 ScrollView로 사용하던 것이 잘 동작했는데 단순히 ScrollContentContainer로 바꾸자마자 동작하지 않게 되었다.

 

이슈 해결

이를 해결하기 위해 forwardRef를 활용하였다.

일반적인 방식으로는 부모 컴포넌트에서 자식 컴포넌트로 ref를 전달할 수가 없었다.

 

 

forwardRef – React

The library for web and native user interfaces

ko.react.dev

 

ref가 필요할 때가 있는데 FE 작업할 때 forwardRef는 기본적으로 알아두어야겠다.

 

Container 관련 코드

type ScreenContainerProps = {
  flexDirections?: string;
  justifyContent?: string;
  alignItems?: string;
  gap?: number;

  // Border & Shadow
  withUpperShadow?: boolean;
  withBorder?: boolean;
  withDebugBorder?: boolean;
  borderRadius?: number;
};

export const ScreenContainer = styled.SafeAreaView<ScreenContainerProps>`
  width: 100%;
  height: 100%;
  display: flex;
  background-color: ${Color.WHITE};
  gap: ${props => props.gap ?? 16}px;
  flex-direction: ${'column'};
  justify-content: ${'stretch'};
  align-items: ${'center'};
  align-content: space-around;

  /* Border & Shadow */
  ${props => props.withUpperShadow && 'box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);'}
  ${props => props.withBorder && `border: 1px solid ${Color.GRAY};`}
  ${props => props.withDebugBorder && 'border: 1px solid red;'}
  border-radius: ${props => props.borderRadius ?? 0}px;
`;

 

type ContentContainerProps = {
  // Size
  width?: string;
  height?: string;
  minHeight?: string;

  // Layout
  useHorizontalLayout?: boolean;
  flex?: number;
  gap?: number;
  absoluteTopPosition?: boolean;
  absoluteBottomPosition?: boolean;
  absoluteLeftPosition?: boolean;
  absoluteRightPosition?: boolean;
  expandToEnd?: boolean;

  // Align
  alignCenter?: boolean;
  justifyContent?: string;
  alignItems?: string;

  // Padding
  withScreenPadding?: boolean;
  withContentPadding?: boolean;
  paddingVertical?: number;
  paddingHorizontal?: number;
  paddingTop?: number;
  paddingBottom?: number;
  paddingLeft?: number;
  paddingRight?: number;

  // Border & Shadow
  withUpperShadow?: boolean;
  withBorder?: boolean;
  withDebugBorder?: boolean;
  borderRadius?: number;
  borderTopRadius?: number;
  borderBottomRadius?: number;

  // ETC
  opacity?: number;
  zIndex?: number;
  backgroundColor?: string;
  withNoBackground?: boolean;
};

export const ContentContainer = styled.View<ContentContainerProps>`
  /* Size */
  width: ${props => props.width ?? '100%'};
  height: ${props => props.height ?? 'auto'};
  ${props => props.minHeight && `min-height: ${props.minHeight}`}

  /* Flex Basic */
  display: flex;
  ${props => props.expandToEnd && 'flex-grow: 1;'}
  ${props => props.flex && `flex: ${props.flex}`}
  
  /* Layout */
  flex-direction: ${props => (props.useHorizontalLayout ? 'row' : 'column')}
  justify-content: ${props =>
    props.useHorizontalLayout ? 'space-between' : 'flex-start'};
  align-items: ${props => (props.useHorizontalLayout ? 'center' : 'stretch')};
  gap: ${props => props.gap ?? 16}px;
  ${props =>
    (props.absoluteTopPosition ||
      props.absoluteBottomPosition ||
      props.absoluteLeftPosition ||
      props.absoluteRightPosition) &&
    'position: absolute;'}
  ${props => props.absoluteTopPosition && 'top: 0;'}
  ${props => props.absoluteBottomPosition && 'bottom: 0;'}
  ${props => props.absoluteLeftPosition && 'left: 0;'}
  ${props => props.absoluteRightPosition && 'right: 0;'}

  /* Align */
  ${props =>
    props.alignCenter && 'align-items: center; justify-content: center;'}
    ${props =>
    props.justifyContent && `justify-content: ${props.justifyContent};`}
    ${props => props.alignItems && `align-items: ${props.alignItems};`}

  
  /* Padding */
  ${props => props.withScreenPadding && 'padding: 16px 20px 16px 20px;'}
  ${props => props.withContentPadding && 'padding: 16px'}
  ${props =>
    props.paddingVertical !== undefined &&
    css`
      padding-top: ${props.paddingVertical}px;
      padding-bottom: ${props.paddingVertical}px;
    `}
  ${props =>
    props.paddingTop !== undefined &&
    `padding-top: ${props.paddingTop}px;
    `}
  ${props =>
    props.paddingBottom !== undefined &&
    css`
      padding-bottom: ${props.paddingBottom}px;
    `}
  ${props =>
    props.paddingHorizontal !== undefined &&
    css`
      padding-left: ${props.paddingHorizontal}px;
      padding-right: ${props.paddingHorizontal}px;
    `}
  
  /* Border & Shadow */
  ${props =>
    props.withUpperShadow &&
    Platform.select({
      ios: `
      shadow-offset: {width: 0, height: -4px}; /* Upper shadow */
      shadow-opacity: 0.2; /* Full opacity since rgba already has the alpha */
      shadow-radius: 4px; /* Blur radius */
    `,
      android: `
        elevation: 4; /* Android shadow effect */
      `,
    })} 
  ${props => props.withBorder && `border: 1px solid ${Color.GRAY};`}
  ${props => props.withDebugBorder && 'border: 1px solid red;'}
  border-radius: ${props => props.borderRadius ?? 0}px;

  ${props =>
    props.borderTopRadius &&
    `border-top-left-radius: ${props.borderTopRadius}px;`};
  ${props =>
    props.borderTopRadius &&
    `border-top-right-radius: ${props.borderTopRadius}px;`};
  ${props =>
    props.borderBottomRadius &&
    `border-bottom-left-radius: ${props.borderBottomRadius}px;`};
  ${props =>
    props.borderBottomRadius &&
    `border-bottom-right-radius: ${props.borderBottomRadius}px;`};

  /* ETC */
  background-color: ${props => props.backgroundColor ?? Color.WHITE};
  ${props => props.withNoBackground && 'background-color: transparent;'}
  opacity: ${props => props.opacity ?? 100};
  z-index: ${props => props.zIndex ?? 0};
  overflow: hidden;
`;

type ScrollContentContainerProps = ContentContainerProps &
  RefAttributes<ScrollView> & {
    onScroll?:
      | ((event: NativeSyntheticEvent<NativeScrollEvent>) => void)
      | undefined;
    children?: ReactNode;
  };

export const ScrollContentContainer = forwardRef(
  (props: ScrollContentContainerProps, ref: React.LegacyRef<ScrollView>) => (
    <ScrollView
      ref={ref}
      onScroll={props.onScroll}
      scrollEventThrottle={100}
      style={{width: '100%'}}
      horizontal={props.useHorizontalLayout}
      showsVerticalScrollIndicator={false}>
      <ContentContainer {...(props as ContentContainerProps)}>
        {props.children}
      </ContentContainer>
    </ScrollView>
  ),
);