ジャンプTOON アプリチーム 2024 年新卒の西峰です。

5 月にサービスを開始した「ジャンプTOON」は、Flutter を採用し Android, iOS, iPadOS 向けのアプリを提供しています。

本記事では、ジャンプTOON モバイルアプリのために独自に開発されたマンガビューワについて紹介したいと思います。

マンガビューワとは

マンガ作品を閲覧できる機能です。

本アプリでは、快適に縦マンガを閲覧いただけるよう、スクロールや拡大縮小操作に工夫を加えています。また、作品の転載防止のため、画面録画、ミラーリングの禁止機能も存在します。(本記事では解説しません)

 

ビューワを自作した目的

本アプリではビューワ機能を自作していますが、その目的はいくつかあります。まず、柔軟に独自の機能を追加しやすくするためです。連続して話を読んだり、お気に入りやコメント、いいね、拡大縮小といった要件を全て満たすことは他社製ビューワだと難易度が高いですが自社開発することで柔軟に追加することができます。

次にコストの面でもメリットがあります。他社のビューワで Flutter に対応し、私たちの要件を満たす SDK が存在しませんでした。また、iOS / Android 向けの SDK をブリッジすることも可能でしたが Flutter の恩恵を受けることができず開発コストが増加する恐れがありました。そのため自社でビューワを開発する方がコストが下がると考え、このような方針になりました。

ビューワのディレクトリ構成

ビューワのすべての機能を紹介しきれないため、今回はユーザーの操作や作品の画像を表示する技術をメインに紹介します。

viewer/
├── component/                       
│   ├── container/
│   │   ├── viewer_frame.dart
│   │   └── viewer.dart
│   ├── image/
│   │   └── viewer_image_creator.dart
│   ├── list/
│   │   └── viewer_list_view.dart
│   ├── scroll/
│   │   └── viewer_scroll_bar_container.dart
│   │   └── viewer_scroll_physics.dart
│   └── gesture/
│       └── multi_tap_detect_gesture_recognizer.dart
│       └── tap_gesture_recognizer.dart
├── hook/
└── viewer_screen.dart

  • viewer_screen.dart : ビューワに関する基盤となるロジックやデータ取得ロジックを保持しています。
  • viewer_frame.dart : ヘッダー、フッター、をつなぎ合わせる役割を担っています。
  • viewer.dart : ユーザー操作に関する基盤となるロジックを保持しています。
  • viewer_list_view.dart : ビューワの画像をリスト状に並べることに加えてユーザー操作に関わるロジックも保持しています。
  • viewer_image_creator.dart : ビューワに表示する画像を生成する役割を担っています。
  • viewer_scroll_bar_container.dart : スクロールバー widget です。
  • viewer_scroll_physics.dart : スクロールの速度等を調節する役割を担っています。
  • multi_tap_detect_gesture_recognizer.dart : マルチタップを制御する役割を担っています。
  • tap_gesture_recognizer.dart : ダブルタップを制御する役割を担っています。

ビューワのユーザー操作を支える技術

ビューワでマンガを快適かつシームレスに閲覧できるようにするにはいくつかの工夫が必要です。

本セクションでは以下の 5 つの技術について紹介します。

  • リフレッシュインジケータによる前後話の遷移
  • 自作スクロールバー
  • 画像のキャッシュ対応
  • UI の拡大・縮小に関わるジェスチャー
  • スクロール速度の調節

リフレッシュインジケータによる前後話の遷移

初めに UI 上でリフレッシュさせるためのロジックを紹介します。

リフレッシュする UI は custom_refresh_indicator というパッケージを使用して実装しました。

このパッケージは単にリフレッシュ用のインジケータ UI を提供するだけでなく、インジケータの状態管理を行う IndicatorController や、UI の最上部か最下部、あるいは両方に到達したタイミングでリフレッシュを選択できる IndicatorTrigger も備えています。

今回は以下の動作に custom_refresh_indicator を活用して実装しました。

まず、画面の全体に CustomRefreshIndicator を囲います。CustomRefreshIndicator widget には複数のプロパティが存在しますが、今回主に紹介するのは controller, offsetToArmed, trigger, onRefresh です。

// 前後作品に移動するためのインジケータ状態を管理
final indicatorController = useRef(IndicatorController(refreshEnabled: true));

return Stack(
  children: [
    CustomRefreshIndicator(
      controller: indicatorController.value,
      // 引っ張るとトリガーする距離
      offsetToArmed: 75,
      // 上下にトリガーできるように設定
      trigger: trigger,
      onRefresh: () async {
        if (indicatorController.value.side.isTop) {
          // 前の作品へ
        } else {
          // 次の作品へ
        }
      },
      // 引っ張った時に表示される UI
      builder: (context, child, _) { ... }
      child: ...,
    ),
  ],
);

IndicatorController を使用してインジケータがどの方向にどれだけ引っ張られたかを監視しています。IndicatorController が useRef で管理されているのは前後の話に遷移した際に rebuild が発生することで IndicatorController が初期化されないようにするためです。上記で挙げている実装例だと onRefresh 上で上方向に引っ張ってリフレッシュした場合は前の作品へ、下方向だと次の作品へ遷移するロジックにて活用しています。

