🪂

【Flutter】WebViewのスクロールに追従するAppBarを実装する

2025/01/08に公開

こんにちは、株式会社IVRyでソフトウェアエンジニアをしているtsutouです。

AndroidのCoordinatorLayoutやFlutterのSliverAppBarは、ユーザーのスクロールに応じて、ページの表示領域を柔軟に広げ、ユーザー体験の向上に大きく寄与します。

https://www.youtube.com/watch?v=R9C5KMJKluE

しかし、FlutterでWebViewを使用する際に、上記のようなスクロール連動型AppBarを実現しようとすると、WebViewとスクロールが干渉し、思ったように動作しませんでした。さらに、同様の事例がWeb上ではあまり見受けられないため、具体的な解決策を見つけるのが難しい状況でした。

そこで本記事では、FlutterのWebView内で、スクロールに応じて表示・非表示がシームレスに切り替わるカスタムAppBarを実装した事例をご紹介します。

成果物イメージ

前提

https://pub.dev/packages/webview_flutter

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だったというだけなので、AnimatedSizeAnimatedPositionedなど、自社の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'),
      ),
    );
  }
}

curvedurationはSliverAppBarの設定に合わせています。

https://github.com/flutter/flutter/blob/09a585ba1dd3b856c6a168005ce40553e61f7f2d/packages/flutter/lib/src/material/app_bar.dart#L1970-L1973

UI

最終的なレイアウト構造はColumnにExpandなWebViewを並べるだけで、かなりシンプルになります。

Column(
  children: [
    CustomWebViewAppBar(),
    Expanded(
      child: WebViewWidget(
      controller: _controller,
    ),
  ],
)

まとめ

GestureReconizerや、スクロールリスナーを持つflutter_inappwebviewを使う別の解法もあるかもしれません。もっとシンプルな方法があればぜひコメント頂ければと思います!

IVRyテックブログ

Discussion