Flutter 玩轉微信——微信首頁

CoderMikeHe發表於2020-06-19

概述

  • 這篇文章主要介紹的是如何利用Flutter搭建微信首頁的功能,詳細講述該功能實現過程中所運用到的技術,以及遇到問題後如何解決的心得體會。該功能雖然粗看時看似簡單,但是細作時發現其功能邏輯複雜,內部細節處理較高,當然其中涵蓋了Flutter中大部分知識點,筆者相信初學者通過實現該功能後,定會對所學的Flutter知識的掌握上更上一層樓

  • 筆者此次主要實現了微信首頁的以下幾個功能點:

    • 訊息的側滑刪除
    • 下拉顯示小程式
    • 點選導航欄 + 按鈕,彈出選單欄
    • 點選搜尋框,彈出搜尋頁
  • 筆者希望初學者通過實現上面?的功能點,能夠在學習Flutter的過程中有所幫助,當然筆者必將知無不言、言無不盡,梳理實戰過程之問題,總結解決問題之方案,讓爾等知其然,知其所以然。望能拋玉引磚,擺渡眾生,如有紕漏,還望斧正。

  • 原始碼地址:flutter_wechat

效果圖

GIF 微信頁 選單欄
mainframe_page.gif
mainframe_page_1.png
mainframe_page_2.png
小程式 搜尋頁
mainframe_page_3.png
mainframe_page_4.png

知識儲備

  • Stack + Positioned 佈局
  • Transform.translate(平移)Transform.scale(放大)Opacity(設定子部件透明度)
  • 滾動監聽及控制
  • 動畫元件使用(AnimatedPositioned、AnimatedOpacity、ScaleTransition)
  • 狀態管理Provider
  • 監聽鍵盤彈起
  • 通過GlobalKey 獲取某個 Widget 的尺寸

功能

一、訊息的側滑刪除

側滑刪除的功能,主要利用 flutter_slidable 外掛來實現的,其具體實現過程以及細節處理的心得體會,與筆者前面寫過的 Flutter 玩轉微信——通訊錄 文章中詳細說明如何實現聯絡人側滑刪除的功能類似,這裡筆者就不再一一贅述。有興趣的同學,還請自行移步。

二、下拉顯示小程式

下拉顯示小程式,以及顯示後上拉隱藏小程式的功能,個人認為在實現過程是比較複雜的,涵蓋大部分Flutter必備的知識點,所以筆者會詳述其實現過程中遇到的坑以及填坑的方法。

  • UI搭建

由於考慮到下拉過程中,內容頁導航欄三個點小程式都會層疊展示,所以整個微信頁面這裡採取的是 Stack + Positioned 佈局方案,關於UI構建的細節,大家參看原始碼即可,這裡就不再贅述,具體虛擬碼如下:

/// 構建子部件
Widget _buildChildWidget() {
  return Container(
    constraints: BoxConstraints.expand(),
    color: Style.pBackgroundColor,
    child: Stack(
      overflow: Overflow.visible,
      // 注意層疊順序,她不像 Web 中有 z-index 的概念
      children: <Widget>[
        // 導航欄
        // 內容頁
        // 三個點部件
        // 小程式
        // 選單
      ],
    ),
  );
}
複製程式碼

特別注意:Stack 中子部件(Positioned)新增順序,最後面新增的在最上面,她不像 Web 中的樣式有z-index的概念。

  • 功能分析

大家可以對比你手機上的微信首頁,下拉顯示小程式的功能上其實涵蓋了,下拉顯示小程式上拉隱藏小程式兩個過程的邏輯處理,當然這才是一個真正的閉環,有顯示就會有隱藏。這裡筆者就只拿以 下拉邏輯 為例,詳細講解其中的邏輯分析和細節處理。上拉邏輯 大家可以反推即可。