trigger では viewer 起動時に事前に前後に作品があるかどうかを確認しています。

これらを活用することで前後に作品があってもビューワを離れずに次の話へシームレスに遷移することができます。

// 前後話の有無によって上下のエッジを有効無効を切り替える。
IndicatorTrigger trigger;
if (isViewablePrevEpisode && isViewableNextEpisode) {
  trigger = IndicatorTrigger.bothEdges;
} else if (isViewablePrevEpisode) {
  trigger = IndicatorTrigger.leadingEdge;
} else if (isViewableNextEpisode) {
  trigger = IndicatorTrigger.trailingEdge;
} else {
  // どちらのデータもない場合は設定はしておくがリフレッシュ自体を無効にする
  indicatorController.value.disableRefresh();
  trigger = IndicatorTrigger.leadingEdge;
}

onRefresh では indicatorController の IndicatorSide が上部に達していたら前の作品へそうでなければ次の作品へ遷移するようにしています。

自作スクロールバー

viewer_frame.dart はヘッダーやフッター、スクロールバーといったビューワの UI に関わるコンポーネントによって構成されています。

その中でもスクロールバーは OS 標準のものではなくビューワのために自作しています。

スクロールバーを自作した理由はユーザーにとって作品の邪魔にならないようにブラーを追加したりフロート状態にしたり画面を圧迫しないようなスクロールバーにするためです。

またスクロール開始位置を端末の側面スイッチのあたりからスタートさせることで、親指が届きやすい位置に来るような設計にするためにも自分たちで作るという選択をとりました。

スクロールバーを操作したタイミングでイベントを発火するロジックについて説明します。こちらの発火は 2 パターンあり、どちらも GestureDetector のプロパティです。一つは onVerticalDragDown でスクロールバーの特定の位置をタップした場合に発火するメソッドです。もう一つは onVerticalDragUpdate でスクロールバーをドラッグした時に発火するメソッドです。

そしてそれぞれで行なっている処理が以下の通りです。


GestureDetector(
  // スクロールバーをタップした時のイベント(一気に移動する時など)
  onVerticalDragDown: (detail) {
    final dragInfo = onDragPositionChanged(detail.localPosition);
    onUpdatedScrollThumbPosition(
      dragInfo.thumbTopOffset,
      dragInfo.scrolledPage,
    );
  },
  // スクロールバーをドラッグした時のイベント
  onVerticalDragUpdate: (detail) {
    final dragInfo = onDragPositionChanged(detail.localPosition);
    onUpdatedScrollThumbPosition(
      dragInfo.thumbTopOffset,
      dragInfo.scrolledPage,
    );
  },
  child: ...,
);

内部の処理について詳しく紹介します。

onDragPositionChanged は以下のように定義しています。

/// gesture 操作に合わせて thumb 位置を連動させ、現在ページと合わせて返却
({double thumbTopOffset, int scrolledPage}) onDragPositionChanged(
  Offset offset,
) {
	// スクロールバー全体から移動させるバーの要素の高さ分を引いた純粋な移動距離
  final scrollableDistance = scrollBarLength.value - thumbHeight;
  // 現在のバーの高さからバーの要素の高さ分を引いて ÷ 2 とスクロールバー全体の高さの
  // 最小値が一番上の Offset となる
  thumbTopOffset.value = min(
    max(0, offset.dy - thumbHeight / 2),
    scrollableDistance,
  );
  // スクロールバーの padding, margin を追加して画面全体としてのバーの高さ
  final thumbTopGlobalOffset =
      thumbTopOffset.value + topMargin + verticalPadding;
	// スクロールされた画像の枚数
  final scrolledPage = min(
    thumbTopOffset.value ~/ (scrollableDistance / totalPageCount),
    totalPageCount - 1,
  );
  return (thumbTopOffset: thumbTopGlobalOffset, scrolledPage: scrolledPage);
}

タップ or スライドした位置 (offset) を基に thumb (つまみ) の移動先と、表示する画像のページを算出します。

次に onUpdatedScrollThumbPosition について説明します。

こちらは useValueNotifier で管理されている currentPage (現在の画像枚数) にずれがあった場合に現在のページの値を更新し、現在のページへ移動してくれるメソッドです。

/// ピンチイン中の最小縮小率
static const double pinchInMinScale = 0.75;

// 現在のページ番号
final currentPage = useValueNotifier(0);
final itemScrollController = useMemoized(ItemScrollController.new);

// ViewerListView の minCacheExtent を多めにとっているため
// jumpTo の際に人間には認識できないくらいの遅延をいれることでメモリ解放をまつ
// useDebounce は Timer によって遅延を行うメソッド
final pageMoveDebounce = useDebounce(delay: const Duration(milliseconds: 10));

/// scrollbar 操作に合わせてページと表示位置を更新
void onUpdatedScrollThumbPosition(double thumbTopOffset, int scrolledPage) {
  if (currentPage.value != scrolledPage) {
    pageMoveDebounce(() => itemScrollController.jumpToIndex(scrolledPage, pinchInMinScale));
    currentPage.value = scrolledPage;
  }
}

画像のキャッシュ対応

画像の読み込みから表示まで全て行うのが viewer_image_creator.dart となっています。

