Flutter 開發踩坑記錄(乾貨總結)

flutter.png

Flutter 太好學了!BUG 真的太少了! issues 只有 5000 多!也就那麼億點!簡單得我都枯了!畢竟每次遇到問題,👴🏻 都是直接去找羣裏的法佬、低調、Alex 等幾位大佬(🐶管理,此處小聲嗶嗶)來解決,只要有大佬在,問題也就不大。雖然法佬常常說要學會看源碼,但道理你們其實都懂,看源碼也就圖一樂,真正有 BUG 仍是得找法佬。css

很少嗶嗶,單寫一篇文章,先記錄它一手。本文記錄 👴🏻 在 Flutter 開發中遇到的一些 BUG(as design),避免遺忘,若是正在看文章的你也遇到了,那我們能夠握個手。html

容器寬高相關問題

Container 設置寬高不生效

通常是因爲父級容器的 constraints 屬性引發的,在 Flutter 中,子組件的大小會被父組件的 constraints 屬性限制,例如android

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: 100.0, // 最小寬度爲 100 像素
    minHeight: 50.0 // 最小高度爲 50 像素
  ),
  child: Container(
    height: 5.0,// 高度爲 5 邏輯像素
    child: redBox 
  ),
)
複製代碼

上面的代碼中,Container 組件設置高度爲 5 像素,是沒法生效的,由於父級容器已經設置了最小高度爲 50 像素,因此 Container 組件的最終高度將會是 50 像素。git

固然,這確定不是咱們想要的效果,咱們就想讓 Container 組件的最終高度是 5 像素怎麼辦?其實很簡單,可使用 UnconstraindBox 解除父級容器的 constraints 屬性對子組件大小的限制。例如:github

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: 100.0, // 最小寬度爲 100 像素
    minHeight: 50.0 // 最小高度爲 50 像素
  ),
  child: UnconstraintsBox(
    child: Container(
      height: 5.0, 
      child: redBox 
    ),
  ),
)
複製代碼

UnconstrainedBox 容許其子組件按照其自身的大小繪製,咱們不多直接使用此組件,除非對於 Material 自帶的一些組件,如 Appbar 的 icon 被官方限制了固定的大小,利用該組件能夠解除限制,而通常狀況下,咱們在組件外面套一層佈局類組件就能夠解決需求,例如如下組件:json

Row()
Column()
Align()
Center()
Flex()
Wrap()
Flow()
Stack()
複製代碼

SignleChildScrollView 不滿一屏高度時沒法撐滿全屏

其實和上面這個問題是類似的,可使用佈局類組件解決,或者用以下方式:微信

Container(
  alignment: Alignment.topLeft,
  child: SingleChildScrollView(),
),
複製代碼

若是你看過 Container 的源碼你會發現其實設置 alignment 屬性,和用 Align 組件是一回事,源碼也是使用 Align 組件,這就是個語法糖,僅此而已。網絡

說到語法糖,其實 Center 組件也是 Align 組件的語法糖,當你不給 Align 傳遞任何參數時,使用 Center() 和使用 Align() 是如出一轍的效果,個人習慣是無論什麼狀況,都是隻用Align 組件。app

Container 設置 borderRadius 不生效

設置 borderRadius 有兩種作法,第一種使用 Container 等組件自帶的 borderRadius 屬性,第二種是,直接用 ClipRRect 等 clip 組件對容器進行裁剪,第二種比第一種更加暴力、消耗性能,但更有效。less

例如給 TabView 的容器設置 borderRadius,你會發現沒法生效,而使用 ClipRRect 則能夠解決,個人理解是 ClipRRect 會直接裁剪成圓角形狀,而 BorderRadius 的圓角外的弧形範圍是透明的,相似 css 中的 display:noneopaticy:0 的區別,實際具體是什麼緣由,我也沒有去細究,複製粘貼、能跑就行。

元素顯示層級問題

能夠認爲 Flutter 中 widget 佈局的層級關係是遞進的,例如 child 的層級比父 Widget 層級更高, ColumnRow 等組件的 children 中同級 widget,誰在後面誰的層級就更高,和 Stackchildren 的層級關係相同。

顯示隱藏的幾種作法

第一種,利用 IndexedStack 組件控制層級,上面也提到過,子組件誰在後面誰的層級就高,Flutter 中雖然沒有 z-index 這一說法,但其實原理和 css 的 z-index 是相似的,index 越大,層級越高,固然這裏的 IndexedStackindex 屬性是用來控制當前顯示的某一個 children,只能顯示一個。該方法經常使用於 APP 首頁切換底部導航。

