다짐) React Native WebView 셋팅하기

2ast·2023년 8월 20일
2

React Native WebView Handling

웹뷰란 앱에서 웹페이지를 띄우는 기술을 말한다. 보통 모바일로 구현하기 까다로운 기능을 가진 페이지높은 빈도로 재배포되어야 하는 페이지들을 웹뷰로 띄우는 결정을 하며, 코드 재사용성과 유지보수성 측면에서 모바일 대비 강점이 있다. 때문에 전략적으로 일부 기능을 웹뷰로 구현하기도하며, 다짐 또한 일부 스크린을 웹뷰로 대체하고 있다. 이번에는 다짐에서 사용하는 기본 웹뷰 컴포넌트인 DgWebView가 어떻게 구성되어 있는지, 그리고 실제 웹뷰 스크린에서는 이를 어떻게 사용하고 있는지 살펴보려고 한다.

DgWebView

headers

const headers = {
    ...(token, version 같은 defaultHeaders)
    ...props.extraHeaders,
}
...
<WebView
  	source={{
  		headers: props.headerDisabled ? {} : headers
  	}}
/>

headers는 웹뷰를 띄울 때 웹으로 전달하는 데이터들의 묶음이다. token, app version과 같이 기본적으로 모든 웹뷰에 넘겨주는 default headers와 props로 넘겨받은 extraHeaders를 병합하여 정의해줬다. headers를 사용할 때 주의할 점은 반드시 android에서 webView의 method가 get이어야 한다는 점이다. 웹뷰에 데이터를 넘겨주는 방법은 headers와 body 두가지가 있는데, android 기준 webview의 method가 get인 경우 headers만 사용가능하고 post인 경우 body만 사용 가능하다.
그리고 WebView에 headers를 넘겨주는 부분을 보면 props로 headerDisabled를 받고 있는걸 볼 수 있다. s3에 올라간 일부 웹의 경우 Authorization header가 들어왔을 때 접근을 제한하는 케이스가 있어서 이를 우회하기 위해 아예 모든 headers를 없애는 prop을 추가하는 방법으로 임시조치 해놓은 결과다.

onNavigationStateChange

const onNavigationStateChange = (state: WebViewNativeEvent) => {

  props?.onNavigationStateChange(state)

  if (isIOS) {
    props?.handleWebviewURLStateChange?.(state.url);
  }
};

const onLoadProgress={(event:WebViewProgressEvent) => {
	if (isAndroid) {
		handleWebviewURLStateChange?.(event.nativeEvent.url);
    }
}}

<WebView
	  onNavigationStateChange={onNavigationStateChange}
      onLoadProgress={onLoadProgress}
/>

onNavigationStateChange는 webView의 navState가 바뀔때마다 호출되는 함수다. 여기서 navState란 webView의 url, canGoBack 등 말 그대로 웹뷰의 현재 정보를 담고있는 객체을 의미한다.
위 코드를 보면 handleWebviewURLStateChange라는 함수도 눈에 띄는데, 이 함수는 url이 바꼈을 때만 호출된다. 특이한점은 ios, android가 호출되는 조건이 다른데, 이는 android의 경우 간혹 url이 바꼈음에도 onNavigationStateChange가 호출되지 않는 케이스가 존재하기 때문에 url 변경을 보장하기 위해 onLoadProgress로 호출 위치를 옮긴 결과다.

onShouldStartLoadWithRequest

 const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => {
   if (
     event.url.startsWith('http://') ||
     event.url.startsWith('https://')
   ) {
     return true;
   } else if (isAndroid) {
     NativeModules.RNCWebView.onShouldStartLoadWithRequestCallback(
       false,
       event.lockIdentifier,
     ); //첫 로드시 onShouldStartLoadWithRequest를 무조건 true를 반환해주기 때문에 이를 방지하기 위해 실행하는 함수라고 한다.
     SendIntentAndroid.openAppWithUri(event.url)
       .then(isOpened => {
       if (!isOpened) {
         SendIntentAndroid.openChromeIntent(event.url)
           .then(isOpened => {
           console.log(isOpened);
           if (!isOpened) {
             Alert.alert('실행에 실패했습니다. 설치가 되어있지 않은 경우 설치하기 버튼을 눌러세요.');
           }
           return false;
         })
           .catch(err => {
           console.log(err);
         });
       }
     })
       .catch(err => {
       console.log(err);
     });

     return false;
   } else if (isIOS) {
     Linking.openURL(event.url).catch(() => {
       Alert.alert(
         '앱 실행에 실패했습니다. 설치가 되어있지 않은 경우 설치하기 버튼을 눌러주세요.',
       );
     });
     return false;
   }

   return true;
 };