今回画像の表示のために ExtendedImage という package を使用しています。

この package は、ネットワーク経由で取得した画像データをローカルにキャッシュし、再表示時にそのキャッシュを再利用できるので、画像表示時間の短縮、バックエンドへのリクエスト負荷を軽減することができます。

画像をキャッシュ化するために ExtendedImage の cacheKey プロパティを使用しています。

ExtendedImage の仕様上、ディスクキャッシュに保存するために 128 bit 長のハッシュ関数である MD5 を採用しなければいけなかったため、画像 URL からハッシュ値へ変換しています。

メモリキャッシュはすでに ExtendedImage.network で提供されている ExtendedNetworkImageProvider で対応されているので追加する実装はありません。


return ExtendedImage.network(
  ...,
  cacheKey: keyToMd5(imageUrl),
);

/// get md5 from key
String keyToMd5(String key) => md5.convert(utf8.encode(key)).toString();

また、スクロール動作に応じてスムーズに画像を表示するためにある程度先の要素までキャッシュする必要があります。

しかし、端末によって画像の表示されるサイズが変動するのでそれに応じてキャッシュする範囲を決める必要があります。

この問題を解決するための対応を紹介します。まず画像を縦に並べて表示する際、本アプリでは ListView と似たような widget である scrollable_positioned_list というパッケージを採用しています。この package のポジション管理は offset ではなく index に基づいています。これにより、特定のアイテムの位置を簡単に追跡できます。また初期スクロール位置も設定できるため、ビューワを一度離脱した後に読み進めていた途中のページ位置から再開して読むといった実装が容易にできます。ScrollablePositionedList.builder にある minCacheExtent プロパティを使用してリスト内でキャッシュする最小範囲を決定します。

import 'dart:math' as math;

import 'package:app/foundation/platform.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:memory_info/memory_info.dart';

/// ビューワなどスクロールのパフォーマンスが重い画面の対策として、
/// minCacheExtent をデバイスの物理メモリに応じて計算する
double useCacheExtent({
  int min = 5,
  int max = 8,
}) {
  // 最小値から物理メモリに応じて設定する
  final cacheExtent = useState(min);
  final data = useFuture(
    useMemoized(() => MemoryInfoPlugin().memoryInfo),
  );
  
  // data.requireData.totalMem はデバイスの最大メモリー容量が取得できる
  if (data.hasData && data.requireData.totalMem != null) {
    // 物理メモリが MB で返却されてくるので、 1000 MB に対して 1 画面分を設定する
    // iOS のみ +2 多く設定する (iOS は Android に比べても軽いため)
    // e.g.
    //  Pixel 6 Pro = 5571 MB  -> 5
    //  OPPO A201OP = 5404 MB  -> 5
    //  Pixel Fold  = 11471 MB -> 10 (最大値固定)
    //  iPhone 12   = 3670 MB  -> 3 + 2 = 5
    final memorySize = (data.requireData.totalMem! / 1000).floor();
    cacheExtent.value = math.min(max, isAndroid ? memorySize : memorySize + 2);
  }

  // 画面サイズと係数をかけた値を返却する
  final context = useContext();
  final screenSize = MediaQuery.sizeOf(context);
  return screenSize.height * cacheExtent.value;
}

List 内の widget のキャッシュする範囲を決定する関数を useCacheExtent とし以下のように定義しました。

こちらは 1000 MB につき 1 画面分のキャッシュを確保するために最大と最小画面数を指定した上で、デバイスの最大メモリ容量の範囲内で指定された値を画面の縦幅と掛け合わせたものをキャッシュの範囲としています。そうすることでキャッシュ領域の拡大とメモリ上限値設定により、スムーズな画像表示とメモリ不足の防止を両立しています。また、端末のメモリ容量取得に memory_info を使用しています。

widget をキャッシュする他に画像自体のキャッシュも端末のメモリ容量に応じて調整されています。

/// imageCache の設定
/// デバイスによってデフォルトの 100 MB から変更する
Future<void> _setImageCacheSize() async {
  final info = await MemoryInfoPlugin().memoryInfo;
  debugPrint(
    'MemoryInfo appMem = ${info.appMem}, freeMem = ${info.freeMem}, totalMem = ${info.totalMem}',
  );
  if (isAndroid && info.freeMem != null) {
    // Android の場合のみ
    // 利用可能なメモリの 30% を確保する
    // e.g.
    //  Pixel 7 Pro = 4009 MB  -> 1202 MB
    //  OPPO A201OP = 2432 MB  -> 729 MB
    imageCache.maximumSizeBytes = (info.freeMem! * 0.3).toInt() << 20;
  } else {
    // Android 以外またはデータとれなかった場合は 250 MB に変更
    imageCache.maximumSizeBytes = 250 << 20;
  }
  debugPrint('imageCache.maximumSizeBytes = ${imageCache.maximumSizeBytes}');
}

imageCache は PaintingBinding に定義されています。

シングルトンで管理されており Flutter のプロジェクト内どこでもアクセス可能です。

ImageCache get imageCache => PaintingBinding.instance.imageCache;

デフォルトでは最大 100 MB もしくは最大 1000 枚の画像がキャッシュされる設定になっています。