第二種,利用 IgnorePointerOpacity 組件組合隱藏 widget,可使用 AnimationOpacity 組件達到之前 JQuery 中經常使用的 fadeIn 效果。

第三種,利用 PositionedTransform.translate 移動到屏幕外,須要顯示時再移動回來,這種作法很是適合動畫切換,例如視頻進度條等效果。

第四種,利用 Offstage 組件,前三種都是利用視覺效果將元素隱藏起來,其實在佈局上並未發生改變,而此組件就是相似於 css 中的 display:none,直接讓元素在佈局中隱藏,不會在佈局上繼續佔用空間。

最後一種,在 build 方法中提早判斷,不符合條件直接不渲染,或者返回空 box,這就相似於 HTML 中刪除 dom 元素,我人沒了,還顯示個🔨,這是最恐怖的。

GestureDetector 設置 onTap 不生效

Listener 默認的 behaviorHitTestBehavior.deferToChild

若是 Listener 的子組件是一個 Container,這個 Container 不設置 decoration 的狀況下,即透明背景色、無邊框,則點擊 Container 時,沒法觸發 down、up 等事件。

同理,GestureDetector 是對 Listener 的封裝,沒法觸發 onTap 等事件也是必然的,那麼解決辦法也很簡單,有如下兩種解決辦法:

1. 給 Container 設置 decoration
2. 將 behavior 屬性設置爲 opaque 或 translucent
複製代碼

調用 setState 或 markNeedsBuild 後報錯

第一種報錯

setState() or markNeedsBuild() called during build

遇到此提示,通常解決思路都是利用 addPostFrameCallback 來解決,例如:

WidgetsBinding.instance.addPostFrameCallback((_){
    _model.setOpacity(opacity);
});
複製代碼

第二種報錯

setState() called after dispose()

通常定時器在 app 返回桌面後仍在調用 setState 或 頁面 pop 銷燬後異步任務才完成,此時調用了 setState 必然會出現該提示,那麼解決辦法也很簡單,判斷生命週期再執行重構邏輯。

if (!mounted) return;
setState(() {
  // do somthing
});
複製代碼

鍵盤相關問題

鍵盤彈出後將佈局頂起來了,而不是遮住佈局

解決辦法:在 scafold 裏設置 resizeToAvoidBottomInset: false,鍵盤會遮住佈局,而不是頂起佈局。

就想讓鍵盤頂起佈局,佈局卻溢出了怎麼辦?

溢出確定是由於沒有鍵盤時,總體高度沒有一屏高,鍵盤出現了,卻超出了一屏的高度。解決辦法很簡單,首先將佈局使用 SingleChildScrolleView 之類的滾動組件包裹住,將佈局改變可爲滾動的,這樣鍵盤彈出後佈局就不會溢出了。

接着可使用 WidgetsBindingObserver 類來監聽鍵盤彈起事件,每次彈起鍵盤出觸發 didChangeMetrics 鉤子,在該鉤子裏執行邏輯便可,例如將 SingleChildScrolleView 的當前位置調整至最底部,相關代碼以下:

import 'package:flutter/material.dart';

class Demo extends StatefulWidget {
  @override
  createState() => _DemoState();
}

class _DemoState extends State<Demo> with WidgetsBindingObserver {

  final _scrollController = ScrollController();
  final _phoneController = TextEditingController();

  FocusNode _phoneFocusNode = FocusNode();
  FocusScopeNode _focusScopeNode;

  get _phoneTextFiled => TextField(
    controller: _phoneController,
    focusNode: _phoneFocusNode,
    keyboardType: TextInputType.phone,
    maxLength: 11,
    decoration: InputDecoration(
      hintText: '請輸入手機號',
      border: InputBorder.none,
      counterText: '',
    ),
  );

  void handlePostFrame() {
    if (!_phoneFocusNode.hasFocus) {
      print('requestFocus');
      _focusScopeNode.requestFocus(_phoneFocusNode);
    }
    print('jumpTo');
    _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
  }

  @override
  void initState() {
    WidgetsBinding.instance.addObserver(this);
    super.initState();
  }

