Hone
Hone

Reputation: 31

flutter: how to make pull down to refresh flutter webview using the official webview_flutter package

I want to add refresher in flutter web view

 Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: Stack(
        children: <Widget>[
          Container(
            child: Center(
              child: Text(_title),
            ),
          ),
        ],
      )),
      body: SafeArea(
          child: WebView(
              key: _key,
              javascriptMode: JavascriptMode.unrestricted,
              initialUrl: _url)),
    );
  }
}

Upvotes: 2

Views: 4366

Answers (2)

cefaci
cefaci

Reputation: 51

@peter-koltai: Many thanks for this! I really appreciated your solution the height is working correctly, even the height is coming a bit late (page content is seen, but scrolling height not there), but there were other issues. (Sorry I can't vote you up)

Issues w/ SingleChildScrollView:

  • SingleChildScrollView has always the absolute height of the page e.g. if a text box was not expanded from the beginning (javascript), the scroll height exceeds the page height.
  • The WebView gets the whole scroll area height, but doesn't know the display size, so if a bottom or top modal sheet appears, they are not rendered correctly in the view area of the screen but in the absolute complete height of the scroll area, so then you have to scroll e.g. 6000px up and down.
  • The scroll position stays where you left somewhere in your previous absolute page height, if you browse further w/o a page refresh.

Complete code:

So the solution of @shalin-shah gave me this nice working solution: I calculate the dragging down distance (>20% of screen height) if you start at the top=0 of the page which then shows the RefreshIndicator until onPageFinished.

webview.dart: The RefreshIndicator gets a Completer if dragging down distance is reached and starts the reloading with the spinning, which is completed if page finishes loading.

import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_web_refresh/pull_to_refresh.dart';
import 'package:webview_flutter/webview_flutter.dart';

class MyWebViewWidget extends StatefulWidget {
  final String initialUrl;

  const MyWebViewWidget({
    Key? key,
    required this.initialUrl,
  }) : super(key: key);

  @override
  State<MyWebViewWidget> createState() => _MyWebViewWidgetState();
}

class _MyWebViewWidgetState extends State<MyWebViewWidget> with WidgetsBindingObserver {

  late WebViewController _controller;

  // Drag to refresh helpers
  final DragGesturePullToRefresh pullToRefresh = DragGesturePullToRefresh();
  final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();

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

    WidgetsBinding.instance!.addObserver(this);
    if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
  }

  @override
  void dispose() {
    // remove listener
    WidgetsBinding.instance!.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    // on portrait / landscape or other change, recalculate height
    pullToRefresh.setRefreshDistance(MediaQuery.of(context).size.height);
  }

  @override
  Widget build(context) {
    return RefreshIndicator(
      key: _refreshIndicatorKey,
      onRefresh: () {
        Completer<void> completer = pullToRefresh.refresh();
        _controller.reload();
        return completer.future;
      },
      child: WebView(
        initialUrl: widget.initialUrl,
        javascriptMode: JavascriptMode.unrestricted,
        zoomEnabled: true,
        gestureNavigationEnabled: true,
        gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>{
          pullToRefresh.dragGestureRecognizer(_refreshIndicatorKey),
        },
        onWebViewCreated: (WebViewController webViewController) {
          _controller = webViewController;
          pullToRefresh.setController(_controller);
        },
        onPageStarted: (String url) { pullToRefresh.started(); },
        onPageFinished: (finish) {    pullToRefresh.finished(); },
        onWebResourceError: (error) {
          debugPrint(
              'MyWebViewWidget:onWebResourceError(): ${error.description}');
          pullToRefresh.finished();
        },
      ),
    );
  }
}

pull_to_refresh.dart: After drag start from top=0 of the page and is always downward, the moving distance is calculated, and when it exceeds 20% of the screen size the RefreshIndicator show() is called.

import 'package:flutter/material.dart';
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:webview_flutter/webview_flutter.dart';

// Fixed issue: https://github.com/flutter/flutter/issues/39389
class AllowVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer {
  @override
  //override rejectGesture here
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }
}

class DragGesturePullToRefresh {
  static const double EXCEEDS_LOADING_TIME = 3000;
  static const double REFRESH_DISTANCE_MIN = .2;

  late WebViewController _controller;

  // loading
  Completer<void> completer = Completer<void>();
  int msLoading = 0;
  bool isLoading = true;

  // drag
  bool dragStarted = false;
  double dragDistance = 0;
  double refreshDistance = 200;