UI の拡大・縮小に関わるジェスチャー

ジャスチャー機能について説明します。

以下のようなジャスチャーの挙動を実装します。


今回の要件ではダブルタップとマルチタップでマニュアルで拡大縮小するパターンがあります。マルチタップの場合はシンブルタップでかつ 2 本指でのズームしか許容しないようにしており、ダブルタップの方はタップ後の反映時間が長いので OneSequenceGestureRecognizer を override して反映時間を調節しています。

このダブルタップとマルチタップの仕様を実装に反映させるために GestureDetector を使用せずにそれぞれ独自のクラスを実装しました。

ダブルタップ用のクラスから紹介します。

全文コードはこちら
/// デフォルトの DoubleTap だとタップの許容認識時間が長めなので
/// 独自に作成し、200ms 以内で 50px 以下の範囲内でのみ認識する
class TapGestureRecognizer extends OneSequenceGestureRecognizer {
  ValueSetter<Offset>? onTap;
  ValueSetter<Offset>? onDoubleTap;

  /// ダブルタップ計測用のタップ回数
  int tapCount = 0;

  /// 1 回目のタップ開始位置
  Offset firstTapPosition = Offset.zero;

  /// ダブルタップの間隔計測用のタイマー
  Timer? timer;

  /// スクロール移動量の閾値
  static const _scrollDistanceThreshold = 30;

  /// ダブルタップの許容移動量の閾値
  /// 許容範囲を広くし過ぎるとピンチインした際の範囲によってはダブルタップと認識される
  static const _doubleTapDistanceThreshold = 50;

  /// ダブルタップの間隔時間
  static const _doubleTapInterval = AppDuration.short;

  // リセット
  void reset() {
    tapCount = 0;
    firstTapPosition = Offset.zero;
  }

  @override
  void handleEvent(PointerEvent event) {
    if (event is PointerDownEvent) {
      if (tapCount == 0) {
        // 1 回目のタップ開始位置を記録
        firstTapPosition = event.position;
      }
    } else if (event is PointerUpEvent) {
      // PointerUp でのみ計測するようにする
      final distance = (firstTapPosition - event.position).distance;
      if (distance > _scrollDistanceThreshold) {
        reset();
      }

      // タップ数をカウント
      tapCount++;
      if (tapCount == 1) {
        // タップ間の時間を計測する、200ms 以内でなければリセット
        timer?.cancel();
        timer = Timer(_doubleTapInterval, () {
          if (distance <= _scrollDistanceThreshold) {
            onTap?.call(event.position);
          }
          reset();
        });
      } else {
        // 1 回目と 2 回目のタップ位置が 50px 以内の場合のみダブルタップとする
        if (distance <= _doubleTapDistanceThreshold) {
          timer?.cancel();
          onDoubleTap?.call(event.position);
        }
        reset();
      }
    }

    if (event is PointerUpEvent || event is PointerCancelEvent) {
      stopTrackingPointer(event.pointer);
    }
  }

  @override
  String get debugDescription => 'タップイベントを検出する';

  @override
  void didStopTrackingLastPointer(int pointer) {}
}

TapGestureRecognizer に継承している OneSequenceGestureRecognizer は 1 度に 1 つの gesture しか受け付けないようにする class です。つまりダブルタップかシングルタップのどちらかしか反応しません。

handleEvent の内部実装が少し複雑であるので順を追って説明します。

まず PointerEvent を継承している PointerDownEvent, PointerUpEvent, PointerCancelEvent について紹介します。前提としてここで出てくるポインターとは、タップやマウス、タッチペンが該当します。PointerDownEvent はポインターが特定の位置で画面に接触した場合に該当するイベントです。PointerUpEvent はポインターがスクリーンから離れた場合に該当するイベントです。PointerCancelEvent はアプリから直接ポインターへの入力が一切なくなった場合に該当するイベントです。

流れとしては、初めに PointerDownEvent を受け取ったら position が保存されポインターが離れるタイミング(PointerUpEvent)でタップ数がカウントされます。ダブルタップであるのでカウントが 2 回目の時に初めにタップした箇所から 50 px 以内ならダブルタップとして認識するようにしています。また、ダブルタップの時間が 200 ms 以内でなければリセットします。これによりダブルタップと別の操作によって生じた連続したタップと混同しないようにしています。

次にマルチタップのクラスを紹介します。

/// マルチタップしているか判定するためのジェスチャー
class MultiTapDetectGestureRecognizer extends OneSequenceGestureRecognizer {
  ValueChanged<bool>? onMultiTap;

  /// マルチタップされた時に許可するかどうか判断するためのタップ数
  /// シングルタップのみ許可をする
  static const _maxPointerCount = 2;

  /// 現在のタップされている指の数
  int pointerCount = 0;

  bool current = false;

  @override
  void addAllowedPointer(PointerDownEvent event) {
    startTrackingPointer(event.pointer);
    pointerCount++;
    final isMultiTap = pointerCount >= _maxPointerCount;
    if (isMultiTap != current) {
      current = true;
      onMultiTap?.call(current);
    }
    // 親の GestureDetector に伝搬する
    resolve(GestureDisposition.rejected);
  }

