Flutter 實現webview與原生元件組合滑動
最近在用Flutter寫一個新聞客戶端, 新聞詳情頁中的內容需要用Flutter的本地Widget和WebView共同展示 . 比如標題/上方的視訊播放器是用本地Widget展示, 新聞內容的富文字文字使用webview展示html, 這樣就要求標題/視訊播放器與webview可以組合滑動 .
ps: 如果把新聞詳情頁都用html畫出, 就不用考慮組合滑動的問題.
轉載請標明出處:juejin.im/post/5c997f…
找到支援與本地元件共存的webview控制元件
找一個可以與本地元件共存的webview控制元件是首要任務, 以下是我測試過的幾個庫:
-
flutter_WebView_plugin
: 不可以inline; -
webView_flutter
: 可能支援, 但是還沒有釋出; -
flutter_inappbrowser
: 可以實現組合佈局, 所以選用了此庫, 連結github.com/pichillilor…
另外, 如果僅是展示html靜態頁面, 可以嘗試以下幾個庫, 不用看我這個麻煩的解決辦法了:
初步實現組合佈局
選定flutter_inappbrowser
後開始實現, 初步程式碼如下:
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Column( children: <Widget>[ Text('Title'), Expanded( // 注意必須加這個, 否則webview沒有高度 child: InAppWebView(initialUrl: 'https://juejin.im/timeline'), ), ], ), ); } 複製程式碼
這樣會構建一個text和webview組合的介面, 不過這裡webview自帶滾動條, 滾動時是不帶著title一塊的. 嘗試以下兩種辦法
-
包裹
SingleChildScrollView
: 介面會消失不見, 因為Scrollview根據子佈局處理高度, 而Expanded又要根據父佈局處理高度, 所以互相依賴導致整個頁面無法繪製.body: SingleChildScrollView( child: Column( children: <Widget>[ Text('Title'), Expanded( child: InAppWebView(initialUrl: 'https://juejin.im/timeline'), ), ], ), ), 複製程式碼
-
包裹
SingleChildScrollView
, 去掉Expanded
: AppBar可以顯示了, 但是InAppWebView
沒有高度了.body: SingleChildScrollView( child: Column( children: <Widget>[ Text('Title'), InAppWebView(initialUrl: 'https://juejin.im/timeline'), ], ), ), 複製程式碼
這兩種方式都不行, 歸根到底是不知道InAppWebView
的高度, 所以才需要使用與SingleChildScrollView
相沖突的Expanded
, 所以這個問題變為了如何獲取WebView的高度
.
獲取WebView的高度
在android中不會有這個破問題, 給webview
設定wrap_content
就可以了, 但是在Flutter中我沒有找到類似佈局方式. (有大哥知道的話麻煩告訴我一下下啊)
其他嘗試的方法就不說了, 最後我採用的辦法是:通過JS注入拿到html內容的高度回撥 . 實現方法如下:
class TestState extends State<Test> { InAppWebViewController _controller; double _htmlHeight = 200; // 目的是在回撥完成直接先展示出200高度的內容, 提高使用者體驗 static const String HANDLER_NAME = 'InAppWebView'; @override void dispose() { super.dispose(); _controller?.removeJavaScriptHandler(HANDLER_NAME, 0); _controller = null; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: SingleChildScrollView( child: Column( children: <Widget>[ Text('Title'), Container( // 使用可提供高度的Container包裹WebView, 設定為回撥的高度 height: _htmlHeight, child: InAppWebView( initialUrl: 'https://juejin.im/timeline', onWebViewCreated: (InAppWebViewController controller) { _controller = controller; _setJSHandler(_controller); // 設定js方法回掉, 拿到高度 }, onLoadStop: (InAppWebViewController controller, String url) { // 頁面載入完成後注入js方法, 獲取頁面總高度 controller.injectScriptCode(""" window.flutter_inappbrowser.callHandler('InAppWebView', document.body.scrollHeight)); """); }, ), ) ], ), ), ); } void _setJSHandler(InAppWebViewController controller) { JavaScriptHandlerCallback callback = (List<dynamic> arguments) async { // 解析argument, 獲取到高度, 直接設定即可(iphone手機需要+20高度) double height = HtmlUtils.getHeight(arguments); if (height > 0) { setState(() { _htmlHeight = height; }); } }; controller.addJavaScriptHandler(HANDLER_NAME, callback); } } 複製程式碼
以上方法可以精確獲取到webview高度, 實現webview與本地Widget組合滑動的要求.
Android端一個問題
以上方法實現後我是一陣竊喜, 趕忙測試了一下, 結果發現一個嚴重問題:Android端給webview設定超出5500左右的高度時, App會閃退
.
閃退時AndroidStudio不會展示錯誤日誌, 通過flutter run --verbose
命令執行可以獲取到錯誤資訊, 大體看了下是Flutter渲染的問題, 先反饋給官方以及flutter_inappbrowser
作者了.
然後自己簡單測試發現, 給Column的child添加了多個webview沒什麼問題, 哪怕這幾個webview的內容相加絕對超出了5500高度. 所以有了思路:切分html, 分為多個webview共同展示, 然後分別注入JS獲取高度 .
注意!注意! 我們的使用場景是:要展示的內容 = assets儲存的html外殼 + 介面獲取到的新聞內容段落, 而不是一個url . 以上解決思路僅適用於載入html的場景, 而不是url.
這個思路的核心在於如何切分html內容, 需要保證切分後的html是標籤閉合的, 即不是切在了某標籤內部. 使用此切分方案的前提是: body內部的html標籤不會有超大範圍的div包裹, 否則單個標籤內容就超過高度了. 可用的html示例:
<html> <head></head> <body> <!-- 並列小組合, 沒有超大範圍的div等標籤的包裹 --> <p style.. > asdasdasd </p> <div style.. > <img ... /> <p> ... </p> </div> <p> asdasdas </p> </body> </html> 複製程式碼
下面是我實現的切分html的演算法:
// 剪下過長的html, 考慮到較差機型以及其他誤差, 定為4000 // @params htmlString 待切分的html // @params totalHeight 前面webview回調出的總高度 // @return String 剪下後的html static List<String> cutHtml(String htmlString, double totalHeight) { htmlString = _getBody(htmlString); List<String> htmlList = List(); if (Platform.isAndroid && totalHeight > 4000) { // 切為幾段('~/'整除, /.toInt) int childNum = totalHeight ~/ 4000 + (totalHeight % 4000 == 0 ? 0 : 1); // 每段html的長度 int childLength = htmlString.length ~/ childNum; // 切一刀後的兩段html String resultHtml = '', remainHtml = htmlString; int labelStack = 0; while (childNum > 0 && remainHtml.length > 0) { if (childLength < remainHtml.length) { resultHtml = remainHtml.substring(0, childLength); remainHtml = remainHtml.substring(childLength); } else { resultHtml = remainHtml; remainHtml = ''; } if (_checkComplete(resultHtml, labelStack)) { htmlList.add(resultHtml); childNum--; } else { // 如果不是閉合的, 把remain裡的n個標籤尾之前的內容剪下到result中 while (labelStack != 0) { int tailPosition = remainHtml.indexOf(_labelsTail); if (tailPosition != -1) { resultHtml = resultHtml + remainHtml.substring(0, tailPosition + 2); remainHtml = remainHtml.substring(tailPosition + 2); labelStack--; } } htmlList.add(resultHtml); childNum--; } } } else { htmlList.add(htmlString); } return htmlList; } // true if resultHtml是標籤閉合的 static bool _checkComplete(String resultHtml, int labelStack) { labelStack = 0; for (int i = 0; i < resultHtml.length; i++) { if (resultHtml.startsWith('<', i)) { String label = _startWithLabel(resultHtml.substring(i)); if (label != null) { labelStack++; i += label.length - 1; } } if (resultHtml.startsWith(_labelsTail, i)) { labelStack--; i += _labelsTail.length - 1; } } return labelStack == 0; } // 以_labelsHead內的字串開頭 static String _startWithLabel(String resultHtml) { for (String label in _labelsHead) { if (resultHtml.startsWith(label)) { return label; } } return null; } // 去除body及以外的標籤, 露出並列的子標籤 // <html> //<head></head> //<body> // ... //</body> // </html> static String _getBody(String htmlString) { if (htmlString.contains('<body>')) { htmlString = htmlString.substring(htmlString.indexOf('<body>') + 6); htmlString = htmlString.substring(0, htmlString.indexOf('</body>')); } return htmlString; } // 待檢測的標籤 static final _labelsHead = {'<div', '<img', '<p', '<strong', '<span'}; static final _labelsTail = '</'; 複製程式碼
通過以上演算法, 拿到了切分好的htmlList, 然後在PageState中使用多個webview分別載入, 分別注入js即可解決此問題.
大功告成!
附:
-
flutter_inappbrowser
如何載入html字串:InAppWebView( initialData: InAppWebViewInitialData(' htmlContent ')) 複製程式碼
-
解析asset檔案為字串:
static Future<String> decodeStringFromAssets(String path) async { ByteData byteData = await PlatformAssetBundle().load(path); String htmlString = String.fromCharCodes(byteData.buffer.asUint8List()); return htmlString; } 複製程式碼