[웹뷰 깨부숴보기] 웹뷰를 앱처럼 사용하자!

개똥이개똥이
6 min read

웹뷰 라이브러리로 선정된 flutter_inappwebview.

과연 이 친구는 웹을 앱스럽게 만들어줄 수 있을까?

웹을 앱으로 만드는 과정을 하나씩, 차근히 밟아가 보자!

웹뷰를 앱처럼 만들자!

아 깨알팁(인가?)인데 안드로이드는 localhost 로 접근하기 위해서 URL에 localhost:portNumber 를 작성하면 에러가 발생하게 된다. localhost를 대신하여 10.0.2.2 를 작성해주자.

웹뷰에게, 아니 정확히는 웹에게 앱에서 동작됨을 알려주자!

데이터 왔어요!

기본적으로 웹으로 제작된 사이트는 반응형으로 모바일을 구현했다고 하더라도 엄연히 앱과는 차이가 나는 디자인을 갖추게 된다. 가장 대표적인 것이 상단의 GNB에 과도한 기능이 몰려있다는 점인데, 앱의 경우 이를 App Bar와 Bottom Navigation Bar라고 하는 요소에 각각 나누어 UI를 그리게 된다. 내가 디자인 전공도 아니고, 디자인에 대한 지식이 풍족한 사람도 아니기 때문에 정확히는 어떤 이유로 굳이 분리하는가 라는 생각이 들기도 하지만, 조금 생각해보자면 아마 좁은, 한정된 스크린 안에 시인성 좋게 담아두기 위함 아닐까싶다.

뭐 아무튼 웹으로 웹뷰로 실행되고 있음을 알리는 방법은 간단한데, 지난 포스팅에서 적어둔 것 처럼 웹뷰-웹 간의 양방향 통신을 활용하여 접속 여부를 불리언 값으로 넘겨주면 된다.

나는 이를 받아서 Context API를 활용하여 App 전역에서 활용했는데, 전역상태관리를 활용하거나 일일이 페이지마다 불러오거나 하는 등의 방법은 필요에 따라 선택적으로 설계하면 될 것 같다.

import ...

let isFlutter = false

// 앱에서 전달하는 데이터를 받기
// 하지만 SSR 환경에서는 window 객체가 "undefined"가 될 수 있기 때문
// 필수적으로 undefined에 체크를 해야함.

if (typeof window !== 'undefined') {
  window.addEventListener('flutterInAppWebViewPlatformReady', event => {
    // 또 window 객체는 현시점에 flutter_inappwebview를 가지고 있지 않기때문에
    // `// ts-ignore` 옵션은 꼭 해줘야 타입스크립트 린트에러가 발생하지 않음.

    // @ts-ignore
    window.flutter_inappwebview.callHandler('flutterChannel').then(result => {
      isFlutterResult = result
    })
  })
}

export const FlutterContext = createContext()