  @override
  void handleEvent(PointerEvent event) {
    if (event is PointerUpEvent || event is PointerCancelEvent) {
      pointerCount--;
      final isMultiTap = pointerCount == 0;
      if (isMultiTap != current) {
        current = false;
        onMultiTap?.call(current);
      }
      stopTrackingPointer(event.pointer);
    }
  }

  @override
  String get debugDescription => 'マルチタップしているか判定する';

  @override
  void didStopTrackingLastPointer(int pointer) {}
}

処理の流れを追いたいと思います。addAllowedPointer は PointerDownEvent がイベントとして受け取ったタイミングで発火します。ポインターが認識されるごとにこのメソッドが発火するので pointerCount にて認識したポインターの数をカウントしています。そしてポインターが 2 つカウントされるとマルチタップとして認識しています。逆に handleEvent では PointerUpEvent, PointerCancelEvent のイベントを受け取った時点でポインターのカウントを減らします。完全にポインターがなくなった場合は再度 onMultiTap を実行するようにしています。マルチタップ中は画面全体のスクロールを無効にしています。理由としてはマルチタップ時に横スワイプや縦スクロールが誤作動しないようにするためです。

また、このダブルタップとマルチタップは独自のクラスを作成しているのでそれらをまとめて管理してくれる widget が必要です。その時に使用するのが RawGestureDetector です。普段よく使用するような GestureDetector もさまざまなタップの種類に応じてクラスが作成され、それらが RawGestureDetector によって管理されています。

RawGestureDetector は gesture を Map で管理してくれるので独自で作成して Gesture Recognizer も簡単に管理できます。


RawGestureDetector(
  behavior: HitTestBehavior.opaque,
  gestures: {
    TapGestureRecognizer: 
      GestureRecognizerFactoryWithHandlers<
        TapGestureRecognizer>(
          TapGestureRecognizer.new,
          (instance) {
            ...
          },
      ),
    MultiTapDetectGestureRecognizer: 
      GestureRecognizerFactoryWithHandlers<
        MultiTapDetectGestureRecognizer>(
          MultiTapDetectGestureRecognizer.new,
          (instance) {
            ...
          },
      ),
  },
  child: ...,
);

マルチタップとダブルタップで綺麗に管理することができました。ここから実際にタップイベントがあった際の処理を紹介します。


final transformation = useTransformationController();
final tween = useRef<Animation<Matrix4>>();

RawGestureDetector(
  behavior: HitTestBehavior.opaque,
  gestures: {
    TapGestureRecognizer: 
      GestureRecognizerFactoryWithHandlers<
        TapGestureRecognizer>(
          TapGestureRecognizer.new,
          (instance) {
            ...
          },
      ),
    MultiTapDetectGestureRecognizer: 
      GestureRecognizerFactoryWithHandlers<
        MultiTapDetectGestureRecognizer>(
          MultiTapDetectGestureRecognizer.new,
          (instance) {
            ...
          },
      ),
  },
  child: ...,
);

onDoubleTap では自動で拡大・縮小を行うために 4 次元 matrix を使用して調整します。

4 次元 matrix は InteractiveViewer 上で管理されている TransformationController の値が Matrix4 であり、それに適応するために使用しています。

onDoubleTap の引数である location は offset でタップした位置の情報が入っています。この値を使用して matrix の offset 値へ変換します。その後新たな matrix を scale と先ほど変換した offset を使用して生成します。生成した matrix を Matrix4Tween に代入し拡大・縮小のアニメーションを実行します。

// タップ位置から matrix の offset 値への変換
Offset convertToMatrixOffset(Offset tapLocation, double targetScale) {
  final offsetX =
      targetScale == 1.0 ? 0.0 : -tapLocation.dx * (targetScale - 1);
  final offsetY =
      targetScale == 1.0 ? 0.0 : -tapLocation.dy * (targetScale - 1);
  return Offset(offsetX, offsetY);
}

// 拡大縮小のアニメーションを計算する
void updateTween(double targetScale, Offset offset) {
  final matrix = transformation.value;
  final newMatrix = Matrix4.fromList([
    targetScale,
    matrix.row1.x,
    matrix.row2.x,
    matrix.row3.x,
    matrix.row0.y,
    targetScale,
    matrix.row2.y,
    matrix.row3.y,
    matrix.row0.z,
    matrix.row1.z,
    targetScale,
    matrix.row3.z,
    offset.dx,
    offset.dy,
    matrix.row2.w,
    matrix.row3.w,
  ]);

  tween.value = Matrix4Tween(
    begin: transformation.value,
    end: newMatrix,
  ).animate(
    CurveTween(curve: animCurve).animate(animation),
  );
}

スクロール速度の調節

次に閲覧時のスクロール速度の調節機能について説明します。

以下のような挙動を実現する機能です。

フリックする回数によってスクロール速度が変化するような挙動を実現する機能です。

今回実現したいことは、通常のスクロールスピードをコントロールして快適なビューワの閲覧を行えるようにすることです。コントロールする理由は少なく小さなフリックでマンガ 1 ページ分を読み進められるようにし、スクロールの速度による違和感、誤操作感をなくすためです。また継続的にスクロール体験を調整できるよう自作しました。