<WebView
  onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
/>

onShouldStartLoadWithRequest는 webView를 load할 때 실행되는 함수다. boolean을 return type으로 갖고 있으며, true를 반환하면 load를 계속 진행하고, false를 반환하면 load를 중단한다. 코드가 뭔가 길어보이지만 실제로 하는 일은 웹뷰를 열거나 앱을 실행하는 역할을 수행할 뿐이다.

 const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => {
   if (
    //웹 브라우저의 url이면
   ) {
	//로딩을 계속해라
   } else if (android라면) {
     //web url이 아니니까 app schema겠지
     //android에서 그 앱을 실행하는 함수를 실행해라
     //앱 실행에 실패하면 설치하라는 alert도 띄워줘라
     //웹 로드가 아니니 당연히 로딩은 중단해라
   } else if (ios라면) {
     //ios에서 앱을 실행하는 함수를 실행해라
     //앱 실행에 실패하면 설치하라는 alert도 띄워줘라
     //웹 로드가 아니니 당연히 로딩은 중단해라
   }
   return true;
 };

onMessage

const executeDeeplink = useDeeplink();

const onMessage = (event: WebViewMessageEvent) => {
  props.onMessage(event);

  const deeplink = JSON.parse(event.nativeEvent.data)?.deeplink;

  if (deeplink) {
    executeDeeplink(deeplink);
  }
};

const onFocus =  useCallback(()=>ref?.current?.postMessage('refetch'),[])

useFocusEffect(() => {
  onFocus()
});

<WebView
  onMessage={onMessage}  
/>

onMessage는 웹으로부터 message를 받았을 때 실행되는 함수다. 공통 웹뷰 컴포넌트에는 deeplink를 실행하는 함수를 기본적으로 넣어놨고, 그 외의 부분은 각 웹뷰 스크린에서 커스텀해서 props로 넘겨준 함수를 바인딩 시켜줬다.
useFocusEffect를 보면 웹뷰가 포커싱 될 때마다 postMessage로 refetch를 보내고 있다. 만약 웹뷰 쪽에서 refetch에 대한 핸들링을 onMessage로 정의해놨다면 매번 포커싱 될 때마다 웹뷰 데이터를 최신화해서 받아볼 수 있다.

Loading

const [loading, setLoading] = useState(true);

return(
  <View style={{flex: 1}}>
    <WebView
      onLoad={() => setLoading(false)}
    />
    {loading ? <SpinnerLoadingOverlay hideBackground /> : null}
  </View>
)

초기 웹뷰 로딩 시 로딩 시간이 길어질수록 사용자가 흰색 화면만 보고 있어야하는 시간이 길어질 수밖에 없다. 이때 사용성을 개선하기 위해 loading state를 만들어서 loading spinner를 노출하도록 해주었다.

ref

React Native WebView는 postMessage 등 일부 기능을 사용하기 위해 ref를 셋팅해주어야 한다. 이를 위해 DgWebView를 forwardRef로 정의해주었다.

