📜

無限スクロールができるTabViewが使えるFlutterパッケージを作りました

2021/07/25に公開

普段スマホアプリを使っていると、よく上付きのタブでコンテンツを切り替える画面を目にするかと思います。そしてタブの要素数が多い場合、大抵は左端に言っても左にスクロールできる、逆もしかりな、無限スクロールができるようになっているものが多いのではないでしょうか。

Material Designなどの有名なデザインガイドラインにはあまり無限にスクロールができるタブのような定義はありませんが、実際リリースされているアプリを見ると、肌感ではそのようなものが多い気がします。

そんな無限スクロールタブですが、iOSアプリではよく実装されている(らしい)上にライブラリもあるのに比べて、Flutterではパッケージを見つけることができませんでした。
そんなとき、丁度業務でタブを実装したいという話があり、もしできるなら無限スクロールにしたいという要望もあったので、挑戦してみました💪

実装

pub.devはこちら
https://pub.dev/packages/infinite_scroll_tab_view

業務のアプリから派生しているので、会社名義で公開しております。

そしてソースコードはこちら
https://github.com/cb-cloud/flutter_infinite_scroll_tab_view

構造

実現させたものの構造図は以下の通りです。

構造図

大方普通のTabViewと代わりはないですが、タブとページがそれぞれ別の無限スクロール対応しているリストになっています。また、タブとページの間にインジケーターが入っており、現在選択されているタブの下に付くようになっています。

このTabViewの要件としては以下のものが与えられています。

  • タブ・ページそれぞれが無限にスクロールできること(スクロール上で左端・右端が存在しないこと)
  • 任意のタブをタップしたとき、ページはそのインデックスに該当するページに切り替わること
  • 現在選択されているページをスワイプしたとき、ページがその方向の隣に切り替わり、タブも同じインデックスのものに切り替わること
  • タブをスクロールしたとき、インジケーターは選択されているインデックスに該当するタブの下に常に描画されていること
  • ページをスワイプしたとき、インジケーターも合わせて移動すること
    • 本来実装にあたってインジケーターに関する細かい要件定義はなかったが、よくある実装例に合わせた形にした

次章からはそれぞれの要件を実装した部分の動作原理について説明していきます。

動作原理

無限スクロールするリスト

Flutterで無限スクロールリストと言えば、ということでpub.devで検索してみると、infinite_listviewindexex_list_viewが目に留まる可と思います。これらはそこそこ使われているパッケージ(特にinfinite_listviewはFlutter Community名義)であり信頼性も高いので、本来であればこれを採用するところです。

しかし、今回はそれを採用することができませんでした。

その理由は実装方法にあります。そもそもListViewの構造は、Scrollable Widgetの内部で、リストをsliverで作成しつつViewport Widgetで現在スクロールしている位置の周辺要素を描画するという風になっています。infinite_listviewでは、0よりも後ろの要素を表現するために、sliverを使って負方向のインデックスを広げると同時に、正方向と負方向のsliverをそれぞれ描画するViewportを2つ配置、Stackで重ねて描画という方法をとっています。

https://github.com/fluttercommunity/flutter_infinite_listview/blob/master/lib/infinite_listview.dart

  offset.addListener(() {
  /// Keep the negative scrolling [Viewport] positioned to the [ScrollPosition].
    negativeOffset._forceNegativePixels(offset.pixels);
  });
  /// Stack the two [Viewport]s on top of each other so they move in sync.
  return Stack(
    children: <Widget>[
      Viewport(
        axisDirection: flipAxisDirection(axisDirection),
        anchor: 1.0 - widget.anchor,
        offset: negativeOffset,
        slivers: negativeSlivers,
        cacheExtent: widget.cacheExtent,
      ),
      Viewport(
        axisDirection: axisDirection,
        anchor: widget.anchor,
        offset: offset,
        slivers: slivers,
        cacheExtent: widget.cacheExtent,
      ),
    ],
  );

双方向のリストを実現するだけならこれだけでも充分です。実際これをそのまま採用してある程度問題なく動きはしますが、ページをこの方法で表現しようとすると不都合なことが起きてしまいます。

壊れるページ

