Flutter 新聞詳情頁二——WebView和列表豎直滾動

weixin_33913332發表於2018-11-08

新聞閱讀介面,主要有新聞內容和評論列表或者一些相關推薦新聞。在具體實現時採用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屬性的方式來實現整個頁面的滾動。

實現步驟:

  1. 網頁放在首頁,大小為鋪滿整個螢幕。先載入網頁。
    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();
              });
            }
          }
        });
      }
    }
  }
  1. 判斷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);
        }
    }

最終效果:


4889166-379e816ab7ea1e7a.gif
news_details.gif

完整程式碼:

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;
  }
}

相關文章