❗️下拉邏輯

  1. 手指下拉內容頁整個過程中,導航欄 的頂部會隨著手指下拉而向下偏移(offset),偏移距離等於下拉距離。
  2. 繼續下拉到 臨界點① = 60時,出現一個小球逐漸放大,放大係數(scale) = 0,當 偏移量 > 臨界點① 時,scale 會逐漸變大;反之,scale = 0
  3. 繼續下拉到 臨界點② = 90時,此過程中,小球 會放大到最大值(scale = 2)。即offset:臨界點① --> 臨界點②scale: 0 --> 2。繼
  4. 續下拉到 臨界點③ = 130時,此過程中,小球會生成兩個小球,一個小球逐漸左平移到最大值,一個小球逐漸右平移到最大值,其本身也縮放到原始值(scale = 1)。
  5. 繼續下拉到 臨界點④ = 180時,此過程中,三個球的透明度(opacity)從 1.0 --> 0.2 變化,以及小程式模組透明度(opacity)從0 --> 0.5變化且自身縮放比例(scale)為(scale = 0.4)。
  6. 繼續下拉 offset > 臨界點④時,三個小球的透明度恆等於0.2,以及小程式模組透明度恆等於0.5且自身縮放比例(scale)恆為(scale = 0.4)。

注意: 以上?過程都是使用者手指都是處於拖拽狀態,也就是手指沒有離開螢幕。那麼手指離開螢幕後,有會發生什麼狀況呢,請聽筆者一一道來。

  1. 手指釋放的一瞬間,判斷下拉偏移量offset 是否大於 臨界點② = 90, 若大於,則顯示小程式模組,反之,則隱藏小程式模組。
  2. 顯示小程式的過程中,導航欄的底部偏移到螢幕的底部、內容頁的頂部平移到螢幕的底部,小程式的透明度由0.5 --> 1且縮放比例由0.4 --> 1、底部導航欄隱藏。
  • 功能實現

通過上面的功能分析,我們不難給出程式碼實現。但是必須明確的是,整個下拉或上拉過程中,我們必須依賴一個非常重要的資料——滾動偏移量(offset),那麼我們必須得監聽列表的滾動,從而根據偏移量來完成整個UI邏輯。關於滾動監聽,大家可以參看?滾動監聽及控制 這篇文章。

滾動監聽有兩種方案,其關鍵程式碼如下:

// 方案一
_controller.addListener(() {
    // 獲取偏移量
   final offset = _controller.offset;
    // 處理
    _handlerOffset(offset);
});