  @override
  void didChangeMetrics() {
    WidgetsBinding.instance.addPostFrameCallback(handlePostFrame);
    super.didChangeMetrics();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
}
複製代碼

鍵盤彈起和收回會引發頁面從新build

個人項目中有一個接近 1 萬行代碼的視頻詳情頁,所有使用 Provider 進行狀態管理,若是鍵盤彈起回收觸發 build,就可能出現一些奇怪的 BUG,好比當前的滾動組件在屏幕中的位置發生變化。

個人解決方案是利用 showBottomSheet 方法,頁面中展現的 TextField 上蓋一層透明遮罩,使用戶沒法點擊,而點擊遮罩時,則觸發 showBottomSheet, push 進一個新的路由,彈起鍵盤,卻不會引發從新 build,收起鍵盤時,則會 pop 回頁面,其實視覺上一直都保持在同一頁面中,和普通的彈起鍵盤沒區別,而且性能也很是棒,相關代碼以下:

get textField => TextField(
    autofocus: true,
    cursorColor: currentTheme.hoverColor,
    cursorWidth: 1.0,
    textInputAction: TextInputAction.done,
    style: TextStyle(
      color: currentTheme.primaryColorLight,
      fontSize: setSp(32),
    ),
    decoration: InputDecoration(
      hintText: '發一句友善的評論來見證當下吧',
      hintStyle: TextStyle(fontSize: setSp(28)),
      contentPadding: EdgeInsets.symmetric(horizontal: setWidth(31)),
      filled: true,
      fillColor: currentTheme.primaryColorDark,
      border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(setWidth(30)),
          borderSide: BorderSide.none
      ),
    ),
    onSubmitted: (value) {},
  );

  Widget buildTextFieldPage(BuildContext context) {
    return SizedBox.expand(
      child: Stack(
        alignment: Alignment.bottomLeft,
        children: <Widget>[
          Positioned.fill(
            child: GestureDetector(
              behavior: HitTestBehavior.opaque,
              onTap: () => Navigator.pop(context),
              child: Container(color: Colors.black.withOpacity(.5)),
            ),
          ),
          buildInput(),
        ],
      ),
    );
  }

  buildInput({hasTextField = true}) {
    Widget child;

    child = hasTextField
        ? Container(
            decoration: BoxDecoration(
              color: currentTheme.backgroundColor,
              borderRadius: BorderRadius.circular(setWidth(31)),
            ),
            child: textField,
          )
        : GestureDetector(
            onTap: () {
              showBottomSheet(
                context: context,
                backgroundColor: Colors.transparent,
                builder: buildTextFieldPage,
              );
            },
            child: Container(
              decoration: BoxDecoration(
                color: currentTheme.backgroundColor,
                borderRadius: BorderRadius.circular(setWidth(31)),
              ),
            ),
          );

    return Container(
      height: setWidth(103),
      padding: EdgeInsets.symmetric(
        vertical: setWidth(20),
        horizontal: setWidth(25),
      ),
      decoration: BoxDecoration(
        border: Border(top: commentDivider),
        color: currentTheme.primaryColor,
      ),
      child: Row(
        children: <Widget>[
          Expanded(child: child),
          Container(
            width: setWidth(66),
            padding: EdgeInsets.only(left: setWidth(25)),
            alignment: Alignment.center,
            child: Icon(
              IcoMoon.send,
              color: currentTheme.hoverColor.withOpacity(.5),
              size: setWidth(42),
            ),
          ),
        ],
      ),
    );
  }
複製代碼

相關效果以下:

input.gif

路由 push pop 常見需求

例如瀏覽記錄中有以下 4 個頁面,當前頁面爲 d

a->b->c->d
複製代碼

在當前頁面使用 Navigator.popUtil(context, ModalRoute.withName('a')),能夠直接返回至 a 頁面,並銷燬 bc 頁面。

在當前頁面使用 Navigator.pushNamedAndRemoveUntil(context, 'e', (route) => false),能夠進入 e 頁面以前,銷燬全部歷史記錄,即 e 頁面變成第一頁,e 頁面裏沒法繼續 pop 返回上一頁。

Mac 環境 build 時的錯誤

提示以下:

Automatically assigning platform iOS with version 9.0 on target Runner because no platform was specified. Please specify a platform for this target in your Podfile.

解決辦法是:刪除 pod 文件中 platform前的 #

由於沒有作過原生開發,因此對於這種 build 問題真的是一臉茫然,最開始遇到過幾回相似錯誤,我經過網上搜索答案、羣裏問大佬來解決,很是之麻煩。因此後來我在 Mac 環境 build 產生錯誤時,都是直接重建項目,把邏輯代碼複製進新項目裏,再從新 build 就不會發生各類亂七八糟看不懂的錯誤了,效率也快。

PageView、ListView 等滾動組件切換頁面返回後的高度位置被改變了

解決辦法:給滾動組件加上 key 屬性,用於保存位置信息,例如: key: PageStorageKey(1)