そのためにはいくつか設定する必要があります。減速する速度、加速度、スクロール認識時間、スピードの最大値等様々あります。

実際にコードを追いながらどのような調整、工夫を行なっているかを紹介します。

class ViewerScrollPhysics extends BouncingScrollPhysics {
  ViewerScrollPhysics({
    // スクロールの減速を強めにする
    super.decelerationRate = ScrollDecelerationRate.fast,
    super.parent = const RangeMaintainingScrollPhysics(),
  });

  /// スクロールの方向
  ScrollDirection prevDirection = ScrollDirection.reverse;

  /// 一つ前のはじきスクロール強弱
  double prevVelocity = 0;

  /// 一つ前のはじきスクロール実行時間
  DateTime? prevFlingTime;

  @override
  Simulation? createBallisticSimulation(
    ScrollMetrics position,
    double velocity,
  ) {
    final tolerance = toleranceFor(position);
    if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
      var newVelocity = velocity;

      // スクロール方向を確認
      final direction = velocity.isNegative
          ? ScrollDirection.forward
          : ScrollDirection.reverse;

      final currentTime = DateTime.now();
      final timeDiff =
          currentTime.difference(prevFlingTime ?? currentTime).inMilliseconds;

      // 同方向のスクロールで前回から 350ms 以内だったら加算する
      // ※ Activity が DragScrollActivity の場合のみ加算処理を行う
      //   DragScrollActivity がユーザがスクロールした時に使われる
      //   BallisticScrollActivity がはじきスクロールの際にさらに追加で使われる
      //   activity へのアクセスは非公開 API を使った判定方法しかなかったのでこの方法
      // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
      final activity = (position as ScrollPosition).activity;
      if (direction == prevDirection && activity is DragScrollActivity) {
        const continueTimeLimit = 350;
        const continueVelocityLimit = 10000;
        if (timeDiff < continueTimeLimit && velocity < continueVelocityLimit) {
          if (isIos) {
            // iOS は加算する
            newVelocity = velocity + prevVelocity;
          } else {
            // Android は前回のスクロール速度を継続するだけで加算はしない
            newVelocity = velocity;
          }
        }
      }

      // 前回のスクロール方向
      prevDirection = direction;
      // 前回のスクロール強度
      prevVelocity = velocity;
      // 前回のはじきスクロール時間
      prevFlingTime = currentTime;

      // 計算後のスクロール係数
      final newVelocityAbs = newVelocity.abs();

      // iOS は強弱で係数を計算し、Android は一定のままにする
      var multiplier = 1.0;
      var deceleration = 0.0;

      if (isIos) {
        // iOS ははじきの強さが一定を超えた時に係数をかける
        final iosFlingType = FlingType.values.firstWhere(
          (element) => newVelocityAbs <= element.factor,
          orElse: () => FlingType.stronger,
        );
        multiplier = iosFlingType.multiplier;
      }

      if (isAndroid) {
        // Android はさらにスクロールの減速を少し強めにする
        final androidDecelerationType = DecelerationType.values.firstWhere(
          (element) => newVelocityAbs <= element.factor,
          orElse: () => DecelerationType.normal,
        );
        deceleration = androidDecelerationType.deceleration;
      }

      return BouncingScrollSimulation(
        spring: spring,
        position: position.pixels,
        velocity: newVelocity * multiplier,
        leadingExtent: position.minScrollExtent,
        trailingExtent: position.maxScrollExtent,
        tolerance: tolerance,
        constantDeceleration: deceleration,
      );
    }
    return null;
  }

  /// はじきと認識する距離を少し長くする(デフォルトは 18px)
  @override
  double get minFlingDistance => 30;

  /// はじきと認識する最短時間を長めにする(デフォルトは 100/sec)
  @override
  double get minFlingVelocity => 150;

  /// スクロールと認識する閾値を緩めにする(デフォルトは 3.5)
  @override
  double get dragStartDistanceMotionThreshold => 0.1;

  /// スクロールスピードの最大値の調整(デフォルトは kMaxFlingVelocity の 8000.0)
  @override
  double get maxFlingVelocity {
    return kMaxFlingVelocity * (isIos ? 3 : 0.8);
  }

  @override
  SpringDescription get spring => SpringDescription.withDampingRatio(
        mass: 0.3, // .fast デフォルト
        stiffness: 750, // 引っ張って遷移部分で意図しない遷移が起きないようにバネをきつめにする
        ratio: 1.3, // .fast デフォルト
      );

  @override
  BouncingScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return ViewerScrollPhysics(
      decelerationRate: decelerationRate,
      parent: buildParent(ancestor),
    );
  }
}

今回実装した ViewerScrollPhysics では BouncingScrollPhysics を継承して実装しています。

iOS の場合は標準で BouncingScrollPhysics で Android だと ClampingScrollPhysics **となっていますが、今回は iOS の標準に準拠して実装することにしました。

細く設定した数値は以下のとおりです。

/// はじきと認識する距離を少し長くする(デフォルトは 18px)
@override
double get minFlingDistance => 30;

/// はじきと認識する最短時間を長めにする(デフォルトは 100/sec)
@override
double get minFlingVelocity => 150;

/// スクロールと認識する閾値を緩めにする(デフォルトは 3.5)
@override
double get dragStartDistanceMotionThreshold => 0.1;