const DgWebView = forwardRef<WebView, DgWebViewProps>(function DgWebView(
  props,
  forwardedRef,
) {
  const currentRef = useRef<WebView>(null);
  const ref = forwardedRef ?? currentRef;
  
  return (
    <View style={{flex: 1}}>
      <WebView
        ref={ref}
  	  />
    </View>
  )
}

allowsBackForwardNavigationGestures

<WebView
  allowsBackForwardNavigationGestures={
    props.allowsBackForwardNavigationGestures ?? true
  }
/>

allowsBackForwardNavigationGestures는 ios에서 navigating handling gesture를 webView의 navigating에도 적용할 것인지 설정하는 prop이다. ios에서는 기본적으로 화면을 왼쪽에서 오른쪽으로 슬라이드하여 뒤로가기를 실행하는 등의 제스쳐를 지원한다. 웹뷰의 경우 모바일 navigator 기준으로는 단일 스크린이기 때문에, 웹뷰 상의 route stack이 몇개가 쌓여있든 뒤로가기 제스쳐를 실행하는 순간 웹뷰 자체가 종료되어 버린다. 하지만 allowsBackForwardNavigationGestures를 활성화하면 슬라이드 제스쳐를 사용해서 웹페이지 상의 뒤로가기를 실행할 수 있게 되기 때문에 훨신 쾌적한 사용성을 제공할 수 있다.

그외 props

지금까지 webview 기본 셋팅을 알아봤는데, 이 외에도 react native webview는 textZoom, originWhitelist, mixedContentMode, javaScriptEnabled 등등 굉장히 다양한 props들을 제공하고 있다. webview를 구현하면서 마주하는 무궁무진한 문제상황들은 props docs를 참고하여 해결해나가면 된다.

WebView Screen


const WebViewScreen = () => {

  const url = `${ENV.WEB_VIEW_BASE_URL}/sample}`;

  const [navState, setNavState] = useState<WebViewNativeEvent>();
  const webViewRef = useRef<WebView>(null);

  const {navigation} = useDgNavigation();
  const theme = useTheme();

  useSetWebViewBackButton({
    title: 'Sample',
    webViewRef,
    navState,
  });
  
 const onNavigationStateChange=(state) => setNavState(state)

  return (
    <SafeAreaView style={{flex: 1, backgroundColor: theme.colors.themeColor}}>
      <DgWebView
        ref={webViewRef}
        url={url}
        extraHeader={{
          'X-Dagym-Extra-Header': 'someThing',
        }}
        onNavigationStateChange={onNavigationStateChange}
      />
    </SafeAreaView>
  );
};

export default MyMembershipScreen;

DgWebView를 사용하는 WebView screen의 예시를 가져와봤다. DgWebView의 props를 생각하면서 필요한 내용을 커스텀해서 넣어주면 된다. 다만 여기서 눈여겨 볼것은 useSetWebViewBackButton이라는 custom hook이다. 이 hook은 webView의 header를 셋팅해주는 동시에 android 뒤로가기 버튼을 webView 뒤로가기에 바인딩 해주는데 사용된다.

export interface SetWebViewBackButtonHookProps {
  title?: string;
  navState?: WebViewNativeEvent;
  canNotGoBackAction?: () => void;
  canGoBackAction?: () => void;
  webViewRef: React.RefObject<WebView<{}>>;
}

const useSetWebViewBackButton = ({
  title,
  navState,
  canNotGoBackAction,
  canGoBackAction,
  webViewRef,
}: SetWebViewBackButtonHookProps) => {
  const {navigation} = useDgNavigation();
  const theme = useTheme();

  const onBackPress = useCallback(() => {
    const canGoBack = navState?.canGoBack;

    if (canGoBack) {
      canGoBackAction ? canGoBackAction() : webViewRef?.current?.goBack();
      return true;
    } else {
      canNotGoBackAction ? canNotGoBackAction() : navigation.goBack();
      return true;
    }
  }, [
    navState?.canGoBack,
    webViewRef,
    canNotGoBackAction,
    navigation,
    canGoBackAction,
  ]);

  useAndroidBackHandler({onBackPress});

  useLayoutEffect(() => {
    navigation.setOptions({
      headerLeft: () => <HeaderBackButton onPress={onBackPress} />,
      headerTitle: () =>
        title && (
          <DgText fontType={'subTitle_17_bold'}>{title}</DgText>
        ),
    });
  }, [navigation, theme.colors.textDefault, title, onBackPress]);
};
export default useSetWebViewBackButton;

코드를 보면 onBackPress라는 함수를 정의하고, 이 함수를 header의 뒤로가기 버튼과 useAndroidBackHandler라는 훅으로 넘겨주고 있다.

export const useAndroidBackHandler = ({onBackPress}: {onBackPress: () => boolean}) => {

  useEffect(() => {
    BackHandler.addEventListener('hardwareBackPress', onBackPress);

    return () => {
      BackHandler.removeEventListener('hardwareBackPress', onBackPress);
    };
  }, [onBackPress]);

};

이렇게 함으로써 android 물리버튼으로 웹뷰의 네비게이팅을 제어할 수 있게 되었다.

profile
React-Native 개발블로그

4개의 댓글

comment-user-thumbnail
2023년 9월 7일

잘 읽었습니다! 혹시 (event: ShouldStartLoadRequest) 이런 타입은 어디서 가져오나요? 문서에 타입 정의가 안보여서요ㅠ

1개의 답글