@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();
};
}
}