// 方案二
NotificationListener(
 onNotification: (ScrollNotification notification) {
    // 正在重新整理 do nothing...
    if (_isRefreshing || _isAnimating) {
       return false;
    }
    // offset
    final offset = notification.metrics.pixels;
    if (notification is ScrollStartNotification) {
      if (notification.dragDetails != null) {
        _focus = true;
      }
    } else if (notification is ScrollUpdateNotification) {
      // 能否進入重新整理狀態
      final bool canRefresh = offset <= 0.0
          ? (-1 * offset >= _topDistance ? true : false)
          : false;
      if (_focusState && notification.dragDetails == null) {
          _focus = false;
          // 手指釋放的瞬間
          _isRefreshing = canRefresh;
      }
    } else if (notification is ScrollEndNotification) {
       if (_focusState) {
         _focus = false;
       }
    }
   // 處理
   _handlerOffset(offset);
   return false;
},
複製程式碼

通過NotificationListener監聽滾動事件和通過ScrollController有兩個主要的不同:

  • 通過NotificationListener可以在從可滾動元件到widget樹根之間任意位置都能監聽。而ScrollController只能和具體的可滾動元件關聯後才可以。
  • 收到滾動事件後獲得的資訊不同;NotificationListener在收到滾動事件時,通知中會攜帶當前滾動位置和ViewPort的一些資訊,而ScrollController只能獲取當前滾動位置。

當然這裡筆者使用NotificationListener監聽滾動事件的另一個重要原因是:監聽手指是否處於拖拽狀態,即notification.dragDetails != null。從而明確使用者手指離開螢幕的瞬間時,得到此時的偏移量,以此來決定小程式模組的顯示與否。

一旦我們監聽列表滾動的偏移量,頁面只需要根據_offset的變化而變化即可,偏移量處理如下:

// 處理偏移邏輯
void _handlerOffset(double offset) {
  // 計算
  if (offset <= 0.0) {
    _offset = offset * -1;
  } else if (_offset != 0.0) {
    _offset = 0.0;
  }
  // 這裡需要
  if (_isRefreshing && !_isAnimating) {
    // 重新整理且非動畫狀態
    // 正在動畫
    _isAnimating = true;
    // 動畫時間
    _duration = 300;
    // 最終停留的位置
    _offset = ScreenUtil.screenHeightDp -
        kToolbarHeight -
        ScreenUtil.statusBarHeight;
    // 隱藏掉底部的TabBar
    Provider.of<TabBarProvider>(context, listen: false).setHidden(true);
    setState(() {});
    return;
  }

  _duration = 0;
  // 非重新整理且非動畫狀態
  if (!_isAnimating) {
    setState(() {});
  }
}
複製程式碼

因為考慮到UI佈局依賴於_offset的變化而變化,這裡必須強調的是下拉過程中的兩種狀態:

  • 拖拽狀態(手指未離開螢幕)
  • 非拖拽狀態(手指離開螢幕)

拖拽狀態 下時UI,導航欄的頂部回跟隨_offset的變化發生偏移,其無非是修改Positionedtop屬性即可,虛擬碼如下:

Positioned(
    top: _offset,
    //...
)
複製程式碼

當結束 拖拽狀態 下時UI,即:如果手指釋放的瞬間,_offset 大於 臨界點,則 導航欄內容頁...等部件會絲滑的過渡到底部,這裡想必大家一定清楚了,要想實現絲滑過渡這個功能,一定離不開動畫的加持。那麼這種狀態下,若依然延用修改Positionedtop屬性方法就會在這個過程中顯得生硬,所以這裡採用Flutter 自帶的動畫元件 AnimatedPositioned 來代替 Positioned。 虛擬碼如下:

AnimatedPositioned(
    top: _offset,
    duration: Duration(milliseconds: 300),
    //...
)
複製程式碼

AnimatedPositioned雖然輕而易舉的實現了非拖拽狀態下時 導航欄 絲滑過渡到底部的功能,但是若處於拖拽狀態下時,用AnimatedPositioned就會導致導航欄很Q彈,比較差強人意。為了兼顧這兩種狀態,筆者採用的是控制AnimatedPositionedduration屬性來實現的,即:拖拽時,_duration=0;釋放且大於臨界點時,_duration=300。虛擬碼如下:

AnimatedPositioned(
    top: _offset,
    duration: Duration(milliseconds:(_isRefreshing ? 300 : 0)),
    //...
)
複製程式碼

當然,筆者認為下拉過程中比較有趣的功能點就是:三個小球邏輯。當然結合上面的功能分析,其實實現也比較簡單,主要用到Opacity 、Transform.translate、Transform.scale 元件,且其使用比較高頻,大家很有必要掌握,這裡筆者給出關鍵程式碼邏輯,大家一看便知:

// 階段I臨界點
final double stage1Distance = 60;
// 階段II臨界點
final double stage2Distance = 90;
// 階段III臨界點
final double stage3Distance = 130;
// 階段IV臨界點
final double stage4Distance = 180;

final top = (offset + 44 + 10 - 6) * 0.5;

// 中間點相關
double scale = 0.0;
double opacityC = 0;

// 右邊點相關
double translateR = 0.0;
double opacityR = 0;

// 右邊點相關
double translateL = 0.0;
double opacityL = 0;

final cOffset = (offset <= stage4Distance) ? offset : stage4Distance;

if (offset > stage3Distance) {
  // 第四階段 1 - 0.2
  final step = 0.8 / (stage4Distance - stage3Distance);
  double opacity = 1 - step * (cOffset - stage3Distance);
  if (opacity < 0.2) {
    opacity = 0.2;
  }
  // 中間點階段III: 保持scale 為1
  opacityC = opacity;
  scale = 1;

  // 右邊點階段III: 平移到最右側
  opacityR = opacity;
  translateR = 16;

  // 左邊點階段III: 平移到最左側
  opacityL = opacity;
  translateL = -16;
} else if (offset > stage2Distance) {
  final delta = stage3Distance - stage2Distance;
  final deltaOffset = offset - stage2Distance;

  // 中間點階段II: 中間點縮小:2 -> 1
  final stepC = 1 / delta;
  opacityC = 1;
  scale = 2 - stepC * deltaOffset;

  // 右邊點階段II: 慢慢平移 0 -> 16
  final stepR = 16.0 / delta;
  opacityR = 1;
  translateR = stepR * deltaOffset;

  // 左邊點階段II: 慢慢平移 0 -> -16
  final stepL = -16.0 / delta;
  opacityL = 1;
  translateL = stepL * deltaOffset;
} else if (offset > stage1Distance) {
  final delta = stage2Distance - stage1Distance;
  final deltaOffset = offset - stage1Distance;

  // 中間點階段I: 中間點放大:0 -> 2
  final step = 2 / delta;
  opacityC = 1;
  scale = 0 + step * deltaOffset;
}
複製程式碼

小程式模組,在下拉過程中,只需要控制其透明度opacity,以及內容頁的縮放scale係數即可,以及上拉過程中,控制好其透明度opacity即可,總體來說,So Easy ~,當然整個過程也是都需要考慮手指的 拖拽狀態,也就是需要加動畫,如:透明度動畫、縮放動畫。對此這裡用到的對應的動畫元件如下,

  • AnimatedOpacity 替代 Opacity,增加透明度動畫
  • ScaleTransition 替代 Transform.scale,增加縮放動畫

關於其具體的使用,大家還請自行閱讀原始碼哈,就不再贅述了。當然,小程式模組 筆者覺得比較細節的地方,就是UI佈局上了。因為要實現上拉滑動,且小程式內容頁也支援上下拉。所以就涉及到巢狀滑動,即ListView巢狀ListView。因為最外層的上拉滑動,能促使導航欄、內容頁向上偏移,所以最外層的ListViewmaxScrollExtent:最大可滾動長度的處理是比較細節的。也就是理想情況下,手指從螢幕最底部向上拖拽到螢幕最頂部,正好能使導航欄的最頂部到達螢幕的頂部即可,那麼maxScrollExtent = 2 * 螢幕的高度 - 狀態列的高度 - 導航欄的高度 ,且如果小程式內容頁高度已知(假設:480)。那麼最外層的ListView不僅要巢狀一個ListView(高度480),而且要巢狀一個空(佔位)部件(SizedBox),且空部件的高度為:

佔位部件高度 =  2 * 螢幕的高度 - 狀態列的高度 - 導航欄的高度 - 480;
複製程式碼

當然上拉和下拉類似,無非也是監聽滾動,處理滾動的偏移量,上拉的偏移量的處理程式碼如下:

/// 處理小程式滾動事件
void _handleAppletOnScroll(double offset, bool dragging) {
  if (dragging) {
    _isAnimating = false;
    // 去掉動畫
    _duration = 0;
    // 計算高度
    _offset = ScreenUtil.screenHeightDp -
        kToolbarHeight -
        ScreenUtil.statusBarHeight -
        offset;
    // Fixed Bug: 如果是dragging 狀態下 已經為0.0 ;然後 非dragging 也為 0.0 ,這樣會導致 即使 setState(() {}); 也沒有卵用
    // 最小值為 0.001
    _offset = max(0.0001, _offset);
    setState(() {});
    return;
  }
  if (!_isAppletRefreshing && !_isAnimating) {
    // 開始動畫
    _duration = 300;

    // 計算高度
    _offset = 0.0;

    _isAppletRefreshing = true;
    _isAnimating = true;

    setState(() {});
  }
}
複製程式碼

小模組內容頁,也有個比較新穎的小功能:就是預設每次進來小程式模組是隱藏搜尋框的,只有當使用者下拉一丟丟,手指釋放時,會自動看到搜尋框,且使用者上拉一丟丟,手指釋放時,也會自動隱藏搜尋框的。實現這一功能主要涉及到兩個知識點:監聽滾動控制滾動。其中監聽滾動肯定已經耳熟能詳了,控制滾動有兩個常用API如下:

  • jumpTo(double offset)
  • animateTo(double offset,...)

這兩個方法用於跳轉到指定的位置,它們不同之處在於,後者在跳轉時會執行一個動畫,而前者不會罷了。

所以,我們只要在滾動結束後,通過是下拉還是上拉,來決定是否顯示搜尋框。關鍵程式碼如下:

return NotificationListener(
  onNotification: (ScrollNotification notification) {
    if (notification is ScrollStartNotification) {
      if (notification.dragDetails != null) {
        // 記錄起始拖拽
        _startOffsetY = notification.metrics.pixels;
      }
    } else if (notification is ScrollEndNotification) {
      final offset = notification.metrics.pixels;
      if (_startOffsetY != null &&
          offset != 0.0 &&
          offset < ScreenUtil().setHeight(60.0 * 3)) {
        // 如果小於 60 再去判斷是 下拉 還是 上拉
        if ((offset - _startOffsetY) < 0) {
          // 下拉
          Future.delayed(
            Duration(milliseconds: 10),
            () async {
              _controllerContent.animateTo(.0,
                  duration: Duration(milliseconds: 200),
                  curve: Curves.ease);
            },
          );
        } else {
          // 上拉
          // Fixed Bug : 記得延遲一丟丟,不然會報錯 Why?
          Future.delayed(
            Duration(milliseconds: 10),
            () async {
              _controllerContent.animateTo(ScreenUtil().setHeight(60.0 * 3),
                  duration: Duration(milliseconds: 200),
                  curve: Curves.ease);
            },
          );
        }
      }
      // 這裡設定為null
      _startOffsetY = null;
    }
    return true; // 阻止冒泡
  },
  child: ListView()
}
複製程式碼

但是如果我們在結束滾動的一瞬間,呼叫 jumpTo(double offset) 或 animateTo(double offset,...)其實是不起作用的,只有延遲一丟丟時間,再去控制其滾動才行,這裡筆者也是懵逼好久,還望有緣人解答一下哈(評論即可)~。

這裡還要講一個功能點:下拉釋放時,需要隱藏底部的tabBar;上拉釋放時,需要顯示底部tabBar。這裡就要用到狀態管理的功能。 這裡主要筆者藉助 provider 來實現的。關鍵程式碼如下:

/// 用於控制TabBar 的顯示和隱藏
class TabBarProvider with ChangeNotifier {
  // 顯示or隱藏
  bool _hidden = false;
  bool get hidden => _hidden;

  void setHidden(bool hidden) {
    _hidden = hidden;
    notifyListeners();
  }
}

// UI層
return Consumer<TabBarProvider>(
  builder: (context, tabBarProvider, _) {
    return Scaffold(
      appBar: null,
      body: list[_currentIndex],
      // iOS
      bottomNavigationBar: tabBarProvider.hidden
          ? null
          : CupertinoTabBar(
              items: myTabs,
              onTap: _itemTapped,
              currentIndex: _currentIndex,
              activeColor: Style.pTintColor,
              inactiveColor: Color(0xFF191919),
            ),
    );
  },
);

// 下拉釋放時,隱藏
Provider.of<TabBarProvider>(context, listen: false).setHidden(true);

// 上拉釋放時,顯示
Provider.of<TabBarProvider>(context, listen: false).setHidden(false);

複製程式碼

至此!下拉顯示小程式的功能點也就是以上這些了,當然一些UI搭建和邏輯處理還是比較複雜的,只要你思維縝密,邏輯清晰,也就沒什麼難得了。


三、點+按鈕彈出選單

該功能的實現也是細節滿滿,由於展示和隱藏都需要用到動畫,主要用到的透明度動畫AnimatedOpacity縮放動畫ScaleTransition元件。

  • 功能分析 1、 點選導航欄+按鈕,選單漸漸顯示(透明度動畫)。 2、 顯示選單欄後,點頁面空白處,選單漸漸向右上角縮放隱藏(透明度動畫+縮放動畫)

  • 功能實現 關鍵程式碼如下:

@override
void initState() {
  super.initState();

  // 配置動畫
  _controller = new AnimationController(
      vsync: this, duration: Duration(milliseconds: 200));
  _animation =
      new CurvedAnimation(parent: _controller, curve: Curves.easeInOut);

  // 監聽動畫
  _controller.addStatusListener((AnimationStatus status) {
    // 到達結束狀態時  要回滾到開始狀態
    if (status == AnimationStatus.completed) {
      // 正向結束, 重置到當前
      _controller.reset();
      setState(() {});
    }
  });
}

@override
Widget build(BuildContext context) {
  if (widget.show) {
    // 只有顯示後 才需要縮放動畫
    _shouldAnimate = true;
    _scaleBegin = _scaleEnd = 1.0;
  } else {
    _scaleBegin = 1.0;
    _scaleEnd = 0.5;
    // 處於開始階段 且 需要動畫
    if (_controller.isDismissed && _shouldAnimate) {
     _shouldAnimate = false;
      _controller.forward();
    } else {
      _scaleEnd = 1.0;
    }
  }

  // Fixed Bug: offstage 必須要等縮放動畫結束後才去設定為 true, 否則 休想看到縮放動畫
  return Offstage(
    offstage: !widget.show && _controller.isDismissed,
    child: InkWell()
}
複製程式碼

結合?程式碼,特別要注意的是,隱藏選單時,要加個判斷邏輯,只有當顯示過選單以及動畫狀態正處於開始狀態時,才去進行縮放動畫,且動畫完成後需要重置到初始狀態,以便下次繼續縮放。當然,一定要等縮放動畫結束後,方可隱藏整個選單(蒙版+內容),否則是看不到縮放動畫的,因為蒙版會比內容先隱藏。

四、點選搜尋框,彈出搜尋頁

該功能的實現上還是涵蓋幾個比較重要的知識點的,且都是日常開發中比較常用的,由於筆者也是在學習Flutter的路上,很多知識都不夠全面,導致其實現過程中還是遇到了些許坑,這裡筆者一一詳盡,所需知識點如下:

  • 通過GlobalKey 獲取某個 Widget 的尺寸

  • AnimatedPositioned 實現平移動畫

  • 監聽鍵盤的高度變化

  • 功能分析 1、點選微信首頁搜尋框,?搜尋取消 按鈕同時向左平移,並且AppBarSearch頁同時向上移動,鍵盤彈出;微信內容頁底部TabBar 隱藏,搜尋頁面展示,按住說話 按鈕跟隨鍵盤彈出而彈出。 2、點選搜尋頁的取消按鈕?搜尋取消按鈕同時向右平移,並且AppBarSearch頁同時向下移動,鍵盤收起;微信內容頁底部TabBar 顯示,搜尋頁面隱藏,清掉搜尋內容,按住說話 按鈕跟隨鍵盤收起而收起。

  • UI搭建

UI主要包括搜尋框(SearchBar)搜尋頁(SearchContent)的搭建,雖整體不難,但細節滿滿。因為考慮平移(左移、右移)動畫監聽鍵盤高度變化而變化的UI,所以整體內部widget佈局都是採用Stack + Positioned/AnimatedPositioned 來構建的,當然道路千萬條,實現第一條。這裡以SearchBar為例,其內部的子部件(widget)佈局,虛擬碼如下:

Stack(
  children: <Widget>[
      // 白色背景框
      AnimatedPositioned(),
      // 輸入框
      Positioned(),
      // ?搜尋 按鈕 
      AnimatedPositioned(),
      // 取消按鈕
      AnimatedPositioned()
  ]
)
複製程式碼
  • 功能實現

?搜尋 居中實現。雖然UI實現居中可能比較簡單,比如: Stackalignment: AlignmentDirectional.center,RowmainAxisAlignment: MainAxisAlignment.center, ,以及 Containeralignment: AlignmentDirectional.center,等.... 但是需要考慮到動畫的加入以及動畫絲滑的效果,就不得不採用Stack佈局的形式了,以及採用Stackalignment: AlignmentDirectional.center,來達到居中,且AnimatedPositionedleftright必須設定``null,不然是無法居中的,虛擬碼如下:

Stack(
  alignment: AlignmentDirectional.center,
  children: <Widget>[
      // ?搜尋 按鈕 
      AnimatedPositioned(
         child: `?搜尋`,
         left : null,
         top: 0,
         bottom: 0
     ),
  ]
)
複製程式碼

雖然上述確實實現了?搜尋 居中,且不費吹灰之力。如果點選?搜尋 按鈕,假設此時是編輯模式,即 _isEdit = true; ,此時?搜尋 按鈕需要加入左移動畫,即AnimatedPositionedleft : 0,同學們可能會非常輕鬆的寫出如下程式碼:

Stack(
  alignment: AlignmentDirectional.center,
  children: <Widget>[
      // ?搜尋 按鈕 
      AnimatedPositioned(
         child: `?搜尋`,
         left : _isEdit? 0 : null,
         top: 0,
         bottom: 0
     ),
  ]
)
複製程式碼

當然,上述程式碼邏輯確實是穩如藏獒,但是一旦執行後,你就會一臉懵逼,因為點選?搜尋 按鈕,?搜尋按鈕會的一下到達左側,絲毫沒有理想情況下的左移的絲滑度。側面驗證 理想34D(很豐滿),現實對A(很骨感)的道理。 其實原因就是: AnimatedPositioned 的 left 是從 null --> 0 過渡的,若 left 有值過渡到 0 是有動畫的。

要想左移縱享絲滑,則AnimatedPositioned 的 left 在非編輯(_isEdit = false)的場景下必須的有值 ,且為了保證?搜尋居中,則left必須滿足:left = (螢幕的寬度 - ?搜尋的寬度) * 0.5,所以首要任務是獲取?搜尋按鈕的尺寸,這裡採用GlobalKey來獲取,關於GlobalKey的使用,大家請自行百度。 虛擬碼如下

/// 用於獲取文字高度
GlobalKey _textKey = new GlobalKey();
/// 搜尋圖示距離左側的距離
double _searchIconLeft = 0;

@override
Widget build(BuildContext context) {
  // 方案一: 先算出 SearchCube 的寬度,再去計算其位置 left ,雖然能實現,但是初次顯示時會跳動
  widgetUtil.asyncPrepare(context, true, (Rect rect) {
    final RenderBox box = _textKey.currentContext.findRenderObject();
    final Size size = box.size;
    setState(() {
      _searchIconLeft = (rect.width - 16.0 - size.width) * .5;
    });
    print('渲染完成  ${rect.size} $size  ${size.width} $_searchIconLeft');
  });
   return Stack(
      alignment: AlignmentDirectional.center,
      children: <Widget>[
          / / ?搜尋 按鈕 
          AnimatedPositioned(
             child: `?搜尋`,
             left : _isEdit? 0 : _searchIconLeft,
             top: 0,
             bottom: 0
         ),
      ]
    ) ;
}
複製程式碼

上面完美實現了?搜尋按鈕居中,且左移動畫縱享絲滑,但是在首次初始化的時候,會有跳動的Bug,原因就是_searchIconLeft初始化為0,導致在widgetUtil.asyncPrepare()計算出來_searchIconLeft,會有個_searchIconLeft 由 0 過渡到 大於0 的動畫,導致了跳動的Bug,解決方法:初始狀態下,left 為 null ,等渲染完成後,再去設定 left 為 _searchIconLeft ,且渲染完成後再去顯示 ?搜尋 按鈕,終極虛擬碼如下

/// 用於獲取文字高度
GlobalKey _textKey = new GlobalKey();
/// 搜尋圖示距離左側的距離
double _searchIconLeft = 0;
/// 是否已經渲染好
bool _isPrepared = false;

@override
Widget build(BuildContext context) {
  // 方案一: 先算出 SearchCube 的寬度,再去計算其位置 left ,雖然能實現,但是初次顯示時會跳動
  widgetUtil.asyncPrepare(context, true, (Rect rect) {
    final RenderBox box = _textKey.currentContext.findRenderObject();
    final Size size = box.size;
    setState(() {
      _isPrepared = true;
      _searchIconLeft = (rect.width - 16.0 - size.width) * .5;
    });
    print('渲染完成  ${rect.size} $size  ${size.width} $_searchIconLeft');
  });
   return Stack(
      alignment: AlignmentDirectional.center,
      children: <Widget>[
          / / ?搜尋 按鈕 
          AnimatedPositioned(
             child: Offstage(
                 offstage: !_isPrepared,
                 child: `?搜尋`,
             ),
             left : _isEdit? 0 : (_isPrepared ? _searchIconLeft : null),
             top: 0,
             bottom: 0
         ),
      ]
    ) ;
}
複製程式碼

鍵盤高度監聽。這個雖然看似簡單,但確實是筆者在實現過程中耗時最久的模組,首先縱觀全網,鍵盤監聽高度的方法都是如下實現,虛擬碼如下:

class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    super.didChangeMetrics();
    // Fixed Bug : bottomNavigationBar 的子頁面無法監聽到鍵盤高度變化, so 沒辦法只能再此監聽了
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
    });
  }
}
複製程式碼

