iT邦幫忙

2021 iThome 鐵人賽

DAY 28
0
Mobile Development

Flutter - 複製貼上到開發套件之旅系列 第 28

【第二八天 - Flutter 開發套件之旅(上)】

  • 分享至 

  • twitterImage
  •  

前言

當我在做某某知名平台的畫面時,我發現其實健在都沒有這個效果的 plugin,可以快速達到想要做的事情。
先來宣傳一下我做的套件 => vertical_scrollable_tabview 版本 0.0.2

程式碼 => GITHUB

之後這個版本可能會再去修改,讓整個使用上面的體驗更好,可以比較好控制一些效果。
也非常的歡迎發 PR 給我呦~~

開心的專案

https://ithelp.ithome.com.tw/upload/images/20210915/20134548h8FSZq7VdF.png

講講 plugin 和 Application 的不同

其實就是大概差不多的東西,多了一個叫做 example的 flutter application。
目標:

  • ./example/lib 寫好一個 sample code,給使用這個套件的人看要怎麼使用這個套件。
  • ./lib 開始寫套件。

Example

這個範例也是基於前幾天的 【第26、26、27天 - Flutter 知名外送平台畫面練習】 的文章,做簡化。
不使用 SliverAppBar 了,而是使用普通的 appBar

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Vertical Scrollable TabView Demo',
        theme: ThemeData(
          primarySwatch: Colors.purple,
        ),
        home: MyHomePage(title: 'Vertical Scrollable TabView Plugin'));
  }
}

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

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

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {

  final List<Category> data = ExampleData.data;
  // TabController More Information => https://api.flutter.dev/flutter/material/TabController-class.html
  late TabController tabController;

  @override
  void initState() {
    tabController = TabController(length: data.length, vsync: this);
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        title: Text(widget.title),
        bottom: TabBar(
          isScrollable: true,
          controller: tabController,
          indicatorPadding: EdgeInsets.symmetric(horizontal: 16.0),
          indicatorColor: Colors.cyan,
          labelColor: Colors.cyan,
          unselectedLabelColor: Colors.white,
          indicatorWeight: 3.0,
          tabs: data.map((e) {
            return Tab(text: e.title);
          }).toList(),
          onTap: (index) {
            VerticalScrollableTabBarStatus.setIndex(index);
          },
        ),
      ),
      body: VerticalScrollableTabView(
          tabController: tabController,
          listItemData: data,
          verticalScrollPosition: VerticalScrollPosition.middle,
          eachItemChild: (object, index) =>
              CategorySection(category: object as Category)),
    );
  }
}

套件如何使用

可以看到我在 TabBaronTap 裡面使用了 VerticalScrollableTabBarStatus.setIndex(index); 這個 method,這裡是被要求一定要放的,沒放的話會出現畫面上的 bug。
可以看到 套件的程式碼範例

// Required it
TabBar(
    onTap: (index) {
        VerticalScrollableTabBarStatus.setIndex(index); <- Required 
    },
)

再來是使用 VerticalScrollableTabView 這個元件。裡面的 listItemData 是一個 List<dynamic>
的型態, eachItemChild 則是每個 item 的樣式。並且會有一個 object 回傳,可以把 object 指定為 listItemData 裡面的 dynamic 的物件。

verticalScrollPosition: VerticalScrollPosition.begin,則是動畫的位置。和 scroll_to_index,裡面的 AutoScrollPosition效果一樣。

VerticalScrollableTabView(
    tabController: tabController,                             <- Required TabBarController
    listItemData: data,                                       <- Required List<dynamic>
    eachItemChild: (object,index){
        return CategorySection(category: object as Category); <- Object and index
    },
    verticalScrollPosition: VerticalScrollPosition.begin,
),

套件

可以看到這邊 VerticalScrollableTabBarStatus 裡面我放了 static,這樣子的寫法不好,非常不好,但是因為我想不出來要怎麼寫才會好。

我有想過使用 inheritedwidget 來寫,可是我發現這樣子的話連 TabBar 都要自己造一個,我覺得這樣雖然把 static 拿掉了,可是這樣子反而讓使用上程式碼更多...。

歡迎發 PR 給我:)

我自己認為套件的目的就是要讓程式碼變少,就可以達到想要多樣性的效果。

