【Flutter】WebViewのスクロールに追従するAppBarを実装する
こんにちは、株式会社IVRyでソフトウェアエンジニアをしているtsutouです。
AndroidのCoordinatorLayoutやFlutterのSliverAppBarは、ユーザーのスクロールに応じて、ページの表示領域を柔軟に広げ、ユーザー体験の向上に大きく寄与します。
しかし、FlutterでWebViewを使用する際に、上記のようなスクロール連動型AppBarを実現しようとすると、WebViewとスクロールが干渉し、思ったように動作しませんでした。さらに、同様の事例がWeb上ではあまり見受けられないため、具体的な解決策を見つけるのが難しい状況でした。
そこで本記事では、FlutterのWebView内で、スクロールに応じて表示・非表示がシームレスに切り替わるカスタムAppBarを実装した事例をご紹介します。
成果物イメージ
前提
- 公式の webview_flutter を使っている
- ScaffoldのAppBarでは実現できない
- CustomScrollViewでは実現できない
AppBarの状態を管理する
ここではグローバルな状態を管理したかったので、Riverpodを使用した例ですが、flutter_hooksなどの方が用途によってライトかもしれません。
class AppBarStateNotifier extends _$AppBarStateNotifier {
AppBarState build() {
// 初期状態を設定(AppBarは表示)
return AppBarState(isVisible: true);
}
void showAppBar() {
if (!state.isVisible) {
state = state.copyWith(isVisible: true);
}
}
void hideAppBar() {
if (state.isVisible) {
state = state.copyWith(isVisible: false);
}
}
}
WebViewにスクロールリスナーを追加する
WebViewを表示するWidget内で、下記のスクロールリスナーを実装します。scrollThreshold
はAppBarの高さ分ですが、プロダクトに合わせて調整するのが良いかと思います。
double _previousScrollY = 0;
bool _isScrollingDown = false;
void _onScroll(double scrollY) {
// 最上部から引っ張った時に隠れてしまうので制御する
if (scrollY < 0) {
return;
}
// スクロール差分
final delta = scrollY - _previousScrollY;
// アニメーション閾値
final scrollThreshold = kToolbarHeight + MediaQuery.of(context).padding.top;
if (delta.abs() < scrollThreshold) {
return;
}
if (delta < 0) {
if (_isScrollingDown) {
_isScrollingDown = false;
ref.read(appBarStateProvider.notifier).showAppBar();
}
} else if (delta > 0) {
if (!_isScrollingDown) {
_isScrollingDown = true;
ref.read(appBarStateProvider.notifier).hideAppBar();
}
}
_previousScrollY = scrollY;
}
Webのスクロールイベントを受け取る
FlutterのWebView内でスクロールイベントを検知し、スクロール位置をネイティブアプリケーションに送信するために、以下のJavaScriptコードを注入します。
String injectedScrollListenerScript() {
return '''
window.addEventListener('scroll', function() {
ScrollListener.postMessage(window.scrollY.toString());
});
''';
}
次に、WebViewControllerにJavaScriptChannelを設定し、JavaScriptからアプリ側にメッセージを送信できるようにします。
これにより、スクロール位置の情報を、先述の_onScroll
をトリガーし、AppBarの表示・非表示を制御します。
final controller = WebViewController.fromPlatformCreationParams(params)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (String url) async {
await _controller.runJavaScript(injectedScrollListenerScript(version));
}
)
..addJavaScriptChannel(
'ScrollListener',
onMessageReceived: (msg) {
final scrollY = double.tryParse(msg.message) ?? 0.0;
_onScroll(scrollY);
},
)
アニメーションしながら隠れるAppBarを作る
スクロールに連動して表示・非表示が切り替わるカスタムAppBarを実装します。AnimatedContainerを使用することで、AppBarの高さを滑らかにアニメーションさせることができます。
自社のプロダクト内のレスポンシブなUIの変動と一番相性が良かったのがAnimatedContainerだったというだけなので、AnimatedSizeやAnimatedPositionedなど、自社のWebViewの挙動やUXに合わせて他のAnimatedWidgetでも採用していけると思います。
class CustomWebViewAppBar extends ConsumerWidget {
const CustomWebViewAppBar({
super.key,
});
Widget build(BuildContext context, WidgetRef ref) {
final isAppBarVisible = ref.watch(appBarStateProvider).isVisible;
final appBarHeight = kToolbarHeight + MediaQuery.of(context).padding.top;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
height: isAppBarVisible ? appBarHeight : 0,
child: AppBar(
title: Image.asset('path/to/logo'),
),
);
}
}
curve
やduration
はSliverAppBarの設定に合わせています。
UI
最終的なレイアウト構造はColumnにExpandなWebViewを並べるだけで、かなりシンプルになります。
Column(
children: [
CustomWebViewAppBar(),
Expanded(
child: WebViewWidget(
controller: _controller,
),
],
)
まとめ
GestureReconizerや、スクロールリスナーを持つflutter_inappwebviewを使う別の解法もあるかもしれません。もっとシンプルな方法があればぜひコメント頂ければと思います!
Discussion