こんな感じで、0と-1の境目が壊れてしまっています。infinite_listviewは、負のインデックスのスクロールが正のインデックスに同期するような実装をしています。そして、上記画像の実装では、ページの挙動を再現するために PageScrollPhysics を適用しています。この2つの要素が合わさることで、以下の現象が起きてしまいます。

  1. PageScrollPhysics 内で打ち出されるバネ運動のシミュレーションを適用しようとする
  2. シミュレーションが正負どちらか側に適用される
  3. もう一方の動きが遅れて反映される、あるいはシミュレーションの計算が適用されない
  4. 壊れる

このような現象を回避するため、infinite_listviewを使わず、独自で ListView を再実装する方向に舵を切りました。

Viewport には、centerというプロパティがあります。これは任意のKeyを指定することで、そのKeyがあたっているsliver要素を中心に描画するという挙動をします。これを利用して、正方向と負方向のsliverを一つのViewportで表示することにしました。
運良く、同じような問題がFlutterリポジトリにissueとして上がっており、その回答にコード例が載っていたので、これが丁度参考になりました。
https://github.com/flutter/flutter/issues/20608#issuecomment-451040159

これを適用することで、無事 PageScrollPhysics を適用しても壊れない双方向の無限スクロールリストを実現することができました。

ちなみに、タブやページにこのリストを適用させる際、以下の2つのインデックスを意識する必要があります。

  • 正負の概念がある生のインデックス(rawIndex
  • 生のインデックスを要素数でモジュロ演算したインデックス(modIndex

負の値をモジュロ演算するとき、ことプログラミングにおいては言語によって結果の定義が異なります。Dartは % 演算子で負の値をモジュロ演算すると、常に正の値を返すようになっています。これは負の値であっても、循環する値としては正の並びと一致するため、modIndex% 演算子でモジュロ演算した値を採用します。

タブをタップでページ移動

タブをタップしたときにインデックス情報が渡ってきているので、それをもとにページ側のコントローラー _pageController を使ってアニメーションをします。ページ側は各要素の幅が決まっているので、移動するページ数さえわかれば目的のスクロール位置は 現在位置 + 移動量 * ページ幅で求めることができます。

  // 現在のスクロール位置とページインデックスを取得
  final currentOffset = _pageController.offset;
  final currentModIndex =
      (currentOffset ~/ widget.size.width) % widget.contentLength;
  // 選択したページまでの距離を計算する
  // modの境界をまたぐ場合を考慮して、近い方向を指すように正負を調整する
  final move = calculateMoveIndexDistance(
      currentModIndex, modIndex, widget.contentLength);
  final targetPageOffset = currentOffset + move * widget.size.width;
  await _pageController.animateTo(
    targetPageOffset,
    duration: _tabAnimationDuration,
    curve: Curves.ease,
  );

タブをタップしたとき、ページだけではなくタップしたタブも中心に向けてスクロールをしなければなりません。タブの方は、渡されるテキストの文字数やスタイルによって各要素の幅が異なります。
そのため、一連の計算に必要な数値を予め計算しておく必要があります。
事前に計算しているのは、

  • 各要素のパディングを含めた横幅(_tabTextSizes
  • 各要素に関して、要素0から _tabTextSizes の要素を加算していった値(_tabSizesFromIndex
  • (インジケーターの制御に使う)各要素を中心に表示するためのオフセットTween(_tabOffsets
  • (インジケーターの制御に使う)_tabTextSizes の各要素に関するTween(_tabSizeTweens

の4つです。

テキストのスタイルを含めたタブのサイズは、TextPainterを使って計算されます。詳しくはInnerInfiniteScrollTabViewState#calculateTabBehaviorElements を参照してください。

話が少し逸れましたが、上記の各種計算された値を用いて、タップされたタブのインデックスからそのタブのスクロール位置を割り出し、そこに向けてスクロールさせています。

  final sizeOnIndex = _calculateTabSizeFromIndex(modIndex);
  final section = rawIndex.isNegative
      ? (rawIndex + 1) ~/ widget.contentLength - 1
      : rawIndex ~/ widget.contentLength;
  final targetOffset = _totalTabSize * section + sizeOnIndex;
  _isTabForceScrolling = true;
  _tabController
      .animateTo(
        targetOffset + centeringOffset(modIndex),
        duration: _tabAnimationDuration,
        curve: Curves.ease,
      )
      .then((_) => _isTabForceScrolling = false);

ページスワイプでタブ切り替え

ページをスワイプしたときは、タブをページのスクロール位置にマッピングするようにしてタブが追従するようにしました。
このマッピングには事前に計算した _tabOffsets を使います。その値は、以下のような計算で求められます。

タブTween

タブの各要素のサイズは事前に計算してわかっています(_tabTextSizes)。各要素のサイズが分かるということは、ある要素のスクロール位置、及びある要素を画面中央に表示するためのオフセットが計算できるということになります。0を基準として、各要素に関するスクロール位置と中央表示のためのオフセットの和を求めておき、それをTweenで繋げるようにしました。

実際にページがスクロールされたときには、ページのスクロール位置に合わせてTweenを参照するだけで、スクロール位置を同期することができます。

  _pageController.addListener(() {

    ...

    final currentIndexDouble = _pageController.offset / widget.size.width;
    final currentIndex = currentIndexDouble.floor();
    final modIndex = currentIndexDouble.round() % widget.contentLength;
    final currentIndexDecimal =
        currentIndexDouble - currentIndexDouble.floor();

    _tabController.jumpTo(_tabOffsets[currentIndex % widget.contentLength]
        .transform(currentIndexDecimal));

インデックスを色々こねくり回してるのは、Tweenを適用するために 現在のスクロール位置のインデックスから次のインデックスまでの割合(0~1)を計算するためです。

タブをスクロールしたときのインジケーター

タブをスクロールしているとき、インジケーターは単に選択されているインデックスの下に描画するだけです。ここは特に難しいことはしていないです。本当に。

    return Stack(
      clipBehavior: Clip.none,
      children: [
        // タブ
        Container(
          padding: EdgeInsets.symmetric(horizontal: tabPadding),
          decoration: BoxDecoration(
            border: Border(bottom: separator ?? BorderSide.none),
          ),
          child: Center(
            child: tabBuilder(modIndex, selectedIndex == modIndex),
          ),
        ),

        // インジケーター
        if (selectedIndex == modIndex && !isTabPositionAligned)
          Positioned(
            bottom: 0,
            left: 0,
            right: 0,
            child: Container(
              height: indicatorWidth,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(indicatorWidth),
                color: indicatorColor,
              ),
            ),
          )
      ],
    );

ページをスワイプしたときのインジケーター

ページをスワイプしようとしているとき、あるいはタブをスクロールしていないときは、インジケーターは常に中央に表示させています。ページを実際にスワイプするとき、インジケーターの長さは選択されているインデックスに基づいて、そのタブの長さに合うように変化します。

言葉で表現しようとするとちょっとわかりにくいですが、要するに上述した

(インジケーターの制御に使う)_tabTextSizes の各要素に関するTween(_tabSizeTweens

このTweenを使います。_tabSizeTweensは、単純にタブの各要素のサイズを次のインデックスに繋げるようなTweenをかくのうしています。

    for (var i = 0; i < widget.contentLength; i++) {
      ...

      final sizeBegin = _tabTextSizes[i];
      final sizeEnd = _tabTextSizes[(i + 1) % widget.contentLength];
      _tabSizeTweens.add(Tween(begin: sizeBegin, end: sizeEnd));
    }

これをページスワイプ時にインジケーターの長さに反映させるだけです。ここでやることは、ページスワイプでタブ切り替えでやっていることと同じです。

  _indicatorSize.value = _tabSizeTweens[currentIndex % widget.contentLength]
      .transform(currentIndexDecimal);

まとめと感想

構造で提示した要素を埋めていくことで、双方向に無限スクロールが可能なTabViewを実装することができました。要件としてはある程度クリアしているものの、細かい挙動や端末の条件などに手が回っていない部分も多少ありそうなので、なにか気づいた点があればissueやPRなどを気軽にあげていただければ幸いです。

無限スクロールタブの実装をするにあたって、なんやかんやで無限スクロール可能なリストの選定や実装にかなり手間がかかりました🤦‍♂️ しかしそのおかげで、PageScrollPhysics を当てても破綻しないリストを作成することができ、sliverの扱いも多少理解することができました。それ以外の要素に関しては、Flutterのごく基本的な機能の組み合わせといった感じで、半ば「もしかしてかなりゴリ押しの実装をしているのでは」とも思いましたが、結局Flutterのエコシステム内でうまく完結していて、かつパフォーマンスも悪くないラインを維持できているので、方向性としてはこれで良いかなと思います。

最後にちょっとだけ宣伝をば…
弊社CBcloudではモバイルアプリエンジニアを絶賛募集中です!興味の湧いた方は是非お声がけください🙏
https://www.wantedly.com/projects/683329

Discussion