Flutter Webview 사용시 키보드가림 현상 수정
문제 상황
앱 안에서 웹뷰를 쓰다 보면 한 번은 꼭 만나는 문제가 있다. 웹뷰 안의 Input을 누르면 키보드가 올라오면서 그 Input을 덮어버린다. 사용자는 지금 뭘 치고 있는지 볼 수가 없다.
검색해서 나온 방법들
검색해 보면 보통 이런 방법들이 나온다.
Scaffold의resizeToAvoidBottomInset속성 활용SingleChildScrollView로 감싸기MediaQuery.viewInsets.bottom만큼 패딩 추가
전부 Flutter 위젯 쪽에서 화면을 줄이거나 밀어 올리는 방법이다. 그런데 우리 앱 웹뷰에는 잘 안 맞았다.
Flutter 입장에서 웹뷰는 그냥 큰 위젯 하나다. 그 안에 Input이 어디 있는지는 모른다. Input이 화면 위쪽에 있으면 안 올려도 되고, 아래쪽에 있으면 가려지는 만큼만 올려야 하는데 Flutter 쪽에서는 이걸 구분할 수가 없다.
결국 Input 위치는 웹뷰 안에서 JavaScript로 물어봐야 한다.
결과
좌(변경 전) / 우(변경 후)
동작 방식
- 웹뷰 안에서 포커스된 Input을 찾는다. 사용자가 직접 누를 때도 있고, 키보드의 '다음' 버튼으로 넘어올 때도 있다.
- JavaScript로 그 Input의 bottom 값을 가져온다. (
getBoundingClientRect().bottom) - 웹뷰 높이에서 Input bottom을 빼면 Input 아래에 남은 공간이 나온다.
- 남은 공간이 키보드보다 크면 안 가려지니까 그대로 둔다.
- 키보드가 더 크면 모자란 만큼 웹뷰 아래에 패딩을 넣어서 위로 밀어 올린다.
코드
웹뷰 플러그인은 flutter_inappwebview를 썼다. 코드 보기 전에 몇 가지만 먼저 적어둔다.
키보드 높이를 Stream으로 받는 이유
키보드는 한 번에 뜨는 게 아니라 애니메이션으로 올라온다. 그동안 MediaQuery.viewInsets.bottom 값이 프레임마다 바뀌고 build도 계속 다시 불린다. 여기서 매번 JavaScript를 부르면 쓸데없는 호출이 너무 많다.
그래서 키보드 높이를 RxDart의 BehaviorSubject에 넣고, distinct()로 같은 값은 거르고, debounceTime(100ms)로 키보드가 다 올라온 뒤의 마지막 값만 받게 했다. 키보드 높이는 기기마다 달라서 고정값으로 박아둘 수도 없다.
focus 이벤트를 따로 받는 이유
키보드가 떠 있는 상태에서 '다음' 버튼으로 다른 Input으로 넘어가면 키보드 높이는 그대로다. 그러면 위 Stream은 distinct() 때문에 아무 일도 안 한다.
그래서 페이지가 로드될 때 focus 리스너를 JavaScript로 심어두고, Input이나 Textarea에 포커스가 갈 때마다 Flutter 쪽에서 패딩을 다시 계산하게 했다.
LayoutBuilder를 쓰는 이유
웹뷰는 기본적으로 높이가 무한대로 잡혀 있어서 다른 위젯과 같이 쓰려면 constraints로 높이를 정해줘야 한다. 이 높이가 3번 계산에 쓰이는 웹뷰 높이다. 화면에 웹뷰 하나만 띄운다면 굳이 필요 없다.
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;
});
}
}
}
한계
INPUT,TEXTAREA만 본다.contenteditable같은 요소는 따로 처리해야 한다.- 페이지 안에 따로 스크롤되는 영역이 있거나 구조가 복잡하면 계산이 안 맞을 수 있다.
모든 환경에 대응할 수는 없지만 지금 프로덕트에는 잘 맞는 결과를 얻었다. 그대로 가져다 쓰기보다는 각자 프로젝트에 맞게 고쳐서 쓰는 걸 추천한다.