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

Flutter做爲現代的響應式UI框架,佈局邏輯上推薦使用Flex佈局來實現不一樣終端屏幕尺寸和比例的適配,具備很是強大的表現力和靈活性。html

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

也就是說,不該該假定屏幕尺寸爲特定值,而且儘可能避免使用固定的大小和位置值,而應該分析UI組件的相對邏輯關係進行佈局。Flutter提供了豐富的UI組件,在「組合」的設計思想下,利用Flex系列容器控件、各類處理父子關係的組件以及指定寬高值或寬高比的組件等等,足夠知足大多數狀況下的需求。可是不少時候,一些複雜的佈局、動畫效果或者UI相關的邏輯功能卻必須以「得到約束佈局渲染後部件的尺寸及位置」爲前提才能實現。下面我將用本身寫過的三個例子進行說明,但願能夠對讀者的學習或工做起到啓發做用。github

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

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

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

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

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

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

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

從上面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,
      ),
    );
  }
}

複製代碼

效果演示:

相比之下,這種處理方式的好處是準確,可以適應高度不定條目的處理,不用設置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'),
              )
            ],
          ),
        ],
      ),
    );
  }
}

複製代碼

效果演示:

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

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更新到容器中,並觸發重繪,從而實現瀑布流的自動更新

效果演示:

源碼地址:Flutter_Staggered_View_Demo

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

相關文章
相關標籤/搜索