其實通常的 ListView 還沒法知足咱們平常開發中各類花式的需求,推薦使用法佬的 NestedScrollView

法佬已經給咱們解決了不少奇怪的 bug,還要什麼自行車?

如何監聽 App 返回桌面事件

我須要當 app 返回桌面時暫停視頻的播放,從桌面返回 app 後再繼續播放,解決方案以下:

class _DemoState extends State<Demo> with WidgetsBindingObserver {
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    print('app lifecycle state: $state');
    if (state == AppLifecycleState.inactive) {
      _playerModel.pausePlayer();
    } else if (state == AppLifecycleState.resumed) {
      if (_homeModel.isFindPage) _playerModel.startPlayer();
    }
    super.didChangeAppLifecycleState(state);
  }
}

複製代碼

WidgetsBindingObserver 這個類我常用,例如監聽鍵盤彈起事件也會用到這個類。

TextField 設置 border 不生效

TextField 的 border 有以下 3 種,須要針對性地設置,只設置一個是沒法生效的:

decoration: InputDecoration(border enabledBorder focusBorder)
複製代碼

ps:設置 maxLength 屬性後,decoration 裏須要設置 counterText: '',不然默認會附帶一個統計字數的樣式。

Dio 小技巧

使用 Dio 進行 HTTP 請求時,請求頭 content-type 的默認值是

application/json; charset=utf-8
複製代碼

若是返回頭的 content-type

application/json
複製代碼

Dio 將自動解析返回 json 數據爲 Dart 相應的數據類型,而不須要手動地調用 jsonDecode 方法,因此客戶端、服務端的統一使用 application/json 做爲 content-type,他好我也好。

Android 打包後沒法進行網絡請求

在我第一次使用 Flutter 打包項目時遇到了這個問題,最後發現是沒有網絡請求的權限,相似的,儲存讀取本地文件時可能也會有相似問題,這種問題設置權限就能夠解決了。

android/app/src/profile/AndroidManifest.xml

以及 android/app/src/main/AndroidManifest.xml 兩個文件的 manifest 標籤內添加以下子標籤便可:

<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
複製代碼

對於類中的屬性和方法的定義規範的一些建議

  • 不引用其餘屬性的成員,定義爲屬性

  • 引用其餘屬性,且不接收參數的成員,定義爲getter

  • 引用其餘屬性,且接受參數的成員,定義爲function

全屏相關設置

強制豎屏:

void initState() {
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown
  ]);
  super.initState();
}
複製代碼

強制橫屏:

initState() {
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight
  ]);
  super.initState();
}
複製代碼

Transform 3D 轉換

推薦使用 Transform 組件來完成動畫效果,例如 Transform.translateTransform.scale 能夠完成位置、縮放的變化, Transform.rotate 能夠完成旋轉角度的變化。

Transform.rotateRotateBox 均可以完成旋轉功能,他們之間有什麼區別?

使用 RotateBox 渲染 widget 是在 layout 階段,渲染完畢後就會佔用實際位置,而 Transform 組件則是在 layout 以後的繪製階段, Transform 只是一個視覺效果,實際所佔空間大小是 transform 變化以前所佔用的空間大小,因此從新渲染 Transform.rotate 組件比從新渲染 RotateBox 開銷更小。

Flutter 的 Transform 組件的這個特性和 CSS 的 transform 屬性很是類似,均可以用來提高動畫性能。

不過作視頻全屏功能時,能夠用 IndexedStack + RotateBox 替代 push 一個橫屏的路由的作法,RotateBox 它會使容器填充全屏,而 IndexedStack 能夠控制是否顯示全屏,這裏若是使用 Transform 則沒法填充全屏,由於容器的寬高在 layout 時就已經肯定了,因此只能使用 RotateBox

視頻鏡像翻轉

我在項目中不只使用 RotatedBox 完成視頻全屏功能,還利用了 Transform 來完成鏡像翻轉功能,寫法以下:

Selector<VideoModel, bool>(
  selector: (context, model) => model.isMirror,
    builder: (context, isMirror, child) => Transform(
      alignment: Alignment.center,
      transform: Matrix4.identity()..setEntry(3, 2, 0.006)..rotateY(isMirror ? math.pi : 0),
      child: child,
    ),
    child: FijkView(
    player: model.player,
    color: Colors.black,
    panelBuilder: (player, context, size, pos) => emptyBox,
  ),
)
複製代碼

原理很簡單,FijkView 是 fijkplayer 提供的視頻容器,我將視頻容器以中心位置爲圓心,沿 Y 軸作一個 180 度的旋轉,便可知足需求。