function MyApp(///) {
  return (
    <FlutterContext.Provider value={isFlutter}>
        <Component {///} />
    </FlutterContext.Provider>
  )
}

export default MyApp

이렇게 전역에서 활용 가능한 상태를 통해 GNB를 제거하고 App Bar와 Bottom Navigation Bar로 인해 틀어진 스타일을 고쳤다.

반응형 모바일 페이지와 웹뷰 비교

짜잔. GNB 를 제거하고 App Bar, Bottom Navigation Bar를 추가한 것만으로도 한결 웹이 아닌 앱에 근접한 모습을 보이게 되었다.

앱 스타일의 새로고침을 처리해주자!

웹의 경우 웹브라우저의 상단바를 통해 새로고침을 하거나 단축키를 통해 새로고침 할 수 있다. 하지만 앱의 경우 그런 상단바가 존재하지 않는다. 그런 이유로 특정액션을 취했을 때 리프레시하게 해주는 기능을 만들어뒀는데, 대부분의 앱들이 위에서 아래로 쓸어내리는 동작을 통해 리프레시 동작을 수행하게 한다.

flutter_inappwebview 에서도 역시나 이런 기능을 제공하고 있는데, InAppWebView 위젯의 세팅으로 설정하지 않고, PullToRefreshController를 통해 세팅을 하게 된다.

회춘한 캡틴 아메리카

PullToRefreshController

  • 선언
// 하단처럼 controller를 선언해준다.
PullToRefreshController? pullToRefreshController;
  • 컨트롤러 세팅
PullToRefreshSettings pullToRefreshSettings = PullToRefreshSettings(
  color: const Color(0xFF3A5A5E),
);
bool pullToRefreshEnabled = true;

컨트롤러 세팅은 문서에 따르면, attributedTitle, backgroundColor, color, distanceToTriggerSync, enabled, size, slingshotDistance 를 설정할 수 있다고 한다.

  • 컨트롤러가 실행될 때 어떤 동작을 할 지 정하기
// 아래 코드는 공식문서 상에서 제공하는 리프레시 동작 코드이다.
// 그냥 복사 붙여넣기하고 사용해도 무방하지만 하나씩 차근히 뜯어보자면

@override
  void initState() {
    super.initState();

    // 웹일 때는 당연스럽게도 동작이 수행될 필요 없기 때문에
    // 웹여부에 따라 컨트롤러 동작을 추가한다.
    pullToRefreshController = kIsWeb
        ? null
        // PullToRefreshController 위젯에 세팅과 동작 함수를 작성하면 되는데
        : PullToRefreshController(
            // 2번에서 작성한 컨트롤러 세팅이 이곳에 들어간다.
            settings: pullToRefreshSettings,
            // Refresh 시 수행할 동작으로
            onRefresh: () async {
            // 안드로이드 디바이스일 때
              if (defaultTargetPlatform == TargetPlatform.android) {
                // 웹뷰 컨트롤러를 리로드 하는 동작을 수행하고
                webViewController?.reload();
              } else if (defaultTargetPlatform == TargetPlatform.iOS) {
                // iOS 디바이스일 때는 Url을 다시 한번 요청하는데,
                // webViewController가 가지고 있는 URL, 
                // 즉 현재 웹뷰 URL로 다시 요청한다.
                webViewController?.loadUrl(
                  urlRequest: URLRequest(
                    url: await webViewController?.getUrl(),
                  ),
                );
              }
            },
          );
  }
  • inAppWebView에 RefreshController 전달하기
@override
  Widget build(BuildContext context) {
    return InAppWebView(
      ...
      pullToRefreshController: pullToRefreshController,
      ...
    );
  }

새로고침

뒤로 가기 버튼을 일단 막아보자!

처음에는 스와이프로 웹뷰 페이지를 옮기는 동작을 위해서 알아보다가 눈에 많이 띄어서 이리저리 만져봤다. 안드로이드 폰에는 물리 혹은 소프트웨어 키로 뒤로가기 키가 할당되어있는 것이 많다. 하지만 이 버튼을 누를 때마다 앱이 종료된다면 여간 번거로운 일이 아닐 것이다. 그런 것들을 미연에 방지하기 위해 뒤로가기 키 버튼 동작 수행 전에 개발자가 실행할 함수를 끼워넣을 수 있다.

이것은 웹뷰가 아닌 플러터에서 기본적으로 제공하는 기능이므로 flutter_inappwebview 문서가 아닌 플러터 문서를 확인해야한다.

사용을 할 때 Scaffold를 감싸거나 Scaffold 바디에 WillPopScope 위젯을 넣어주면 된다.

onWillPop을 통해 return된 불리언값으로 뒤로가기 버튼을 활성화여부를 컨트롤 할 수 있다.

WillPopScope(
  onWillPop: (){
    // return 값이 true라면 뒤로가기키가 동작하고 false면 동작하지 않는다.
    return Future(() => false);
  }
  child: Scaffold(...),
),

몇가지의 사용 예시가 생각나는데,

  • 뒤로가기 버튼을 눌렀을 때 URL 히스토리를 가지고 와서 히스토리 제일 과거까지 이동하고 히스토리가 없다면 종료.

  • 한번만 눌렸을 때는 앱이 종료되지 않고 안내창이 팝업(ex: 한번 더 누르세요~ 같은) 그리고 두번 연달아 눌러야 종료와 같은 기능

두개를 합쳐서 히스토리까지 이동하고 난 후 정말 종료하겠냐는 팝업을 노출시키고 앱을 종료시키는 것도 많이 쓰이는 것 같다.

그리고 WillPopScope 의 경우 뒤로가기 스와이프 제스쳐나 네비게이터와 같은 동작도 같이 막아버리기 때문에 주의에서 사용해야한다.

뒤로가기를 막아주는 onWillPops

history가 있을 경우는 뒤로가기, 없을 경우에는 앱 나가기

일반적인 모바일 UX 에서는 뒤로가기를 눌렀을 때 직전에 방문했던 페이지로 돌아가게 된다. 하지만 기본적인 뒤로가기는 히스토리를 탐색하고 거슬러 가는 것이 아닌 앱나가기가 기본적 동작이다. 그래서 보편적인 동작을 위해서는 이 뒤로가기 버튼의 기능을 변경해줄 필요가 있다.

controller에는 canGoBack()이라는 메소드가 있다. 이 메소드는 history가 존재하는 지를 boolean으로 반환해주고, goBack()의 경우 history를 기억하고 실행되었을 때 이전 히스토리로 이동한다.

이를 활용해서 다이얼로그를 통해 앱을 종료시키는 동작을 작성한다거나 하는 등의 운영이 가능하다.

// 기본적인 형태의 뒤로가기

WillPopScope(
  onWillPop: () async {
    if (await webViewController!.canGoBack()) {
      webViewController?.goBack();
      return false;
    }
    return true;
  },
  child: Scaffold(...),
);

나는 헤맸다.. 미로인 줄..??

길을 잃었다...

위에 있는 코드는 세상 단순하다. 사실 동작 자체도 내가 짜는 로직이 아닌 메소드로 제공이 되고 있기 때문에 어떻게 ‘배치’ 하는 지만 안다면 헤매일 이유가 하등 없다.

하지만 무엇을 해도 동작이 안되는 것 아닌가,..? 이게 뭐야… 눈물을 흘리면서 극대노의 시간을 3시간쯤 보냈고, 이유를 알아냈다.

  • 일단 여기서 실수한 건 아니지만, 아무튼 당연하게도 비동기 처리가 필요하다. canGoBack() 의 값을 먼저 준비하고 있어야한다. 간단하게 async, await 구문을 활용하면 된다.

  • 하지만 비동기 처리를 했음에도 아무것도 반환하지 않았고 진행되지 않았다. 앱개발을 하면서 웹개발과 달라 굉장히 불편한 지점인데, (내가 익숙하지 않고 모르는 거 겠지만..) 개발자 도구가 없어서(혹은 불편해서) 디버깅을 하기가 너무 힘들었다. 문제점이 어디있는 지 도저히 모르겠지만, print를 찍어 예상하건데 canGoBack()값이 반환되지 않고 있었다.

이리저리 코드를 다시 짜보고 여러 자료도 찾아보았지만 여전히 오리무중인 와중에 마음을 가다듬고 코드를 찬찬히 살펴봤다. 문제는 컨트롤러를 선언한 지점에 있었다.

onWebViewCreated 에 컨트롤러가 직접적으로 선언이 되어야 이 컨트롤러를 통해 추적하거나 하는 거 였나 보다. controller에 자바스크립트 핸들러 추가하는 거에는 아무런 지장이 없었잖아요… 쀙...

아무튼 굉장히 단순하지만 중대한 뻘짓으로 3시간 날렸고 어찌됐건 해결책은 찾았다.

child: InAppWebView(
  key: webViewKey,
  initialUrlRequest: URLRequest(
    url: WebUri("http://10.0.2.2:3002/"),
  ),
  initialSettings: settings,
  onWebViewCreated: (InAppWebViewController controller) {
    // 여기가 문제의 원인이었음
    // onWebViewCreated에 컨트롤러가 있어야 이 경로들을 탐지할 수 있는 거였음
  webViewController = controller;
    // flutter => web
    controller.addJavaScriptHandler(
      handlerName: 'flutterChannel',
      callback: (args) {
        return true;
      },
    );
  },
  pullToRefreshController: pullToRefreshController,
),

마치며…

생각보다는 네이티브스러운 기능들이 손쉽게 구현이 된다. 플러터의 편의기능이 좋은 건지 RN도 이런 건지는 모르겠지만, 익숙하지 않은 것들 임에도 기능이 동작을 하는 것 보니 신기하고 뿌듯하다.

근데 웹과는 다르게 전원이 항시 연결되어있지 않은 모바일기기다보니 이렇게 고려없이 개발하는 게 맞는 걸까 싶다. 지금은 단순히 공부하고 개념을 익히는 단계이지만 조만간 실제 배포할 용도로 개발을 해야할 텐데 이렇게 해도 되는 걸까…? 사람살려다.

0
Subscribe to my newsletter

Read articles from 개똥이 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

개똥이
개똥이

어제보다 더 나은 서비스를 만들어내는 사람이 되고자 노력하며, 내일의 나를 위해 기록합니다.