新闻阅读界面,主要有新闻内容和评论列表或者一些相关推荐新闻。在具体实现时采用WebView和ListView来组合实现。但是WebView的垂直方向的滚动和ListView的垂直滚动会有冲突,解决起来十分麻烦,下面是我的一个方案,希望有读者能提出更好的方案。
在Android开发的时候,解决ListView(或者 RecyclerView)嵌套WebView的方法主要是,使用WebView自适应高度功能(设置高度wrap_content),让ListView完全接管上下滑动。这样滑动十分顺畅,但有个限制WebView必须使用使用loadData() 来加载文本内容形式的HTML数据。
然而,在Flutter中,使用AndroidView功能嵌入原生的WebView,须为WebView指定高度。不然高度会默认为0,看不到网页(显示不出来)。因此,在固定WebView高度的前提下, 要实现新闻页面的整体滚动,就必须自己分发滚动事件, 在合适的时机让应该滚动的控件滚动,达到整个页面滚动的效果。
Flutter中的触摸事件也是按照Layout树来冒泡传递的。查找了一些相关代码和API文档,目前还没有发现类似于Android中ViewGroup的事件拦截控制的方法(onInterceptTouchEvent); 只提供了事件的回调方法和一些监听。例如Listener widget, GestureDetector widget;
详情参考API文档:https://flutter.io/docs/development/ui/advanced/gestures
调查Flutter中能滚动的控件(ListView, GridView, CustomScrollView)之后,发现他们都自动强制拦截了Move事件,子控件根本不能获取到Move事件。但是Flutter也提供了设置禁止滚动的功能 (修改physics属性,设置 NeverScrollableScrollPhysics),提供了滚动控制和回调方式ScrollController。而且在禁止滚动之后,WebView就能够顺畅的滑动了。所以可以采用一种动态修改physics属性的方式来实现整个页面的滚动。
实现步骤:
- 网页放在首页,大小为铺满整个屏幕。先加载网页。
2.列表等其他组件放在网页下面。
3.先让WebView滚动,即整体滚动先设置为NeverScrollableScrollPhysics,禁止滚动;当WebView滚动到底的时候设置为ScrollPhysics,开启滚动。
4.当ScrollView滚动到顶部的时候再设置回NeverScrollableScrollPhysics,让WebView滚动。
5.就是判断时机的问题。目前加在触摸回调Listener中。监听onPointerMove和onPointerUp,在move事件中确定滚动方向, 在up事件时重新确定状态,为下次事件准备。
代码实现:
1.整个布局结构。(注意:CustomScrollView的sliver子项不要做多了,不然滑动到底部的时候会回收WebView,会丢失WebView的状态。因此采用 SliverList 来加载其他的列表数据)
@override
Widget build(BuildContext context) {
var physics = _physics;
return new Scaffold(
appBar: AppBar(
title: Text('news details'),
),
body: Listener(
onPointerMove: (PointerMoveEvent event) {
moveToUp = event.delta.dy < 0;
},
onPointerUp: (PointerUpEvent event) {
resetScrollState();
//惯性滑动
Future.delayed(new Duration(milliseconds: 400), () {
resetScrollState();
});
},
child: CustomScrollView(
physics: physics,
slivers: <Widget>[
SliverToBoxAdapter(
child: htmlBodyWidget,
),
SliverList(delegate: new SliverChildListDelegate(widgetList))
],
controller: _scrollController,
),
),
floatingActionButton: IconButton(
icon: Icon(Icons.call),
color: Colors.yellow,
onPressed: () {
setState(() {
if (_physics is NeverScrollableScrollPhysics) {
_physics = new ScrollPhysics();
} else {
_physics = NeverScrollableScrollPhysics();
}
});
}),
);
}
2.滑动重新设置方法方法:
void resetScrollState() {
print("moveToUp ---- $moveToUp");
if (moveToUp) {
widget.detailsWeb.canScrollDown().then((value) {
print("moveToUp --- canScrollDown --- $value");
print('moveToUp ---- _physics === ${_physics.toString()}');
if (!value) {
if ((_physics is NeverScrollableScrollPhysics)) {
setState(() {
_physics = ScrollPhysics();
});
}
}
});
} else {
bool isScrollViewTop = _scrollController.offset <= 0;
print(
"moveToUp ---- isScrollViewTop = ${isScrollViewTop}");
if (isScrollViewTop) {
widget.detailsWeb.canScrollUp().then((value) {
print("moveToUp --- canScrollUp --- $value");
print('moveToUp ---- _physics === ${_physics.toString()}');
if (value) {
if (!(_physics is NeverScrollableScrollPhysics)) {
setState(() {
_physics = NeverScrollableScrollPhysics();
});
}
}
});
}
}
}
- 判断CustomScrollView滚动到顶部方式:
bool isScrollViewTop = _scrollController.offset <= 0;
4.判断WebView是否滚动到顶部和顶部要采用原生的判断,所以必须是异步返回。
widget.detailsWeb.canScrollUp() 返回WebView是否能向上滚动,
widget.detailsWeb.canScrollDown() 返回webView是否能向下滚动
Android 的判断WebView还能不能滚动的源码:(参考下拉刷新的开源库)
public static boolean canChildScrollUp(View view) {
if (android.os.Build.VERSION.SDK_INT < 14) {
if (view instanceof AbsListView) {
final AbsListView absListView = (AbsListView) view;
return absListView.getChildCount() > 0
&& (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
.getTop() < absListView.getPaddingTop());
} else {
return view.getScrollY() > 0;
}
} else {
return view.canScrollVertically(-1);
}
}
public static boolean canChildScrollDown(View view) {
if (android.os.Build.VERSION.SDK_INT < 14) {
if (view instanceof AbsListView) {
final AbsListView absListView = (AbsListView) view;
return absListView.getChildCount() > 0
&& (absListView.getLastVisiblePosition() < absListView.getChildCount() - 1
|| absListView.getChildAt(absListView.getChildCount() - 1).getBottom() > absListView.getPaddingBottom());
} else if (view instanceof ScrollView) {
ScrollView scrollView = (ScrollView) view;
if (scrollView.getChildCount() == 0) {
return false;
} else {
return scrollView.getScrollY() < scrollView.getChildAt(0).getHeight() - scrollView.getHeight();
}
} else {
return false;
}
} else {
return view.canScrollVertically(1);
}
}
最终效果:
完整代码:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class NewsDetailsPage extends StatefulWidget {
final int nid;
DetailsWeb detailsWeb;
NewsDetailsPage(
int this.nid, {
Key key,
DetailsWeb this.detailsWeb,
}) : super(key: key);
@override
NewsDetailsPageState createState() {
return new NewsDetailsPageState();
}
}
abstract class DetailsWeb {
Widget createHtmlWidget(String body, List<Widget> pageWidgetContainer);
Future<bool> canScrollUp();
Future<bool> canScrollDown();
}
class NewsDetailsPageState extends State<NewsDetailsPage> {
List<Widget> widgetList = [];
Widget htmlBodyWidget;
@override
void initState() {
super.initState();
_getNewsDetails();
_init();
}
@override
void dispose() {
super.dispose();
}
void _getNewsDetails() async {
String url = 'http://www.wsrtv.com.cn/services/node/${widget.nid}.json';
var res = await http.get(url);
var resJson = json.decode(res.body);
try {
List<Widget> tempList = [];
String title = resJson['title'];
String titlehtml = '<h1>$title</h1>';
Padding titleWidget = new Padding(
padding: EdgeInsets.all(10.0),
child: new Text(
title,
style: TextStyle(
color: Colors.black,
fontSize: 20.0,
fontWeight: FontWeight.bold,
),
),
);
int dateTimestamp = int.parse(resJson['changed']);
print('time ---- $dateTimestamp');
var dateTime = DateTime.fromMillisecondsSinceEpoch(dateTimestamp * 1000);
String date = '${dateTime.year}-${dateTime.month}-${dateTime.day}';
String count = resJson['totalcount'];
String dateTimeInfoStr =
'<p style="margin-top: 5px; margin-bottom: 5px; line-height: 1.75em; color: rgb(0, 0, 0); font-size: 12px;"> $date 浏览量$count</p>';
Widget newsDateInfo = new IntrinsicHeight(
child: Padding(
padding: EdgeInsets.only(left: 10.0, right: 10.0, bottom: 10.0),
child: new Row(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
IntrinsicWidth(
child: Text(
date,
style: TextStyle(color: Colors.black, fontSize: 13.0),
),
),
Expanded(
child: new Padding(
padding: EdgeInsets.only(left: 20.0),
child: Text(
'浏览量$count',
style: TextStyle(color: Color(0xff999999), fontSize: 13.0),
),
),
),
],
),
),
);
Widget headerWidget = Container(
color: Colors.white,
child: new Column(
children: <Widget>[titleWidget, newsDateInfo],
),
);
// tempList.add(headerWidget);
// try {
// String videoUrl = resJson['field_news_video_app']['und'][0]['value'];
// print('videoUrl === $videoUrl');
// if (videoUrl != null && videoUrl.isNotEmpty) {
// final playerWidget = new Chewie(
// new VideoPlayerController.network(
// 'https://flutter.github.io/assets-for-api-docs/videos/butterfly.mp4'
// ),
// aspectRatio: 4 / 3,
// autoPlay: false,
// looping: false,
// );
// tempList.add(playerWidget);
// }
// } catch (e) {
// print('e === ${e.toString()}');
// }
String bodyValue = resJson['body']['und'][0]['value'];
String tempvalue = titlehtml + dateTimeInfoStr + bodyValue;
Widget bodyText = widget.detailsWeb == null
? Container()
: widget.detailsWeb.createHtmlWidget(tempvalue, widgetList);
htmlBodyWidget = bodyText;
// if (bodyText != null) {
// tempList.add(bodyText);
// }
for (int i = 0; i < 120; i++) {
tempList.add(AppBar(
title: Text('$i$i$i$i$i$i$i$i$i$i'),
));
}
setState(() {
widgetList = tempList;
});
} on Exception {}
}
ScrollController _scrollController;
bool _scrollAble = true;
ScrollPhysics _physics;
void _init() {
_scrollController = new ScrollController();
_physics = NeverScrollableScrollPhysics();
_scrollController.addListener(() {
print('_scrollController-------------${_scrollController.toString()}');
bool scrollAble = false;
if (scrollAble != _scrollAble) {
_scrollAble = scrollAble;
print('---------------------------$_scrollAble');
setState(() {
print('setState ----- scrollAbleController------');
// widgetList.add(AppBar(title: Text('aaaaaaaaaaaaa')));
// List<Widget> temp = <Widget>[];
// for (var item in widgetList) {
// temp.add(item);
// }
// widgetList = temp;
});
}
});
}
bool canToUp;
bool canToDown;
bool moveToUp = true;
@override
Widget build(BuildContext context) {
var physics = _physics;
return new Scaffold(
appBar: AppBar(
title: Text('news details'),
),
body: Listener(
onPointerMove: (PointerMoveEvent event) {
print(
'event web ---- up == ${widget.detailsWeb.canScrollUp()} ---- down =={${widget.detailsWeb.canScrollDown()}');
moveToUp = event.delta.dy < 0;
},
onPointerUp: (PointerUpEvent event) {
resetScrollState();
//惯性滑动
Future.delayed(new Duration(milliseconds: 400), () {
resetScrollState();
});
},
child: CustomScrollView(
physics: physics,
slivers: <Widget>[
SliverToBoxAdapter(
child: htmlBodyWidget,
),
SliverList(delegate: new SliverChildListDelegate(widgetList))
],
controller: _scrollController,
),
),
floatingActionButton: IconButton(
icon: Icon(Icons.call),
color: Colors.yellow,
onPressed: () {
setState(() {
if (_physics is NeverScrollableScrollPhysics) {
_physics = new ScrollPhysics();
} else {
_physics = NeverScrollableScrollPhysics();
}
});
}),
);
}
void resetScrollState() {
print("moveToUp ---- $moveToUp");
if (moveToUp) {
widget.detailsWeb.canScrollDown().then((value) {
print("moveToUp --- canScrollDown --- $value");
print('moveToUp ---- _physics === ${_physics.toString()}');
if (!value) {
if ((_physics is NeverScrollableScrollPhysics)) {
setState(() {
_physics = ScrollPhysics();
});
}
}
});
} else {
bool isScrollViewTop = _scrollController.offset <= 0;
print(
"moveToUp ---- isScrollViewTop = ${isScrollViewTop}");
if (isScrollViewTop) {
widget.detailsWeb.canScrollUp().then((value) {
print("moveToUp --- canScrollUp --- $value");
print('moveToUp ---- _physics === ${_physics.toString()}');
if (value) {
if (!(_physics is NeverScrollableScrollPhysics)) {
setState(() {
_physics = NeverScrollableScrollPhysics();
});
}
}
});
}
}
}
buildSlivers(List<Widget> list) {
if (list != null) {
Widget sliver = buildChildLayout(context, list);
return <Widget>[sliver];
}
return const <Widget>[];
}
Widget buildChildLayout(BuildContext context, List<Widget> children) {
return SliverList(
delegate: new SliverChildListDelegate(
children,
addAutomaticKeepAlives: true,
addRepaintBoundaries: true,
addSemanticIndexes: true,
));
}
Widget _detailsWidget(BuildContext context, int position) {
return widgetList[position];
}
}
网页部分
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappbrowser_example/native_web_view.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html;
import 'package:path_provider/path_provider.dart';
class NewsDetailsWeb extends StatefulWidget {
String body;
List<Widget> widgets;
NewsDetailsWebState state;
NewsDetailsWeb(
{Key key, @required String this.body, List<Widget> this.widgets})
: super(key: key);
@override
NewsDetailsWebState createState() {
state = NewsDetailsWebState();
return state;
}
Future<bool> canScrollUp() async {
return state?.canScrollUp();
}
Future<bool> canScrollDown() async {
return state?.canScrollDown();
}
}
class NewsDetailsWebState extends State<NewsDetailsWeb> {
final String fileName = 'wenshan_details.html';
final String fileCssName = 'wenshan_details_css.css';
String _webUrl = '';
double top = 156.899;
@override
void initState() {
super.initState();
_createHtmlContent();
}
void _createHtmlContent() async {
String cssUrl = (await _getLocalCssFile()).uri.toString();
String cssHead =
'''<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"/>
<link rel="stylesheet" type="text/css" href="${cssUrl}" />''';
String newHtml = cssHead + widget.body;
dom.Document doc = html.parse(newHtml);
String htmlContent = doc.outerHtml;
print('htmlContent === $htmlContent');
_writeDataFile(htmlContent);
}
void _checkCssFile() async {
File file = await _getLocalCssFile();
bool isExist = await file.exists();
int fileLength = isExist ? await file.length() : -1;
print('csss file length === $fileLength');
if (!isExist || fileLength <= 0) {
if (isExist) {
await file.delete();
}
await file.create();
String cssStr = await DefaultAssetBundle.of(context)
.loadString('assets/css/main.css');
print('csss ==== $cssStr');
await file.writeAsString(cssStr);
}
}
void _writeDataFile(String data) async {
_checkCssFile();
File file = await _getLocalHtmlFile();
File afterFile = await file.writeAsString(data);
setState(() {
_webUrl = afterFile.uri.toString();
});
print('weburl ==== $_webUrl');
}
Future<File> _getLocalCssFile() async {
// 获取本地文档目录
String dir = (await getApplicationDocumentsDirectory()).path;
// 返回本地文件目录
return new File('$dir/$fileCssName');
}
Future<File> _getLocalHtmlFile() async {
// 获取本地文档目录
String dir = (await getApplicationDocumentsDirectory()).path;
// 返回本地文件目录
return new File('$dir/$fileName');
}
@override
Widget build(BuildContext context) {
return getNativeWeb();
}
NativeWebView webView;
Widget getNativeWeb() {
webView = _webUrl.isNotEmpty
? NativeWebView(
webUrl: _webUrl,
webRect: Rect.fromLTWH(
0.0,
0.0,
MediaQuery.of(context).size.width,
MediaQuery.of(context).size.height -
AppBar().preferredSize.height -
MediaQuery.of(context).padding.top),
)
: null;
return _webUrl.isNotEmpty
? webView
: new Container(
height: 300.0,
color: Colors.yellow,
);
}
Future<bool> canScrollUp() async {
return webView?.canScrollUp();
}
Future<bool> canScrollDown() async {
return webView?.canScrollDown();
}
}
//WebView 插件使用flutter_inappbrowser
import 'package:flutter/material.dart';
import 'package:flutter_inappbrowser/flutter_inappbrowser.dart';
class NativeWebView extends StatelessWidget {
String webUrl;
final Rect webRect;
InAppWebViewController webView;
NativeWebView({Key key, this.webUrl, this.webRect}) : super(key: key);
@override
Widget build(BuildContext context) {
InAppWebView webWidget = new InAppWebView(
initialUrl: webUrl,
initialHeaders: {},
initialOptions: {},
onWebViewCreated: (InAppWebViewController controller) {
webView = controller;
},
onLoadStart: (InAppWebViewController controller, String url) {
print("started -------------- $url");
this.webUrl = url;
},
onProgressChanged: (InAppWebViewController controller, int progress) {
double prog = progress / 100;
print('prog --------- $prog');
});
return Container(
width: webRect.width,
height: webRect.height,
child: webWidget,
);
}
Future<bool> canScrollUp() async {
if(webView != null) {
print('webView up ---- ${ await webView.canScrollUp()}');
}
return webView == null ? false : webView.canScrollUp();
}
Future<bool> canScrollDown() async{
if(webView != null) {
print('webView down ---- ${await webView.canScrollDown()}');
}
return webView == null ? false : webView.canScrollDown();
}
}
调用:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_inappbrowser/flutter_inappbrowser.dart';
import 'package:flutter_inappbrowser_example/news_web_page.dart';
import 'package:flutter_inappbrowser_example/news_web_use.dart';
Future main() async {
runApp(new TestApp());
}
class TestHomeScreen extends StatelessWidget{
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text("Title"),
),
body: new Center(child: new Text("Click Me")),
floatingActionButton: new FloatingActionButton(
child: new Icon(Icons.add),
backgroundColor: Colors.orange,
onPressed: () {
print("Clicked");
Navigator.push(context, new MaterialPageRoute(builder: (context) {
return new NewsDetailsPage(
25266.toInt(),
detailsWeb: new DetailWebUse(),
);
}));
},
),
);
}
}
class TestApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return new MaterialApp(
home: TestHomeScreen(),
);
}
}
class DetailWebUse implements DetailsWeb {
NewsDetailsWeb web;
@override
Future<bool> canScrollDown() {
return web?.canScrollDown();
}
@override
Future<bool> canScrollUp() {
return web?.canScrollUp();
}
@override
Widget createHtmlWidget(String body, List<Widget> pageWidgetContainer) {
web = new NewsDetailsWeb(
body: body,
widgets: pageWidgetContainer,
);
return web;
}
}