/// Detect TabBar Status, isOnTap = is to check TabBar is on Tap or not, isOnTapIndex = is on Tap Index
/// 增廁 TabBar 的狀態,isOnTap 是用來判斷是否是被點擊的狀態,isOnTapIndex 是用來儲存 TapBar 的 Index 的。
class VerticalScrollableTabBarStatus {
  static bool isOnTap = false;
  static int isOnTapIndex = 0;

  static void setIndex(int index) {
    VerticalScrollableTabBarStatus.isOnTap = true;
    VerticalScrollableTabBarStatus.isOnTapIndex = index;
  }
}

/// VerticalScrollPosition = is ann Animation style from scroll_to_index plugin's preferPosition,
/// It's show the item position in listView.builder
/// 用來設定動畫狀態的(參考 scroll_to_index 的 preferPosition 屬性)
enum VerticalScrollPosition { begin, middle, end }

class VerticalScrollableTabView extends StatefulWidget {
  /// TabBar Controller to let widget listening TabBar changed
  /// TabBar Controller 用來讓 widget 監聽 TabBar 的 index 是否有更動
  final TabController _tabController;

  /// Required a List<dynamic> Type,you can put your data that you wanna put in item
  /// 要求 List<dynamic> 的結構,List 裡面可以放自己建立的 Object
  final List<dynamic> _listItemData;

  /// A callback that return an Object inside _listItemData and the index of ListView.Builder
  /// A callback 用來回傳一個 _listItemData 裡面的 Object 型態和 ListView.Builder 的 index
  final Widget Function(dynamic aaa, int index) _eachItemChild;

  /// VerticalScrollPosition = is ann Animation style from scroll_to_index,
  /// It's show the item position in listView.builder
  final VerticalScrollPosition _verticalScrollPosition;