/// スクロールスピードの最大値の調整(デフォルトは kMaxFlingVelocity の 8000.0)
@override
double get maxFlingVelocity {
  return kMaxFlingVelocity * (isIos ? 3 : 0.8);
}

@override
SpringDescription get spring => SpringDescription.withDampingRatio(
  mass: 0.3,
  stiffness: 750, // 引っ張って遷移部分で意図しない遷移が起きないようにバネをきつめにする
  ratio: 1.3,
);

全体的に弾きを認識する距離や時間を長めに設定しています。スピードの最大値は OS によって分けてそれぞれのプラットフォームで違和感のないように調節しています。

次に実際にスクロールの挙動を実行するメソッドを詳しく紹介します。

createBallisticSimulation というメソッドは指定された位置、加速度でスクロールの挙動を決定して実行します。具体的に実装した内容は同じ方向のスクロールが 350 ms 以内であれば加速を付け、OS ごとにはじきの強さで加速度の係数を調整したり、減速具合を大きくしたり調節しています。

また 1 回弾くだけで 2 回分のスクロールの速度がついてしまうことがあったので、あくまで DragScrollActivity のみ加速を追加するように制御しています。


RawGestureDetector(
  behavior: HitTestBehavior.opaque,
  gestures: {
    TapGestureRecognizer: 
      GestureRecognizerFactoryWithHandlers<
        TapGestureRecognizer>(
          TapGestureRecognizer.new,
          (instance) {
            ...
          },
      ),
    MultiTapDetectGestureRecognizer: 
      GestureRecognizerFactoryWithHandlers<
        MultiTapDetectGestureRecognizer>(
          MultiTapDetectGestureRecognizer.new,
          (instance) {
            ...
          },
      ),
  },
  child: ...,
);

補足になりますが activity へのアクセスは非公開 API を使った判定方法しかなかったのでこの方法を採用しています。

activity を取得している箇所のコード

abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
  ...

  @protected
  @visibleForTesting
  ScrollActivity? get activity => _activity;

  ...
}

iOS ネイティブのスクロール体験に近づけるため、弾く強さが一定を超えた場合に係数を掛け合わせます。一定の基準は FlingType に集約されています。

/// iOS のはじきスクロールの強弱
enum FlingType {
  weaker(factor: 999, multiplier: 0.9),
  weak(factor: 1999, multiplier: 0.95),
  normal(factor: 6999, multiplier: 1),
  strong(factor: 9999, multiplier: 1.1),
  stronger(factor: 10000, multiplier: 1.1);

  const FlingType({
    required this.factor,
    required this.multiplier,
  });

  final double factor;
  final double multiplier;
}

上記を使用して新たに追加された加速度よりも同等かその次に大きい factor が弾き具合を判定します。その値を multiplier として使用します。


@override
Simulation? createBallisticSimulation(
  ScrollMetrics position,
  double velocity,
) {
  final tolerance = toleranceFor(position);
  if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
    var newVelocity = velocity;
    ...
    
    // 計算後のスクロール係数
    final newVelocityAbs = newVelocity.abs();
    
    // iOS は強弱で係数を計算し、Android は一定のままにする
    var multiplier = 1.0;
    var deceleration = 0.0;
    
    if (isIos) {
      // iOS ははじきの強さが一定を超えた時に係数をかける
      final iosFlingType = FlingType.values.firstWhere(
        (element) => newVelocityAbs <= element.factor,
        orElse: () => FlingType.stronger,
      );
      multiplier = iosFlingType.multiplier;
    }
    ...
  }
}

Android ネイティブのスクロール体験に近づけるため、通常よりもさらにスクロールの減速具合を強めます。また上記で行ったのと同様に一定の基準に応じて減速具合を決定します。これら基準は DecelerationType に集約しています。

/// Android のスクロールの減速の強弱
enum DecelerationType {
  weak(factor: 1499, deceleration: 450),
  normal(factor: 1500, deceleration: 200);

  const DecelerationType({
    required this.factor,
    required this.deceleration,
  });

  final double factor;
  final double deceleration;
}

上記を使用して新たに追加された加速度よりも同等かその次に大きい factor が弾き具合を判定します。その値を deceleration として使用します。

if (isAndroid) {
  // Android はさらにスクロールの減速を少し強めにする
  final androidDecelerationType = DecelerationType.values.firstWhere(
    (element) => newVelocityAbs <= element.factor,
    orElse: () => DecelerationType.normal,
  );
  deceleration = androidDecelerationType.deceleration;
}

実際にスクロールの数値を調節する際、開発環境で速度等を変更できるデバッグ画面を作成しました。

調節できる箇所は加速度、はじきの最大・最小値を1000 ごとに 強弱と減速の値です。またこの調整は iOS のみ適応させています。

調整した値は先ほど紹介した ViewerScrollPhysics へ値を代入します。

使用するのは最大速度を定義している maxFling と はじきを 1000 ごとに強弱と減速の値を管理している params です。


typedef ViewerScrollParameters = (
  // はじき強弱の最小値
  int minVelocity,
  // はじき強弱の最大値
  int maxVelocity,
  // はじきの係数
  double factor,
);

