윤준서의 블로그

Flutter Webview 사용시 키보드가림 현상 수정

문제 상황

앱 내에 웹뷰를 이용하다보면 만나는 문제 중 하나는 웹뷰 내에서 Input Tag를 사용했을 때 해당 요소가 키보드에 가리는 현상입니다. 이 문제는 사용자 경험을 크게 저해할 수 있으며, 특히 모바일 환경에서 자주 발생합니다.

기존 해결 방법의 한계

이 문제를 해결하기 위해 인터넷에서 검색한 결과, 다양한 해결 방법이 있었습니다:

하지만 이러한 방법들은 현재 앱의 웹뷰 환경과 맞지 않았습니다. 각 해결책은 특정 상황에서는 효과적이지만, 모든 환경에 적용하기에는 한계가 있었습니다.

JavaScript를 활용한 해결 방법

이러한 한계를 극복하기 위해 JavaScript를 활용하여 웹뷰 내부의 입력 필드 위치를 동적으로 조정하는 방법을 구현했습니다. 이 방법의 핵심은 다음과 같습니다:

  1. 웹뷰 내에서 Input Tag 요소의 위치를 확인
  2. 키보드 높이와 Input 요소의 bottom 값을 비교
  3. 필요한 경우 패딩을 추가하여 키보드에 가리지 않도록 조정

구현 결과

좌(변경 전) / 우(변경 후)

구현 원리

  1. WebView 내 Focus 된 Input 요소 확인 (사용자 클릭 시, 키보드에서 '다음' Input 요소로 이동)
  2. Input 요소의 bottom 값 확인
  3. Keyboard Height 확인 후 Input bottom 값을 비교
  4. Input bottom > Keyboard Height 일 경우 스크롤 X
  5. Input bottom < Keyboard Height 일 경우
  6. Keyboard Height - Input bottom 값 만큼 Padding 추가

코드 구현

키보드 높이는 디바이스마다 다르고, build 내에서 확인을 수행하고 있기 때문에 RxDart의 Stream을 이용하여 중복값과 JavaScript를 이용한 계산을 최적화했습니다.

Flutter WebView 플러그인으로는 flutter_inappwebview를 사용했습니다.

기본적으로 WebView의 경우 Height가 infinite 처리되어 있어 다른 위젯과 사용하기 위해서 LayoutBuilder로 constraints를 계산하여 사용하고 있습니다. 단일로 WebView 사용 시에는 굳이 필요 없긴 합니다.

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  InAppWebViewController? controller;
  bool isKeyboardUp = false;
  double webviewHeight = 0.0;
  double webviewPadding = 0.0;
  final BehaviorSubject<double> keyboardSizeStream = BehaviorSubject<double>();

  @override
  void initState() {
    super.initState();
    keyboardSizeStream.stream
        .distinct()
        .debounceTime(const Duration(milliseconds: 100))
        .listen(
          (double keyboardSize) {
            if (keyboardSize > 10) {
              // 웹뷰 내 키보드 위치만큼 패딩을 준다.
              keyboardPaddingUpdate(keyboardSize: keyboardSize);
            } else {
              setState(() {
                webviewPadding = 0.0;
              });
            }
          },
        );
  }

  @override
  void dispose() {
    keyboardSizeStream.close();
    controller = null;
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final keyboardBottom = MediaQuery.of(context).viewInsets.bottom;

    if (keyboardBottom > 10) {
      isKeyboardUp = true;
      keyboardSizeStream.add(keyboardBottom);
    } else if (keyboardBottom < 10 && isKeyboardUp) {
      isKeyboardUp = false;
      keyboardSizeStream.add(0.0);
    }

    return Scaffold(
      // 키보드가 올라오면 화면이 올라가는 것을 방지
      resizeToAvoidBottomInset: false,
      body: SafeArea(
        // 다른 요소가 존재할 수도 있으니 LayoutBuilder로 constraints를 받아서 사용
        child: LayoutBuilder(builder: (context, constraints) {
          return SingleChildScrollView(
            reverse: true,
            child: Column(
              children: [
                SizedBox(
                  width: constraints.maxWidth,
                  height: constraints.maxHeight,
                  child: InAppWebView(
                    initialUrlRequest: URLRequest(
                      url: Uri.parse("https://dashboard.branch.io/"),
                    ),
                    onWebViewCreated: (controller) {
                      this.controller = controller;
                      webviewHeight = constraints.maxHeight;
                    },
                    onLoadStart: (controller, url) async {
                      await controller.evaluateJavascript(source: """
                        window.addEventListener('focus', function(event) {
                          if(event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
                            window.flutter_inappwebview.callHandler('focusChange');
                          }
                        }, true);
                      """);
                    },
                    onLoadStop: (controller, url) async {
                      controller.addJavaScriptHandler(
                        handlerName: "focusChange",
                        callback: (_) {
                          if (kDebugMode) {
                            print("focusChange");
                          }
                          keyboardPaddingUpdate(
                            keyboardSize: MediaQuery.of(context).viewInsets.bottom,
                          );
                        },
                      );
                    },
                  ),
                ),
                SizedBox(height: webviewPadding),
              ],
            ),
          );
        }),
      ),
    );
  }

  // 키보드가 올라오면 WebView 내 Focus Input의 위치 계산 및 Padding 추가
  keyboardPaddingUpdate({
    double keyboardSize = 0.0,
  }) async {
    if (controller == null) return;

    final double webViewInputPadding = await controller!.evaluateJavascript(
            source: "document.activeElement.getBoundingClientRect().bottom;")
        as double;

    final double padding = webviewHeight - webViewInputPadding;

    if (keyboardSize < padding) {
      setState(() {
        webviewPadding = 0.0;
      });
    } else {
      setState(() {
        webviewPadding = keyboardSize - padding;
      });
    }
  }
}

해결 방법의 장점

  1. 유연성: 다양한 웹 페이지와 레이아웃에 적용 가능합니다.
  2. 동적 대응: 키보드 높이 변경에 실시간으로 대응합니다.
  3. 사용자 경험 향상: 입력 필드가 항상 가시적으로 유지됩니다.
  4. 성능 최적화: RxDart를 활용한 중복 처리 방지로 성능을 개선했습니다.

주의사항 및 한계

  1. 웹 페이지 구조 의존성: 일부 복잡한 웹 페이지에서는 예상대로 작동하지 않을 수 있습니다.
  2. 브라우저 호환성: 모든 브라우저에서 동일하게 작동하지 않을 수 있습니다.
  3. 성능 고려: JavaScript 실행으로 인한 약간의 성능 저하 가능성이 있습니다.

결론

JavaScript를 활용한 키보드 가림 현상 해결 방법은 WebView 내 입력 필드의 가시성을 보장하는 효과적인 접근법입니다. 이 방법은 다양한 디바이스와 웹 페이지에 적용 가능하며, 사용자 경험을 크게 향상시킬 수 있습니다.

모든 환경에 대응할 수는 없지만, 현재 프로덕트에 잘 어울리는 결과물을 얻을 수 있었습니다. 필요에 따라 프로젝트의 요구사항에 맞게 코드를 조정하여 사용하시기 바랍니다.

참고 자료