setEntry 用於設置透視,不然將沒法看到 Y 軸及 X 軸的立體轉換效果

rotateY 則與 css 中的 rotateY 是相同含義,即沿 Y 軸旋轉。在 css 中能夠設置 transform: rotateY(180deg) 來達到相同的效果。

狀態欄相關設置

隱藏狀態欄:

import 'package:flutter/services.dart';

void toggleFullscreen() {
  _isFullscreen = !_isFullscreen;
  _isFullscreen
      ? SystemChrome.setEnabledSystemUIOverlays([])
      : SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}
複製代碼

改變狀態欄顏色,則須要使用插件:flutter_statusbarcolor,下面是用法示例:

// 改變狀態欄背景顏色,默認改變爲透明
Future<void> changeStatusColor({Color color: Colors.transparent}) async {
  try {
    await FlutterStatusbarcolor.setStatusBarColor(
      color,
      animate: true,
    );
    FlutterStatusbarcolor.setStatusBarWhiteForeground(true);
    FlutterStatusbarcolor.setNavigationBarWhiteForeground(true);
  } on PlatformException catch (e) {
    debugPrint(e.toString());
  }
}
複製代碼

下面介紹一個用法,個人 home 頁使用 indexStack 組件包含了 4 個 tab 頁,每次更改 tab 會改變 currentHomeTab 的值,但不會觸發從新 build,而因爲路由 pushpop 又會觸發從新 build,因此若是須要當進入 home 頁的 發現 tab 頁 時改變爲黑色狀態欄,則能夠用下面這種作法:

// 在發現頁的 build 方法裏進行判斷
@override
Widget build(BuildContext context) {
  if (ModalRoute.of(context).isCurrent && currentHomeTab == '發現') {
    changeStatusColor(color: Colors.black);
  }
}
複製代碼

fijkplayer 秒開、進度跳轉等優化

fijkplayer 默認狀況下,進度跳轉、播放可能會有性能問題,針對這些問題,能夠進行如下優化:

_player.setDataSource(_video.src);
await _player.applyOptions(
    FijkOption()
      ..setFormatOption('flush_packets', 1)
      ..setFormatOption('analyzemaxduration', 100)
      ..setFormatOption('analyzeduration', 1)
      ..setCodecOption('skip_loop_filter', 48)
      ..setPlayerOption('start-on-prepared', 1)
      ..setPlayerOption('packet-buffering', 0)
      ..setPlayerOption('framedrop', 1)
      ..setPlayerOption('enable-accurate-seek', 1)
      ..setPlayerOption('find_stream_info', 0)
      ..setPlayerOption('render-wait-start', 1)
);
await _player.prepareAsync();
複製代碼

參考連接:

IjkPlayer 起播速度優化

IjkPlayer 播放器秒開優化以及經常使用 Option 設置

LayoutBuilder 相關的實踐

如何實現微信朋友圈、嗶哩嗶哩評論的多行文本收起、展開功能

我寫了下面這個工具類,簡單、好用得我都枯了,原理是利用先 LayoutBuilder 判斷是否超出指定的行數,若是超出則返回 Column,若是未超出則返回原 widget

import 'package:flutter/material.dart';

class ExpandableText extends StatefulWidget {
  final String text;
  final int maxLines;
  final TextStyle style;
  final bool expand;
  final TextStyle markerStyle;
  final String atName;

  const ExpandableText(this.text, {
    Key key,
    this.maxLines,
    this.style,
    this.markerStyle,
    this.expand = false,
    this.atName = '',
  }) : super(key: key);

  @override
  createState() => _ExpandableTextState();

}

class _ExpandableTextState extends State<ExpandableText> {

  bool expand;
  TextStyle style;
  int maxLines;

  @override
  void initState() {
    expand = widget.expand;
    style = widget.style;
    maxLines = widget.maxLines;
    super.initState();
  }