  Factory<OneSequenceGestureRecognizer> dragGestureRecognizer(final GlobalKey<RefreshIndicatorState> refreshIndicatorKey) {
    return Factory<OneSequenceGestureRecognizer>(() => AllowVerticalDragGestureRecognizer()
    // Got the original idea from https://stackoverflow.com/users/15862916/shalin-shah:
    // https://stackoverflow.com/questions/57656045/pull-down-to-refresh-webview-page-in-flutter
      ..onDown = (DragDownDetails dragDownDetails) {
        // if the page is still loading don't allow refreshing again
        if (!isLoading ||
            (msLoading > 0 && (DateTime.now().millisecondsSinceEpoch - msLoading) > EXCEEDS_LOADING_TIME)) {
          _controller.getScrollY().then((scrollYPos) {
            if (scrollYPos == 0) {
              dragStarted = true;
              dragDistance = 0;
            }
          });
        }
      }
      ..onUpdate = (DragUpdateDetails dragUpdateDetails) {
        calculateDrag(refreshIndicatorKey, dragUpdateDetails.delta.dy);
      }
      ..onEnd = (DragEndDetails dragEndDetails) { clearDrag(); }
      ..onCancel = () { clearDrag(); });
  }

  void setController(WebViewController controller){ _controller = controller; }
  void setRefreshDistance(double height){ refreshDistance = height * REFRESH_DISTANCE_MIN; }

  Completer<void> refresh() {
    if (!completer.isCompleted) {
      completer.complete();
    }
    completer = Completer<void>();
    started();
    return completer;
  }

  void started() {
    msLoading = DateTime.now().millisecondsSinceEpoch;
    isLoading = true;
  }

  void finished() {
    msLoading = 0;
    isLoading = false;
    // hide the RefreshIndicator
    if (!completer.isCompleted) {
      completer.complete();
    }
  }

  void clearDrag() {
    dragStarted = false;
    dragDistance = 0;
  }

  void calculateDrag(final GlobalKey<RefreshIndicatorState> refreshIndicatorKey, double dy) async {
    if (dragStarted && dy >= 0) {
      dragDistance += dy;
      // Show the RefreshIndicator
      if (dragDistance > refreshDistance) {
        debugPrint(
            'DragGesturePullToRefresh:refreshPage(): $dragDistance > $refreshDistance');
        clearDrag();
        unawaited(refreshIndicatorKey.currentState?.show());
      }
    /*
      The web page scrolling is not blocked, when you start to drag down from the top position of
      the page to start the refresh process, e.g. like in the chrome browser. So the refresh process
      is stopped if you start to drag down from the page top position and then up before reaching
      the distance to start the refresh process.
    */
    } else {
      clearDrag();
    }
  }
}

This fix was helpful for the gesture events flutter webview VerticalDragGestureRecognizer get no callback but only onDown and onCancel.

The complete code is on github too.

Gif, I am not allowed to post one...

Differences w/o SingleChildScrollView or to e.g. the chrome browser

=> Fixed: Go to update

  • The RefreshIndicator shows no initial animation by dragging it down until the distance is reached to start the refresh process. (Can be added differently)
  • The web page scrolling is not blocked, when you start to drag down from the top position of the page to start the refresh process, e.g. like in the chrome browser. So the refresh process is stopped if you start to drag down from the page's top position and then up before reaching the distance to start the refresh process. Check the method in refreshPage() in the pull_to_refresh.dart for my solution and the comment.

I find the differences irrelevant 🤷‍♀️ as the issues destroyed the browsing expierence.

Update

I changed using ScrollNotification which RefreshIndicator interprets right when FixedScrollMetrics are set. So we have the original animation like in SingleChildScrollView or e.g. chrome browser.

github

Complete code:

webview.dart:

import 'package:flutter/material.dart';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter_web_refresh/pull_to_refresh.dart';
import 'package:webview_flutter/webview_flutter.dart';

class MyWebViewWidget extends StatefulWidget {
  final String initialUrl;

  const MyWebViewWidget({
    Key? key,
    required this.initialUrl,
  }) : super(key: key);

  @override
  State<MyWebViewWidget> createState() => _MyWebViewWidgetState();
}

