利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

debuggerx發表於2018-08-02

Flutter作為現代的響應式UI框架,佈局邏輯上推薦使用Flex佈局來實現不同終端螢幕尺寸和比例的適配,具有非常強大的表現力和靈活性。

如果之前對Flex佈局沒有足夠的瞭解認識,推薦先閱讀Flex 佈局語法教程,雖然Flutter中的flex與html中的flex不盡相同,但是花個十幾分鍾瞭解一下概念會對Flutter中佈局的實現思路很有好處。

也就是說,不應該假定螢幕尺寸為特定值,並且儘量避免使用固定的大小和位置值,而應該分析UI元件的相對邏輯關係進行佈局。Flutter提供了豐富的UI元件,在“組合”的設計思想下,利用Flex系列容器控制元件、各種處理父子關係的元件以及指定寬高值或寬高比的元件等等,足夠滿足大多數情況下的需求。但是很多時候,一些複雜的佈局、動畫效果或者UI相關的邏輯功能卻必須以“獲得約束佈局渲染後部件的尺寸及位置”為前提才能實現。下面我將用自己寫過的三個例子進行說明,希望可以對讀者的學習或工作起到啟發作用。

1.仿掌閱的開頁動畫效果

和很多初學者一樣,我接觸Flutter不久後也嘗試過寫一個完整的應用DEMO,結合當時公司專案需要,我選擇了掌閱APP作為模仿的物件,以此來驗證Flutter開發的實際體驗和效果。在解決了諸如json解析(參考上一篇:快速生成json解析模板類的工具),利用Canvas實現自定義文字排版顯示,SDK中顏色轉換函式的BUG(參考:pr16872)等等問題後,實現的半成品演示動畫如下:

利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

其中,我碰到的第一個比較難的點就是選擇GridView中某一個條目轉跳到閱讀器介面時模擬書本開啟的效果,放慢動畫速度的演示如下:

利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

為了實現這個效果,首先考慮的就是直接使用Flutter提供的Animation元件(參考:Flutter中的動畫)與GridView中的條目元件組合,並在條目佈局上新增手勢控制,在其被點選時藉由動畫控制器來驅動動畫的播放來實現效果。然而,在簡單的嘗試後發現這種思路有很大的問題:

利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

如上圖所示,在點選序號為4的'Card'時,在控制器的驅動下該'Card'執行了縮放動畫,但是能清楚地看到,它左上角放大部分確實是蓋在0~3號'Card'之上的,但是其右下角的放大部分卻被5~8號'Card'遮蓋,也就是說其疊放次序是在3號和5號之間的。

其實不僅僅是ListView/GridView中的條目,介面上的各種UI元件根據其建立的順序以及所處Stack等因素,都是存在疊放次序的問題的。

這樣的動畫明顯與預期不符,不是'拿起一本書一邊拿到眼前一邊開啟'的效果。我花了一段時間嘗試能不能更改選定元件在檢視樹中的疊放次序以解決這個問題,最終卻沒能找到解決的方案,於是只能另闢蹊徑嘗試其他的思路——直到從Flutter中自帶的Hero轉場動畫中獲得了靈感。

利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

從上面Hero動畫的原理介紹我們知道,應用中每個頁面的Navigator會預設建立一個懸浮於檢視頂層的透明Stack,叫做Overlay,可以將UI元件通過OverlayEntry包裝後新增到這個Overlay中從而實現元件的置頂顯示。由於Overlay實際上就是個全屏的Stack,那麼想要是實現元件以原始的位置和大小新增到其中顯示就需要先獲得元件當前的Rect資訊,並通過可以限定Rect的元件對其進行包裝約束後再新增到Overlay中。

通過閱讀Hero的原始碼,可以看到有如下實現程式碼:

        ………前略………
        } else if (toHeroBox.hasSize) {
          // The toHero has been laid out. If it's no longer where the hero animation is
          // supposed to end up then recreate the heroRect tween.
          final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject();
          final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
          if (toHeroOrigin != heroRect.end.topLeft) {
            final Rect heroRectEnd = toHeroOrigin & heroRect.end.size;
            heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd);
          }
        }

        final Rect rect = heroRect.evaluate(_proxyAnimation);
        final Size size = manifest.navigatorRect.size;
        final RelativeRect offsets = new RelativeRect.fromSize(rect, size);

        return new Positioned(
          top: offsets.top,
          right: offsets.right,
          bottom: offsets.bottom,
          left: offsets.left,
          child: new IgnorePointer(
            child: new RepaintBoundary(
              child: new Opacity(
                key: manifest.toHero._key,
                opacity: _heroOpacity.value,
                child: child,
              ),
            ),
          ),
        );
      },
    );