  Widget buildOrdinaryText() {
    final text = widget.text;
    return LayoutBuilder(builder: (_, size) {
      final tp = TextPainter(
        text: TextSpan(text: text, style: style),
        maxLines: maxLines,
        textDirection: TextDirection.ltr,
      );
      tp.layout(maxWidth: size.maxWidth);

      if (!tp.didExceedMaxLines) return Text(text, style: style);

      return Builder(
        builder: (context) => Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(text, maxLines: expand ? null : widget.maxLines, style: style),
            GestureDetector(
              onTap: () {
                expand = !expand;
                (context as Element).markNeedsBuild();
              },
              child: Text(
                expand ? '收起' : '展開',
                style: widget.markerStyle,
              ),
            ),
          ],
        ),
      );
    });
  }

  Widget buildAtText() {
    return LayoutBuilder(builder: (_, size) {
      final tp = TextPainter(
        text: TextSpan(text: '回覆 @${widget.text}:', style: style),
        maxLines: maxLines,
        textDirection: TextDirection.ltr,
      );
      tp.layout(maxWidth: size.maxWidth);

      if (!tp.didExceedMaxLines) return Text.rich(
        TextSpan(
          children: [
            TextSpan(text: '回覆 '),
            TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
            TextSpan(text: ':${widget.text}'),
          ],
        ),
        style: style,
      );

      return Builder(
        builder: (context) => Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text.rich(
              TextSpan(
                children: [
                  TextSpan(text: '回覆 '),
                  TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
                  TextSpan(text: ':${widget.text}'),
                ],
              ),
              maxLines: expand ? null : widget.maxLines,
              style: style,
            ),
            GestureDetector(
              onTap: () {
                expand = !expand;
                (context as Element).markNeedsBuild();
              },
              child: Text(
                expand ? '收起' : '展開',
                style: widget.markerStyle,
              ),
            ),
          ],
        ),
      );
    });
  }

  @override
  build(context) => widget.atName == '' ? buildOrdinaryText() : buildAtText();
}
複製代碼

調用方法以下:

Container(
  padding: EdgeInsets.only(top: setWidth(6), bottom: setWidth(11)),
  alignment: Alignment.centerLeft,
  child: ExpandableText(
    reply.content,
    maxLines: 4,
    style: commentTextStyle,
    markerStyle: commentMarkerStyle,
    atName: reply.isDirect > 0 ? '' : reply.pNickname,
  ),
),
複製代碼

相關效果以下:

extendable.gif

監聽父級 widget 的實際寬高信息

LayoutBuilder 的做用很是大,能夠用它來監聽某個widget的寬高信息,我在項目中遇到了 一個需求,須要根據某個 widget 的高度來彈出 BottomSheet,而這個 widget 的高度是能夠滑動改變的,那麼 LayoutBuilder 就派上用場了,作法以下:

須要監聽的 widgetBody() 組件,給 Body() 組件套上一個 Stack

get body => Stack(
  children: <Widget>[
    Body(),
    BodyLayout(model),
  ],
);
複製代碼

而後用 BodyLayout 組件來監聽:

import 'package:flutter/material.dart';

import 'package:vhiphop/provider/video/video_model.dart';

class BodyLayout extends StatelessWidget {

  final VideoModel model;
  BodyLayout(this.model);

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (_, BoxConstraints constraints) {
      model.bottomSheetDy = constraints.maxHeight;
      return emptyBox;
    });
  }
}
複製代碼

Body() 組件高度發生變化時,會觸發 LayoutBuilderbuilder 回調函數,在此函數中將高度信息傳遞給 model ,那麼每次彈出 BottomSheet 以前,我就能夠從 model 中拿到高度,以設置 BottomSheet 的高度。

底部彈出動畫的兩種實現方式

這種動畫在 App 中是很常見的效果,例如 App 分享功能,點擊分享按鈕後,會從頁面底部彈出分享組件。

第一種,利用 showModalBottomSheet,相關實現代碼以下:

void showShareBottomSheet() {
    showModalBottomSheet(
      elevation: 0,
      backgroundColor: currentTheme.highlightColor,
      context: context,
      builder: (context) => Container(
        width: Screens.width,
        decoration: BoxDecoration(color: currentTheme.primaryColor),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Container(
              alignment: Alignment.bottomLeft,
              height: setWidth(59),
              padding: EdgeInsets.only(left: setWidth(42)),
              child: Text(
                '分享',
                style: TextStyle(
                  fontSize: setSp(32),
                  color: currentTheme.highlightColor,
                ),
              ),
            ),
            Container(
              height: setWidth(206),
              padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
              alignment: Alignment.topLeft,
              decoration: BoxDecoration(
                border: Border(
                  bottom: BorderSide(
                    width: setWidth(.7),
                    color: currentTheme.dividerColor,
                  ),
                ),
              ),
              child: Row(
                children: <Widget>[
                  shareIconOfQQ,
                  shareIconOfQQZone,
                  shareIconOfWeChat,
                  shareIconOfWeChatMoments,
                  shareIconOfMicroBlog,
                ],
              ),
            ),
            Container(
              height: setWidth(206),
              padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
              alignment: Alignment.topLeft,
              child: Row(
                children: <Widget>[
                  shareIconOfLink,
                ],
              ),
            ),
            GestureDetector(
              onTap: () {
                Navigator.pop(context);
              },
              child: Container(
                width: Screens.width,
                height: setWidth(125),
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  border: Border(
                    top: BorderSide(
                      width: setWidth(10),
                      color: currentTheme.backgroundColor,
                    ),
                  ),
                ),
                child: Text(
                  '取消',
                  style: TextStyle(
                    fontSize: setSp(36),
                    color: currentTheme.highlightColor,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
複製代碼

使用 translate 實現

我在項目中使用 showModalBottomSheet 時發現動畫有點卡頓,多是測試手機不行,只花了 1000 大洋,但咱是個倔強窮人,非要找一種性能更好的方式,那就是 translate 了。

這種方法比 showModalBottomSheet 動畫性能更高,在我 1000 大洋的測試機 debug 模式下都很是地絲滑流暢,只是代碼實現更復雜一點,而且須要依賴 Provider 來更新,我比較喜歡這種方式。

整個頁面都使用 Stack 構建,而 bottomSheet 與遮罩 box 則使用 Positioned 定位至頁面底部:

get body => Stack(
  children: <Widget>[
    page,
    Positioned(
      left: 0,
      bottom: 0,
      right: 0,
      child: bottomSheetBox,
    ),
    Positioned(
      left: 0,
      top: 0,
      right: 0,
      bottom: shareBottomSheetHeight,
      child: bottomSheetBoxMask,
    ),
  ],
);
複製代碼

接着使用我定義的一個工具類,名字叫 AnimatedTranslateBox,我發現 Animated 家族有各類動畫組件,好比 AnimatedPaddingAnimatedPositioned 等等,惟獨沒有 Translate,不知道官方是什麼意思,可能他們以爲 Positioned 來調整位置就夠用了叭,但是 translate 動畫性能更高,它不香嗎?不要緊,咱本身造了一個,代碼以下:

import 'package:flutter/material.dart';

class AnimatedTranslateBox extends StatefulWidget {
  AnimatedTranslateBox({
    Key key,
    this.dx,
    this.dy,
    this.child,
    this.curve = Curves.linear,
    this.duration = const Duration(milliseconds: 200),
    this.reverseDuration,
  });

  final double dx;
  final double dy;
  final Widget child;
  final Duration duration;
  final Curve curve;
  final Duration reverseDuration;

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

class _AnimatedTranslateBoxState extends State<AnimatedTranslateBox> with SingleTickerProviderStateMixin {

  AnimationController controller;
  Animation<double> animation;
  Tween<double> tween;

  void _updateCurve() {
    animation = widget.curve == null
      ? controller
      : CurvedAnimation(parent: controller, curve: widget.curve);
  }

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: widget.duration,
      reverseDuration: widget.reverseDuration,
      vsync: this,
    );
    tween = Tween<double>(begin: widget.dx ?? widget.dy);
    _updateCurve();
  }

  @override
  void didUpdateWidget(AnimatedTranslateBox oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.curve != oldWidget.curve) _updateCurve();
    controller
      ..duration = widget.duration
      ..reverseDuration = widget.reverseDuration;
    if ((widget.dx ?? widget.dy) != (tween.end ?? tween.begin)) {
      tween
        ..begin = tween.evaluate(animation)
        ..end = widget.dx ?? widget.dy;
      controller
        ..value = 0.0
        ..forward();
    }
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  build(context) => AnimatedBuilder(
    animation: animation,
    builder: (context, child) => widget.dx == null
        ? Transform.translate(
            offset: Offset(0, tween.animate(animation).value),
            child: child,
          )
        : Transform.translate(
            offset: Offset(tween.animate(animation).value, 0),
            child: child,
          ),
    child: widget.child,
  );
}
複製代碼

調用很簡單,使用 Selector 依賴 model 中的布爾值,用於控制顯示隱藏:

get bottomSheetBox => Selector<VideoModel, bool>(
  selector: (context, model) => model.showBottomSheet,
  builder: (context, show, child) => AnimatedOpacity(
    opacity: show ? 1 : 0,
    curve: show ? Curves.easeOut : Curves.easeIn,
    duration: bottomSheetDuration,
    child: AnimatedTranslateBox(
      dy: show ? 0 : bottomSheetHeight,
      curve: show ? Curves.easeOut : Curves.easeIn,
      duration: bottomSheetDuration,
      child: child,
    ),
  ),
  child: Container(
    height: bottomSheetHeight,
    child: bottomSheet,
  ),
);
複製代碼

每當 dxdy 的值發生改變,AnimatedTranslateBox 的 child 就會根據 dxdy 的值進行 y 軸 或 x 軸的移動動畫。

相關的效果以下:

bottom_sheet.gif

Provider 調用問題

我發現若是在 MaterialApp 下全局掛載了 Provider ,則在 Home 頁初始化完成前,是沒法使 Provider 的,例如:

class MyApp extends StatelessWidget {

  final _userModel = UserModel();
  final _homeModel = HomeModel();

  Widget build(BuildContext context) {
    return OKToast(
      dismissOtherOnShow: true,
      child: MultiProvider(
        providers: [
          ChangeNotifierProvider.value(value: _userModel),
          ChangeNotifierProvider.value(value: _homeModel),
        ],
        child: Selector<ThemeModel, ThemeData>(
          selector: (context, model) => model.theme,
          builder: (context, theme, child) => MaterialApp(
            navigatorKey: Constants.navigatorKey,
            debugShowCheckedModeBanner: false,
            theme: theme,
            initialRoute: '/',
            routes: {
              '/': (context) => HomePage(),
            },
          ),
        ),
      ),
    );
  }
}
複製代碼

上面的代碼聲明瞭 MultiProvider,若是在首頁作以下調用:

@override
initState() {
  _model = Provider.of<HomeModel>(context);
  _userModel = Provider.of<UserModel>(context);
  super.initState();
}
複製代碼

則會報錯:

I/flutter ( 8380): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter ( 8380): The following assertion was thrown building Builder:
I/flutter ( 8380): dependOnInheritedWidgetOfExactType<_DefaultInheritedProviderScope<HomeModel>>() or
I/flutter ( 8380): dependOnInheritedElement() was called before _HomePageState.initState() completed.
I/flutter ( 8380): When an inherited widget changes, for example if the value of Theme.of() changes, its dependent
I/flutter ( 8380): widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor
I/flutter ( 8380): or an initState() method, then the rebuilt dependent widget will not reflect the changes in the
I/flutter ( 8380): inherited widget.
I/flutter ( 8380): Typically references to inherited widgets should occur in widget build() methods. Alternatively,
I/flutter ( 8380): initialization based on inherited widgets can be placed in the didChangeDependencies method, which
I/flutter ( 8380): is called after initState and whenever the dependencies change thereafter.
複製代碼

提示 initState 必須調用完成,才能使用 Provider.of 來獲取祖先節點的 model,非要使用怎麼辦?辦法也很簡單, of 方法有一個屬性值 listen,默認值爲 true,將此值設置爲 false 則不會創建與 Provider 的依賴關係,其實我在 Provider 的手冊中也發現,建議在 initState 方法中調用 of 時,將 listen 設置爲 false

@override
initState() {
  _userModel = Provider.of<UserModel>(context, listen: false);
  _model = Provider.of<HomeModel>(context, listen: false);
  super.initState();
}
複製代碼

如何實現網易雲音樂、QQ音樂播放頁面的背景圖片模糊效果

分析一下,其實這種效果特別簡單,首先放大背景圖片,其次對圖片進行高斯模糊,直接上代碼:

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

main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  final image = Image.asset(
    'assets/images/test.jpg',
    fit: BoxFit.cover,
    width: 200,
    height: 200,
  );
  
  get blurImage => ClipRRect(
    child: Stack(
      children: <Widget>[
        Transform.scale(
          scale: 1.5,
          child: image,
        ),
        BackdropFilter(
          filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
          child: Container(
            width: 200,
            height: 200,
            alignment: Alignment.center,
            color: Colors.black.withOpacity(.3),
            child: Text(
              '1 個內容',
              style: TextStyle(
                fontSize: 24,
                color: Colors.white,
              ),
            ),
          ),
        ),
      ],
    ),
);
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo app',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: Scaffold(
        appBar: AppBar(title: Text('blur image demo')),
        body: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Container(
                  margin: EdgeInsets.only(bottom: 30),
                  child: image,
                ),
                blurImage,
              ],
            ),
          ],
        )
      ),
    );
  }
}
複製代碼

這個效果其實沒什麼難度,主要的知識點在於 BackdropFilter 組件默認的模糊效果是全屏的,必須使用 ClipRRect 進行裁剪,並且 Transform 的幾個命名構造函數,如 Transform.translate 帶來的效果是在繪製階段發生的,會超出 widget 實際佔用的空間,也須要使用 ClipRRect 進行裁剪,最後的效果圖以下:

blur_img.jpg
相關文章
相關標籤/搜索