class _MyWebViewWidgetState extends State<MyWebViewWidget>
    with WidgetsBindingObserver {

  late WebViewController _controller;
  late DragGesturePullToRefresh dragGesturePullToRefresh;

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

    dragGesturePullToRefresh = DragGesturePullToRefresh();
    WidgetsBinding.instance!.addObserver(this);
    if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
  }

  @override
  void dispose() {
    // remove listener
    WidgetsBinding.instance!.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    // on portrait / landscape or other change, recalculate height
    dragGesturePullToRefresh.setHeight(MediaQuery.of(context).size.height);
  }

  @override
  Widget build(context) {
    return
        // NotificationListener(
        // onNotification: (scrollNotification) {
        //  debugPrint('MyWebViewWidget:NotificationListener(): $scrollNotification');
        //  return true;
        // }, child:
      RefreshIndicator(
        onRefresh: () => dragGesturePullToRefresh.refresh(),
        child: Builder(
          builder: (context) => WebView(
            initialUrl: widget.initialUrl,
            javascriptMode: JavascriptMode.unrestricted,
            zoomEnabled: true,
            gestureNavigationEnabled: true,
            gestureRecognizers: {Factory(() => dragGesturePullToRefresh)},
            onWebViewCreated: (WebViewController webViewController) {
              _controller = webViewController;
              dragGesturePullToRefresh
                  .setContext(context)
                  .setController(_controller);
            },
            onPageStarted: (String url) { dragGesturePullToRefresh.started(); },
            onPageFinished: (finish) {    dragGesturePullToRefresh.finished();},
            onWebResourceError: (error) {
              debugPrint(
                  'MyWebViewWidget:onWebResourceError(): ${error.description}');
              dragGesturePullToRefresh.finished();
            },
          ),
        ),
      );
  }
}

pull_to_refresh.dart:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'dart:async';

import 'package:flutter/gestures.dart';
import 'package:webview_flutter/webview_flutter.dart';

// Fixed issue: https://github.com/flutter/flutter/issues/39389
class DragGesturePullToRefresh extends VerticalDragGestureRecognizer {
  static const double EXCEEDS_LOADING_TIME = 3000;

  late BuildContext _context;
  late WebViewController _controller;

  // loading
  Completer<void> completer = Completer<void>();
  int msLoading = 0;
  bool isLoading = true;

  // drag
  double height = 200;
  bool dragStarted = false;
  double dragDistance = 0;

  @override
  //override rejectGesture here
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }

  void _clearDrag() {
    dragStarted = false;
    dragDistance = 0;
  }

  DragGesturePullToRefresh setContext(BuildContext context) { _context = context; return this; }
  DragGesturePullToRefresh setController(WebViewController controller) { _controller = controller; return this; }

  void setHeight(double height) { this.height = height; }

  Future refresh() {
    if (!completer.isCompleted) {
      completer.complete();
    }
    completer = Completer<void>();
    started();
    _controller.reload();
    return completer.future;
  }

  void started() {
    msLoading = DateTime.now().millisecondsSinceEpoch;
    isLoading = true;
  }

  void finished() {
    msLoading = 0;
    isLoading = false;
    // hide the RefreshIndicator
    if (!completer.isCompleted) {
      completer.complete();
    }
  }

  FixedScrollMetrics _getMetrics(double minScrollExtent, double maxScrollExtent,
      double pixels, double viewportDimension, AxisDirection axisDirection) {
    return FixedScrollMetrics(
        minScrollExtent: minScrollExtent,
        maxScrollExtent: maxScrollExtent,
        pixels: pixels,
        viewportDimension: viewportDimension,
        axisDirection: axisDirection);
  }

  DragGesturePullToRefresh() {
    onStart = (DragStartDetails dragDetails) {
      // debugPrint('MyWebViewWidget:onStart(): $dragDetails');
      if (!isLoading ||
          (msLoading > 0 && (DateTime.now().millisecondsSinceEpoch - msLoading) > EXCEEDS_LOADING_TIME)) {
        _controller.getScrollY().then((scrollYPos) {
          if (scrollYPos == 0) {
            dragStarted = true;
            dragDistance = 0;
            ScrollStartNotification(
                    metrics: _getMetrics(0, height, 0, height, AxisDirection.down),
                    dragDetails: dragDetails,
                    context: _context)
                .dispatch(_context);
          }
        });
      }
    };
    onUpdate = (DragUpdateDetails dragDetails) {
      if (dragStarted) {
        double dy = dragDetails.delta.dy;
        dragDistance += dy;
        ScrollUpdateNotification(
                metrics: _getMetrics(
                    dy > 0 ? 0 : dragDistance, height,
                    dy > 0 ? (-1) * dy : dragDistance, height,
                    dragDistance < 0 ? AxisDirection.up : AxisDirection.down),
                context: _context,
                scrollDelta: (-1) * dy)
            .dispatch(_context);
        if (dragDistance < 0) {
          _clearDrag();
        }
      }
    };
    onEnd = (DragEndDetails dragDetails) {
      ScrollEndNotification(
              metrics: _getMetrics(0, height, dragDistance, height, AxisDirection.down),
              context: _context)
          .dispatch(_context);
      _clearDrag();
    };
    onCancel = () {
      ScrollUpdateNotification(
              metrics: _getMetrics(0, height, 1, height, AxisDirection.up),
              context: _context,
              scrollDelta: 0)
          .dispatch(_context);
      _clearDrag();
    };
  }
}