秉承著前人栽樹,後人乘涼的原則,以為程式碼一複製,則功能已實現,考慮到只有搜尋頁(SearchContent)需要監聽,所以興致勃勃的把上述程式碼複製進去了,結果 didChangeMetrics 中獲取的MediaQuery.of(context).viewInsets.bottom;的值一直是 0,程式碼完全沒問題,當結果卻是有問題,真是百撕不得騎姐,結果發現,微信頁、聯絡人頁 都監聽不到,後來筆者大膽猜想,是否bottomNavigationBar 的子頁面無法監聽到鍵盤高度變化,後面筆者把程式碼拷貝到 Homepage 頁就行了,期間過程真是欲哭無淚... 只好利用Provider來記錄HomePage的鍵盤高度變化,從而修改 搜尋頁(SearchContent) 的UI變化,虛擬碼如下:

@override
void didChangeMetrics() {
  super.didChangeMetrics();
  // Fixed Bug : bottomNavigationBar 的子頁面無法監聽到鍵盤高度變化, so 沒辦法只能再此監聽了
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
    Provider.of<KeyboardProvider>(context, listen: false)
        .setKeyboardHeight(keyboardHeight);
  });
}
複製程式碼

總結

首先,本篇文章主要講解了實現微信首頁模組上的幾個功能點:訊息的側滑刪除下拉顯示小程式點選導航欄 + 按鈕,彈出選單欄等功能。其中通過對功能點的逐步剖析和邏輯處理,筆者相信大家在各個功能點的程式碼實現上應該能得心應手了。

其次,能夠掌握一些動畫元件和形變元件的使用,豐富了大家自身的flutter元件庫; 同時學會了列表的監聽滾動控制滾動等知識點,掌握了不同的監聽或控制滾動的方案,以及對Flutter中的狀態管理的實現有了一定的瞭解等...

最後,本文的核心還是想培養大家在寫任意一個功能之前,先做一些功能或邏輯分析,理清思路,確定好實現方案,再去編寫程式碼,磨刀不負砍柴工。同時也希望大家在完成此功能後,對Flutter產生學習的動力和樂趣。

期待

  1. 文章若對您有些許幫助,請給個喜歡❤️,畢竟碼字不易;若對您沒啥幫助,請給點建議?,切記學無止境。
  2. 針對文章所述內容,閱讀期間任何疑問;請在文章底部評論指出,我會火速解決和修正問題。
  3. GitHub地址:github.com/CoderMikeHe
  4. 原始碼地址:flutter_wechat

主頁

GitHub 簡書 CSDN 知乎
點選進入 點選進入 點選進入 點選進入

擴充

相關文章