複製程式碼

由此得知指定Rect顯示元件的方法是使用Positioned元件進行處理,而獲取元件當前Rect的方式則是利用元件的Context的findRenderObject()方法獲取元件在渲染樹中的引用,再對這個RenderObject進行研究,得知對其進行座標轉換後可以獲得左上角座標,利用其semanticBounds屬性資訊則可以獲得寬高,由此就得到了元件的Rect資訊。

為了簡化這個流程並減少多餘重複的程式碼,我將這些步驟包裝在了一個叫做'RectGetter'的元件中,用於方便快捷地完成獲取元件Rect的功能,並上傳到了pub倉庫,請通過連結:rect_getter 檢視其用法,後面的例子中都將直接引用這個pub庫來實現相關功能。

於是現在整個過程的思路就是:

  1. 在GridView建立條目的itemBuilder中,利用RectGetter元件包裝原始的Card,使其擁有動態獲得Rect的能力
  2. 在某個Card被點選時,首先通過其RectGetter獲得當前的Rect資訊,並利用該Rect資訊傳遞給Positioned來包裝Card物件,再用OverlayEntry包裝後新增到Overlay層
  3. 驅動Overlay層中的Card執行組合動畫,包括平移、縮放和柱面投影變換(Matrix4.createCylindricalProjectionTransform()),並在動畫過程中調整各個變形動畫的計算原點,實現Card一邊放大一邊移動到螢幕中心的同時模擬3D開頁的效果
  4. 當Card移動到螢幕中心時,3D開頁由於播放到了90°與螢幕垂直而消失,此時藉由上一個動畫的播放完成回撥,驅動'填充動畫'開始播放,使得'書本的背景頁'縮放至全屏顯示
  5. 當填充動畫執行完畢時,在其完成回撥中使用移除了預設路由動畫的自定義路由開啟新頁面,併為該路由新增頁面關閉的回撥函式
  6. 當新頁面關閉時,回撥函式執行,分別反向播放填充動畫和開頁動畫變成合上書本的效果,所有的動畫完成後將Card從Overlay中移除

這個效果的DEMO原始碼地址:flutter_openbookeffect

2. 獲取列表可視Item/轉跳到指定Index

由於有幸參與了 Flutter中文使用者組(QQ:482462550)的管理工作,得以瞭解到很多對Flutter感興趣的朋友的實際開發問題,其中一個便是"在任意時刻獲得ListView可見條目的範圍",類似於文章:Flutter 中 ListView 元件的子元素曝光統計中討論的問題。不同於這篇文章中的方式,我這裡提供一種利用前面RectGetter元件判斷位置的解決思路:

  1. 首先用RectGetter元件包裝ListView本身,從而可以獲得ListView的Rect資訊
  2. 建立列表的Item物件時,在itemBuilder中利用RectGetter元件包裝原始的Item元件,使其擁有動態獲得Rect的能力,並將RectGetter所使用的key記錄在全域性陣列中
  3. 在需要獲得可見條目時,遍歷key陣列,獲得所有'可以獲得Rect資訊的條目的Rect',這包括了實際顯示了的條目,預載入的條目和部分已經劃出螢幕但是被快取的條目
  4. 將上一步中獲得的所有Rect與ListView的Rect進行比較,第一個rect.bottom>listViewRect.top的條目就是第一個顯示的條目,最後一個rect.top<listViewRect.bottom的條目就是最後一個顯示的條目,從而獲得了當前所有顯示的條目

完整DEMO程式碼(編譯執行前注意新增rect_getter依賴):

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _keys = {};

  @override
  Widget build(BuildContext context) {
    /// 給整個ListView設定Rect資訊獲取能力
    var listViewKey = RectGetter.createGlobalKey();
    var _ctl = new ScrollController();

    var listView = RectGetter(
      key: listViewKey,
      child: ListView.builder(
        controller: _ctl,
        itemCount: 1000,
        itemBuilder: (BuildContext context, int index) {
          /// 給每個build出來的item設定Rect資訊獲取能力
          /// 並將用於獲取Rect的key及index存入map中備用
          _keys[index] = RectGetter.createGlobalKey();
          return RectGetter(
            key: _keys[index],
            child: Container(
              color: Colors.primaries[index % Colors.primaries.length],
              child: SizedBox(
                width: 100.0,
                /// 利用index建立偽隨機高度的條目
                height: 50.0 + ((27 * index) % 15) * 3.14,
                child: Center(
                  child: Text('$index'),
                ),
              ),
            ),
          );
        },
      ),
    );

    List<int> getVisible() {
      /// 先獲取整個ListView的rect資訊,然後遍歷map
      /// 利用map中的key獲取每個item的rect,如果該rect與ListView的rect存在交集
      /// 則將對應的index加入到返回的index集合中
      var rect = RectGetter.getRectFromKey(listViewKey);
      var _items = <int>[];
      _keys.forEach((index, key) {
        var itemRect = RectGetter.getRectFromKey(key);
        if (itemRect != null && !(itemRect.top > rect.bottom || itemRect.bottom < rect.top)) _items.add(index);
      });

      /// 這個集合中存的就是當前處於顯示狀態的所有item的index
      return _items;
    }

    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: NotificationListener<ScrollUpdateNotification>(
        onNotification: (notification) {
          /// 滾動時實時列印當前可視條目的index
          print(getVisible());
          return true;
        },
        child: listView,
      ),
    );
  }
}

