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

國服第一攝影師發表於2020-02-28

flutter.png

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

不多嗶嗶,單寫一篇文章,先記錄它一手。本文記錄 ?? 在 Flutter 開發中遇到的一些 BUG(as design),避免遺忘,如果正在看文章的你也遇到了,那我們們可以握個手。

容器寬高相關問題

Container 設定寬高不生效

一般是由於父級容器的 constraints 屬性引起的,在 Flutter 中,子元件的大小會被父元件的 constraints 屬性限制,例如

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

上面的程式碼中,Container 元件設定高度為 5 畫素,是無法生效的,因為父級容器已經設定了最小高度為 50 畫素,所以 Container 元件的最終高度將會是 50 畫素。

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

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

UnconstrainedBox 允許其子元件按照其自身的大小繪製,我們會很少直接使用此元件,除非對於 Material 自帶的一些元件,如 Appbar 的 icon 使用了固定大小,利用該元件可以解除限制,一般情況下,我們在元件外面套一層佈局類元件就可以解決需求,例如以下元件:

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 元件。

Container 設定 borderRadius 不生效

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

例如給 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 實現

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

這種方法比 showBottomSheet 動畫效能更高,在我 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,
  ),
);
複製程式碼

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

相關的效果如下:

bottom_sheet.gif

Provider 呼叫問題

我發現如果在 MaterialApp 下全域性掛載了 Provider ,則在 Home 頁初始化完成前,是無法使用的,例如:

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

相關文章