  const VerticalScrollableTabView(
      {required TabController tabController,
      required List<dynamic> listItemData,
      required Widget Function(dynamic aaa, int index) eachItemChild,
      VerticalScrollPosition verticalScrollPosition =
          VerticalScrollPosition.begin})
      : _tabController = tabController,
        _listItemData = listItemData,
        _eachItemChild = eachItemChild,
        _verticalScrollPosition = verticalScrollPosition;

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

class _VerticalScrollableTabViewState extends State<VerticalScrollableTabView>
    with SingleTickerProviderStateMixin {
  /// Instantiate scroll_to_index (套件提供的方法)
  late AutoScrollController scrollController;

  /// When the animation is started, need to pause onScrollNotification to calculate Rect
  /// 動畫的時候暫停去運算 Rect
  bool pauseRectGetterIndex = false;

  /// Instantiate RectGetter(套件提供的方法)
  final listViewKey = RectGetter.createGlobalKey();

  /// To save the item's Rect
  /// 用來儲存 items 的 Rect 的 Map
  Map<int, dynamic> itemsKeys = {};

  @override
  void initState() {
    widget._tabController.addListener(() {
      // will call two times, because 底層呼叫 2 次 notifyListeners()
      // https://stackoverflow.com/questions/60252355/tabcontroller-listener-called-multiple-times-how-does-indexischanging-work
      if (VerticalScrollableTabBarStatus.isOnTap) {
        animateAndScrollTo(VerticalScrollableTabBarStatus.isOnTapIndex);
        VerticalScrollableTabBarStatus.isOnTap = false;
      }
    });
    scrollController = AutoScrollController();
    super.initState();
  }

  @override
  void dispose() {
    widget._tabController.dispose();
    scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return RectGetter(
      key: listViewKey,
      // NotificationListener 是一個由下往上傳遞通知,true 阻止通知、false 傳遞通知,確保指監聽滾動的通知
      // ScrollNotification => https://www.jianshu.com/p/d80545454944
      child: NotificationListener<ScrollNotification>(
        child: buildScrollView(),
        onNotification: onScrollNotification,
      ),
    );
  }

  Widget buildScrollView() {
    return ListView.builder(
      controller: scrollController,
      itemCount: widget._listItemData.length,
      itemBuilder: (BuildContext context, int index) {
        /// Initial Key of itemKeys
        /// 初始化 itemKeys 的 key
        itemsKeys[index] = RectGetter.createGlobalKey();
        return buildItem(index);
      },
    );
  }

  Widget buildItem(int index) {
    dynamic category = widget._listItemData[index];
    return RectGetter(
      /// when announce GlobalKey,we can use RectGetter.getRectFromKey(key) to get Rect
      /// 宣告 GlobalKey,之後可以 RectGetter.getRectFromKey(key) 的方式獲得 Rect
      key: itemsKeys[index],
      child: AutoScrollTag(
        key: ValueKey(index),
        index: index,
        controller: scrollController,
        child: widget._eachItemChild(category, index),
      ),
    );
  }
  /// Animation Function for tabBarListener
  /// This need to put inside TabBar onTap, but in this case we put inside tabBarListener
  void animateAndScrollTo(int index) async {
    // Scroll 到 index 並使用 begin 的模式,結束後,把 pauseRectGetterIndex 設為 false 暫停執行 ScrollNotification
    pauseRectGetterIndex = true;
    widget._tabController.animateTo(index);
    switch (widget._verticalScrollPosition) {
      case VerticalScrollPosition.begin:
        scrollController
            .scrollToIndex(index, preferPosition: AutoScrollPosition.begin)
            .then((value) => pauseRectGetterIndex = false);
        break;
      case VerticalScrollPosition.middle:
        scrollController
            .scrollToIndex(index, preferPosition: AutoScrollPosition.middle)
            .then((value) => pauseRectGetterIndex = false);
        break;
      case VerticalScrollPosition.end:
        scrollController
            .scrollToIndex(index, preferPosition: AutoScrollPosition.end)
            .then((value) => pauseRectGetterIndex = false);
        break;
    }
  }

  /// onScrollNotification of NotificationListener
  /// true表示消費掉當前通知不再向上一级NotificationListener傳遞通知,false則會再向上一级NotificationListener傳遞通知;
  bool onScrollNotification(ScrollNotification notification) {
    if (pauseRectGetterIndex) return true;
    /// get tabBar index
    /// 取得 tabBar 的長度
    int lastTabIndex = widget._tabController.length - 1;

    List<int> visibleItems = getVisibleItemsIndex();
    /// define what is reachLastTabIndex
    bool reachLastTabIndex = visibleItems.isNotEmpty &&
        visibleItems.length <= 2 &&
        visibleItems.last == lastTabIndex;
    /// if reachLastTabIndex, then scroll to last index
    /// 如果到達最後一個 index 就跳轉到最後一個 index
    if (reachLastTabIndex) {
      widget._tabController.animateTo(lastTabIndex);
    } else {
      // 取得畫面中的 item 的中間值。例:2,3,4 中間的就是 3
      // 求一個數字列表的乘積
      int sumIndex = visibleItems.reduce((value, element) => value + element);
      // 5 ~/ 2 = 2  => Result is an int 取整數
      int middleIndex = sumIndex ~/ visibleItems.length;
      if (widget._tabController.index != middleIndex)
        widget._tabController.animateTo(middleIndex);
    }
    return false;
  }
  /// getVisibleItemsIndex on Screen
  /// 取得現在畫面上可以看得到的 Items Index
  List<int> getVisibleItemsIndex() {
    // get ListView Rect
    Rect? rect = RectGetter.getRectFromKey(listViewKey);
    List<int> items = [];
    if (rect == null) return items;
    itemsKeys.forEach((index, key) {
      Rect? itemRect = RectGetter.getRectFromKey(key);
      if (itemRect == null) return;
      // y 軸座越大,代表越下面
      // 如果 item 上方的座標 比 listView 的下方的座標 的位置的大 代表不在畫面中。
      // bottom meaning => The offset of the bottom edge of this widget from the y axis.
      // top meaning => The offset of the top edge of this widget from the y axis.
      if (itemRect.top > rect.bottom) return;
      // 如果 item 下方的座標 比 listView 的上方的座標 的位置的小 代表不在畫面中。
      if (itemRect.bottom < rect.top) return;
      items.add(index);
    });
    return items;
  }
}

套件就先講到這邊,明天談談上架要注意的事情,還有如何上架。
那我們鐵人賽 Day29 見囉!!


上一篇
【第二七天 - Flutter 知名外送平台畫面練習(下)】
下一篇
【第二九天 - Flutter 開發套件之旅(下)】
系列文
Flutter - 複製貼上到開發套件之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
juck30808
iT邦研究生 1 級 ‧ 2021-10-12 18:34:41

恭喜即將完賽!!!

我要留言

立即登入留言