複製程式碼

效果演示:

利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

相比之下,這種處理方式的好處是準確,能夠適應高度不定條目的處理,不用設定ListView關閉預載入;缺點則是可能產生效能問題(但也有優化空間和手段),以及必須管理好額外的key陣列與列表資料的是對應關係。

在這之後,群裡又有人提出了新的疑問,即如何控制ListView轉跳到指定的index條目顯示。我們知道,在Android/iOS原生的api中都提供了控制列表轉跳到指定item的函式,而Flutter中的ListView並沒有提供該函式,程式碼控制列表滾動只能通過控制器的jumpTo(position)方法,而且這裡的position是實際的滾動距離值而不是條目index值。

群裡多數同學的思路還是利用類似上面文章中,先指定單個固定條目的高度,然後用高度×index的方式得出指定index的偏移值,但是實際操作效果總是不夠理想。而我則是在上一個例子的基礎上稍加擴充套件,用動態的方式比較'精準'地實現了需要的效果:

思路:

  1. 與上一例子的所有思路步驟相同,可以獲得每個時刻可視條目的範圍(即getVisible()這個方法)
  2. 對於指定的目標index,先獲取一次當前可視條目的範圍,如果:
    • 目標index在可視範圍內,執行第4步
    • 如果目標不在可視範圍內,比較index與第一個可視條目的大小,從而確定ListView的滾動方向
  3. 使用jumpTo()方法往目標方向滾動一個ListView的高度,再次執行步驟2邏輯檢查目標index是否在可視條目中,如果不在則迴圈本步驟
  4. 此時目標條目已經出現在了可視條目範圍內,獲取目標條目當前的Rect資訊,並用該rect.top減去listViewRect.top,使用這個差值向上滾動ListView一次,則目標條目就在ListView的第一個顯示位置了

完整DEMO程式碼(編譯執行前注意新增rect_getter依賴):

import 'dart:async';

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var _keys = {};

  @override
  Widget build(BuildContext context) {
    /// 給整個ListView設定Rect資訊獲取能力
    var listViewKey = RectGetter.createGlobalKey();
    var _ctl = new ScrollController();

    var listView = RectGetter(
      key: listViewKey,
      child: ListView.builder(
        controller: _ctl,
        itemCount: 1000,
        itemBuilder: (BuildContext context, int index) {
          print('build : $index');

          /// 給每個build出來的item設定Rect資訊獲取能力
          /// 並將用於獲取Rect的key及index存入map中備用
          _keys[index] = RectGetter.createGlobalKey();
          return RectGetter(
            key: _keys[index],
            child: Container(
              color: Colors.primaries[index % Colors.primaries.length],
              child: SizedBox(
                width: 100.0,
                height: 50.0 + ((27 * index) % 15) * 3.14,
                child: Center(
                  child: Text('$index'),
                ),
              ),
            ),
          );
        },
      ),
    );

    var _textCtl = TextEditingController(
      text: '0',
    );

    List<int> getVisible() {
      /// 先獲取整個ListView的rect資訊,然後遍歷map
      /// 利用map中的key獲取每個item的rect,如果該rect與ListView的rect存在交集
      /// 則將對應的index加入到返回的index集合中
      var rect = RectGetter.getRectFromKey(listViewKey);
      var _items = <int>[];
      _keys.forEach((index, key) {
        var itemRect = RectGetter.getRectFromKey(key);
        if (itemRect != null && !(itemRect.top > rect.bottom || itemRect.bottom < rect.top)) _items.add(index);
      });

      /// 這個集合中存的就是當前處於顯示狀態的所有item的index
      return _items;
    }

    void scrollLoop(int target, Rect listRect) {
      var first = getVisible().first;
      bool direction = first < target;
      Rect _rect;
      if (_keys.containsKey(target)) _rect = RectGetter.getRectFromKey(_keys[target]);
      if (_rect == null || (direction ? _rect.bottom < listRect.top : _rect.top > listRect.bottom)) {
        var offset = _ctl.offset + (direction ? listRect.height / 2 : -listRect.height / 2);
        offset = offset < 0.0 ? 0.0 : offset;
        offset = offset > _ctl.position.maxScrollExtent ? _ctl.position.maxScrollExtent : offset;
        _ctl.jumpTo(offset);
        Timer(Duration.zero, () {
          scrollLoop(target, listRect);
        });
        return;
      }

      _ctl.jumpTo(_ctl.offset + _rect.top - listRect.top);
    }

    void jumpTo(int target) {
      var visible = getVisible();
      if (visible.contains(target)) return;

      var listRect = RectGetter.getRectFromKey(listViewKey);
      scrollLoop(target, listRect);
    }

    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          Expanded(
            child: NotificationListener<ScrollUpdateNotification>(
              onNotification: (notification) {
                getVisible();
                return true;
              },
              child: listView,
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              SizedBox(
                width: 100.0,
                height: 50.0,
                child: TextField(
                  controller: _textCtl,
                ),
              ),
              FlatButton(
                onPressed: () {
                  print('${_textCtl.text}');
                  jumpTo(int.parse(_textCtl.text));
                },
                child: Text('JUMP'),
              )
            ],
          ),
        ],
      ),
    );
  }
}