class ViewerScrollPhysics extends BouncingScrollPhysics {
  ViewerScrollPhysics({
    required this.maxFling,
    required this.params,
    ...
  });

  /// 各強弱のパラメータ
  final List<ViewerScrollParameters> params;

  /// 最大速度
  double maxFling;
  
  ...

そしてparams の適応は createBallisticSimulation にて行います。はじきの最大値よりも小さい一番初めの値を適応させています。


@override
Simulation? createBallisticSimulation(
  ScrollMetrics position,
  double velocity,
) {
  final tolerance = toleranceFor(position);
  if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
    var newVelocity = velocity;
    ...
    // iOS は強弱で係数を計算し、Android は一定のままにする
    var multiplier = 1.0;
    var deceleration = 0.0;
    if (isIOS) {
      // 開発環境のみ適応
      if (const String.fromEnvironment('flavor') == 'dev') {
        final (_, __, factor, dece) = params.firstWhere(
          (element) {
            final (_, max, __, ___) = element;
            return newVelocityAbs <= max;
          },
        );
        multiplier = factor;
        deceleration = dece;
      } else {
        // はじきの強さが一定を超えた時に係数をかける
        final iOSFlingType = FlingType.values.firstWhere(
          (element) => newVelocityAbs <= element.factor,
          orElse: () => FlingType.stronger,
        );
        multiplier = iOSFlingType.multiplier;
      }
    }
  }
}

maxFling は maxFlingVelocity に適応させます。

  /// スクロールスピードの最大値の調整(デフォルトは kMaxFlingVelocity の 8000.0)
  @override
  double get maxFlingVelocity {
    return kMaxFlingVelocity * (isIOS ? maxFling : 0.8);
  }

全体的な工夫点

パフォーマンス改善に関わる tips を紹介したいと思います。

1つ目は useValueNotifier / useValueListenable の利用です。

useValueNotifier とは 自動で dispose してくれる ValueNotifier を継承した hook です。useValueListenable は ValueListenable を監視して値を返す hook です。

useState だと定義した build 関数全体が rebuild されますが、HookBuilder 内で useValueListenable を利用して useValueNotifier の値を監視することで、rebuild のスコープを制限できます。

実際の使用例を紹介したいと思います。

先ほど紹介したスクロールバーにて現在のページの番号を変更する箇所があります。

宣言自体は ViewerScrollBarContainer で定義しており値も使用しています。また別の子 widget (ViewerScrollBarPageCounter) でもその値を表示しています。

使用方法はまず ViewerScrollBarContainer に useValueNotifier を定義します。

// 現在のページ番号
final currentPage = useValueNotifier(0);

次に ViewerScrollBarPageCounter で実際に表示させたい widget へこの値を反映させます。

反映させたい最小限の widget 範囲を HookBuilder で囲います。

builder 内で useValueListenable を呼び出して監視している値を取得します。


child: HookBuilder(
  builder: (context) {
    final currentValue = useValueListenable(current);
    return Row(
      children: [
        SizedBox(
          width: context.adaptive(
            small: 36,
            medium: 40,
          ),
          child: Text(
            // currentPage は 0 始まりのため、+1 する
            (currentValue + 1).toString(),
            textAlign: TextAlign.center,
            style: context
              .adaptive(
                small: context.systemStyle.p14,
                medium: context.systemStyle.p16,
              )
              .bold
              .copyWith(
                color: context.textColor.inversePrimaryDefault,
              ),
          ),
        ), 
        ...
      ],
    );
  },
);

このように useValueNotifier / useValueListenable を使用することで別の箇所で値を定義していても別の widget で取得し、かつ rebuild の範囲も縮小することが可能になります。

2つ目は RepaintBoundary によるパフォーマンス向上です。

RepaintBoundary とは repaint が頻繁に発生する widget に対してそれよりも祖先の widget へ再描画の影響を伝播させないという役割があります。よくあるパターンとして高頻度に作動するアニメーションに RepaintBoundary をラップして repaint を伝播させないという工夫があります。

こちらも実際の使用例を紹介したいと思います。

スクロールによって repaint が頻繁に生じる BackdropFilter の処理が発生する箇所があります。これは RepaintBoundary なしでは BackdropFilter よりも親の widget に対して不要な repaint を伝播させてしまう可能性があります。そのため RepaintBoundary を使用することで親 widget 以降に伝播させないようにしました。しかし他の widget へ repaint を伝播させない目的に他にキャッシュ化するという目的もあります。そのため全ての箇所に配置するとメモリの消費量が増加し逆にパフォーマンスが悪化する可能性があるので注意が必要です。


RepaintBoundary(
  child: BackdropFilter(
    filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
    child: Container(
      child: ...
    ),
  ),
)

最後に

Flutter でマンガビューワを完全オリジナルで実装している事例はかなり少ないので、ぜひ参考にして頂ければ幸いです。

前回、前々回にも同サービスの Flutter に関する記事が掲載されているのでぜひご覧ください。

また、次回からは Web チームからも記事が掲載されるのでそちらもぜひ楽しみにしてください。

ジャンプTOON Flutter アプリの全体像

ジャンプTOON Flutter × GraphQL ~宣言的なアプリ開発の工夫~