Upvotes: 5

Peter Koltai
Peter Koltai

Reputation: 9734

It can be done, basic problem is that RefreshIndicator only works with a scrollable item as child, and WebView is not scrollable from Flutter's point of view (the loaded contents are scrollable). So you have to wrap it into some kind of scrollable, but there comes the other problem: you have to know the height to do so, and you still need the contents of WebView to be scrollable, so that you can scroll up and down on the loaded web page.

The solution includes the following steps, partially using accepted answer here.

  1. Create a RefreshIndicator and add a SingleChildScrollView as its child.
  2. Inside the scroll view add a Container to hold the WebView as child.
  3. Set some initial height for the Container, for example the height of the screen.
  4. After the page is loaded or refreshed, use JavaScript code to get the loaded document's height in the browser.
  5. Resize the Container with the acquired height.
  6. Watch for orientation change, because the same page will have different height in portrait and landscape, and refresh Container height accordingly.

This solution is not perfect. First, as you will see from print outputs in debug console, height settings occur not only when strictly necessary. Second, if the content of a web page changes so that the height of the loaded documents changes as well, without actually reloading the page, height will be not synced. (For example if you add rows to a table on a dynamic webpage.)

Complete code:

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'WebView with RefreshIndicator',
      home: Scaffold(
          appBar: AppBar(title: Text('WebView with RefreshIndicator')),
          body: SafeArea(
            child: MyWebWiew(),
          )),
    );
  }
}

class MyWebWiew extends StatefulWidget {
  const MyWebWiew({Key? key}) : super(key: key);

  @override
  _MyWebWiewState createState() => _MyWebWiewState();
}

class _MyWebWiewState extends State<MyWebWiew> with WidgetsBindingObserver {
  WebViewController? _webViewController;
  // height of the WebView with the loaded content
  double? _webViewHeight;
  // is true while a page loading is in progress
  bool _isPageLoading = true;

  @override
  void initState() {
    super.initState();
    // add listener to detect orientation change
    WidgetsBinding.instance!.addObserver(this);
  }

  @override
  void dispose() {
    // remove listener
    WidgetsBinding.instance!.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    // on portrait / landscape or other change, recalculate height
    _setWebViewHeight();
  }

  @override
  Widget build(BuildContext context) {
    // on initial loading, get height using MediaQuery,
    // this will be used until page is loaded
    if (_webViewHeight == null) {
      final initalWebViewHeight = MediaQuery.of(context).size.height;
      print('WebView inital height set to: $initalWebViewHeight');
      _webViewHeight = initalWebViewHeight;
    }

    return RefreshIndicator(
        // reload page
        onRefresh: () => _webViewController!.reload(),
        child: SingleChildScrollView(
          physics: const AlwaysScrollableScrollPhysics(),
          child: Container(
              height: _webViewHeight,
              child: WebView(
                  javascriptMode: JavascriptMode.unrestricted,
                  initialUrl: 'https://flutter.dev',
                  onWebViewCreated: (WebViewController webViewController) {
                    _webViewController = webViewController;
                  },
                  onPageStarted: (String url) {
                    setState(() {
                      _isPageLoading = true;
                    });
                  },
                  onPageFinished: (String url) {
                    setState(() {
                      _isPageLoading = false;
                    });
                    // if page load is finished, set height
                    _setWebViewHeight();
                  })),
        ));
  }

  void _setWebViewHeight() {
    // we don't update if WebView is not ready yet
    // or page load is in progress
    if (_webViewController == null || _isPageLoading) {
      return;
    }
    // execute JavaScript code in the loaded page
    // to get body height
    _webViewController!
        .evaluateJavascript('document.body.clientHeight')
        .then((documentBodyHeight) {
      // set height
      setState(() {
        print('WebView height set to: $documentBodyHeight');
        _webViewHeight = double.parse(documentBodyHeight);
      });
    });
  }
}

Upvotes: 1

Related Questions