複製程式碼

效果演示:

利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

而在這個例子的基礎上,類似於'仿微信通訊錄滑動拼音首字母定位聯絡人'等類似功能應該也就不難實現了

3.實現一個瀑布流(未完成,已坑)

這個題目最早也是群裡同學提出的,Flutter的SDK中沒有可以直接實現瀑布流效果的元件。於是我還是用動態獲取Rect的思路嘗試了能否實現一個可用的瀑布流。

說明: 我嘗試解決這個問題時,網上只有一個flutter_staggered_grid_view外掛是處理類似的問題,但是當時版本的外掛在使用時必須預先提供每一個子佈局的寬高,也就是說它並不是一個'正真的瀑布流',既不能根據子佈局實際的動態尺寸顯示,子佈局尺寸變化時也不能自動更新,而且我測試下來發現其效能也不是很理想。

不過隨著這個外掛v0.2.0版本的更新,現在它已經解決了上面所說的問題,雖然我還沒有仔細驗證,如果有這方面需要的同學可以優先嚐試這個外掛能否滿足需求;

而我下面的方法在經過一段時間的嘗試後,雖然初步達到了效果,但是還有很多問題和bug沒能解決,僅供有興趣的同學參考吧

思路:

GridView的建構函式中有一個gridDelegate屬性,接收的是一個SliverGridDelegate物件,而這個委託物件就決定了GridView如何佈局其內部的子元素。通過檢視GridView幾種不同模式的建構函式中所使用的不同SliverGridDelegate子類程式碼,發現它實際只需要四個方法,分別是提供可滾動的offset的最大值、某一offset下需要build的child的最大和最小index,以及指定index的child在viewpoet中的rect(getGeometryForChildIndex)。

所以我的大致思路是:

  1. 假如一次向data陣列中加入了100個child的資訊,由於瀑布流不容易計算實際顯示的child範圍,所以乾脆直接返回需要build的child的最大和最小index就是0~99,這樣比較簡單。然後建立一個內部用於儲存所有child高度資訊的資料結構,當這個資料結構中的某個index資訊第一次被訪問時getGeometryForChildIndex直接返回全屏的尺寸,這樣這個child就會’自由地’被繪製出來,同時將容器中該index資訊標記為已渲染。
  2. 在某個child繪製完成後,利用非同步函式獲取這個child自動繪製以後的寬高,並將這個寬高按照瀑布流中單個child允許的寬度縮放計算後得到的高度資訊更新到資料結構中(比如三列的瀑布流的話資料結構中就有三個List,List中的每個item就記錄了對應item的top和bottom值),然後執行setState觸發重繪。
  3. 重繪時getGeometryForChildIndex函式中判斷髮現該index在資料結構中被標記為已繪製,那麼就取出資料返回該item在瀑布流中應該的rect,由此迴圈就實現了瀑布流的效果
  4. 為每個item新增尺寸變化監聽,一旦其尺寸變化就將新的Rect更新到容器中,並觸發重繪,從而實現瀑布流的自動更新

效果演示:

利用RectGetter元件獲取控制元件位置尺寸實現的幾個高階效果和功能 | 掘金技術徵文

原始碼地址:Flutter_Staggered_View_Demo

從 0 到 1:我的 Flutter 技術實踐 | 掘金技術徵文,徵文活動正在進行中

相關文章