RefreshIndicatorを改造する

# はじめに

### 私という人間 / ごあいさつ

初めまして！toridoriエンジニアブログ2021年度5月号を担当させて頂きます。(もう6月入ったよ(　˙-˙　))

開発部でモバイルアプリを担当していますいるべです。

いきなり個人的な話なのですが、GW前に「GW中、個人ブログに1記事は絶対に書こう！」と決めてからもうすぐ一ヶ月が経とうとしています。(執筆時)

おかしいですね？？

今回タイミングよく偉い人からこのエンジニアブログのお話を頂いたので、私の好きなUXの話を交えて書かせて頂きました。最後まで読んで頂けると嬉しいです。

# あのUIすこ！

### UI/UXって大事

さて、みなさんは「小さなストレス」についてどうお考えでしょうか？

例えば

- 押しづらい「いいねボタン」
- いいところで挟まれるローディング
- よくタップするのに画面上端にあるボタン
- 6つあるピノを1つ勝手に食べられる

などなど...

腹を立てて非難するほどではなくとも、その「小さなストレス」が積み重なると嫌になりますよね。

でも大丈夫。

残念ながら私のピノをあげることはできませんが、お力になれることはあるかもしれません。

### iOSのgoogle chromeアプリのUIがすこ

<img width=500 src="https://user-images.githubusercontent.com/61507019/120761478-314b3500-c550-11eb-8449-20dcfbcea887.jpeg">

図1: iOSのgoogle chromeアプリ: pullしているところ

この画像はiOSのGoogle Chromeアプリで、表示の上端で下にスクロールして引っ張ると出てくる「新しいタブを開く」「再読み込み」「タブを閉じる」が表示されている画面です。

**便宜上、図1の「新しいタブを開く」「再読み込み」などのUIをアクションUIと表現します。**

**便宜上、図1の「再読み込み」に重なっている灰色の丸をフォーカスサークルと表現します。**

動作としては、

- フォーカスサークルがガイドになっており、それが重なっているアクションUIが現在選択しているアクションUIであることを表している
- 下にスクロールしたまま指を右に動かすと、が「タブを閉じる」の上に移動し、指を離せばタブを閉じることができる
- 左も真ん中も同様なので、指をあちこち動かさずにスムーズな操作を行うことができる

となっており、めちゃくちゃ便利なUIだと私は感じています。

# 作った( ・∇・)

### 成果物

<img width=400 src="https://user-images.githubusercontent.com/61507019/120761603-55a71180-c550-11eb-8cf8-91a9b4b7d00c.gif">

multi pull

**starとlikeください( ・∇・)**

src: [https://github.com/airy-swift/multi_pull](https://github.com/airy-swift/multi_pull)

pub: [https://pub.dev/packages/multi_pull](https://pub.dev/packages/multi_pull)

動作は上記で説明したものと同等のものですが説明しておくと、

- 下に引っ張るとアクションUIたちが表示される
- 下にスクロールしたまま指を動かすと灰色の丸がそれに合わせて動く
- フォーカスサークルが選択しているアクションUIを示唆している
- 今回の場合は右がテキストフィールドのクリア。真ん中がリロード(実際の動作は2秒待つだけ)。左が表示している画面をpopして前の画面に戻る

となっています。

# 実装解説

流石に全部は解説できないので要所を掻い摘んで解説させて頂きます！

基本的には大したことはしておらず、ほとんどのRefreshIndicatorの動作通りです！

https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/refresh_indicator.dart

### 大まかな内部の動作の流れ

1. 画面表示上端を超えたスクロールを検知すると、アクションUIを並べたUIをdrag状態として操作に合わせて移動させる
2. 一定量下にスクロールを行うとarmed状態となり、「ユーザが指を離す」か「上にスクロールしてキャンセルする(drag状態に戻す)」かを待つ。このとき、横に指を移動するとアクションUIを選択できる
3. キャンセルせず指を離すとsnap状態に入り、UI表示を調節する
4.  選択したアクションUIが非同期処理ならばrefresh状態になり、RefreshProgressIndicatorが表示され、非同期処理が完了するまでloading表示を行う
5.  選択したアクションUIが非同期処理でないならば処理を行う
6. 処理が完了したらdone状態になり、引っ張って出てきたUIが消え、内部状態がリセットされる

### NotificationListener

動作の流れ1にある「画面表示上端を超えたスクロールを検知する」はNotificationListenerが担っています。

```dart
final Widget child = NotificationListener<ScrollNotification>(
      onNotification: _handleScrollNotification,
      child: NotificationListener<OverscrollIndicatorNotification>(
        onNotification: _handleGlowNotification,
        child: widget.child,
      ),
    );
```

NotificationListenerはchildとonNotificationというCallbackと検知するNotificationをジェネリクスとして持ちます。

Notificationについて

- ScrollNotificationはスクロールが検知されたときに発火する。コードで操作したときや慣性が働いているときも呼ばれるので単純に呼び出し回数が多くなる
- OverscrollIndicatorNotificationは画面端を超えたら発火する

onNotificationについて

onNotificationは「親にNotificationを伝播しないでよいか？」というbool値を返します。

OverscrollIndicatorNotificationのonNotificationがtrueのときはScrollNotificationのonNotificationは呼ばれません。

今回の場合は呼び出し回数が多いScrollNotificationを無駄に呼ばないようにOverscrollIndicatorNotificationが制御しています。

### フォーカスサークル

フォーカスサークルの横移動はAnimationControllerで制御しています。

```dart
_horizonPositionController = AnimationController(vsync: this, value: 0.5);
```

AnimationControllerのvalueはlowerBoundやupperBoundを指定しないと0から1の値をとります。

そして_horizonPositionControllerのvalueは下記の画像のように位置付けています。

上記コードで初期値を0.5に設定しているのは灰色の丸が最初は真ん中にくるようにしているというわけですね。

<img width=500 src="https://user-images.githubusercontent.com/61507019/120761254-f77a2e80-c54f-11eb-94d2-46eba11365f3.jpg">

AnimationController.valueの役割

AnimationContorollerを設定したら、あとは下記コードのようにvalueの扱い方を設定し、横スクロール量によって_horizonPositionControllerのvalueを設定してあげれば指の左右操作についてきてくれるようになります。

```dart
AnimatedBuilder(
  animation: _horizonPositionController,
  builder: (context, child) {
    return Transform.translate(
      child: Opacity(
      opacity: _mode == _RefreshIndicatorMode.refresh ||
               _mode == _RefreshIndicatorMode.done
                 ? 0.0
                 : 0.3,
        child: Circle(
          radius: _actionSize / 2,
          backgroundColor: Colors.grey,
        ),
      ),
      offset: Offset(
        (_horizonPositionController.value * indicatorWidth) - (indicatorWidth / 2),
        0,
      ),
    ),
  },
),
```

### アクションUI

今回便宜上アクションUIと呼んでいるUIはActionWidgetとして定義しています。

```dart
class ActionWidget extends StatelessWidget {
  ActionWidget({
    @required this.icon,
    this.label,
    this.action,
    this.onRefresh,
  }) : assert((action != null) != (onRefresh != null));

  final Widget icon;
  final String label;
  final Function action;
  final RefreshCallback onRefresh;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: _actionSize - 30,
          height: _actionSize - 30,
          child: icon,
        ),
        if (label != null) //
          Text(label),
      ],
    );
  }
}
```

なんてことないStatelessWidgetです。特徴は下記のようになっています。

- actionに何らかの処理を渡すと、ActionWidgetが選択されたときに実行されます。
- onRefreshに非同期処理を渡す、ユーザにRefreshProgressIndiicatorを見せることができます。

# 使用方法

### install

```yaml
dependencies:
  multi_pull: [latest_version]
```

### usage

```dart
class Home extends StatelessWidget {
  const Home();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Home Page"),
      ),
      body: MultiPull(
        actionWidgets: [
          ActionWidget(
            icon: Icon(Icons.arrow_back_ios_outlined),
            label: "back",
            action: () => Navigator.pop(context),
          ),
          ActionWidget(
            icon: Icon(Icons.refresh_rounded),
            label: "reload",
            onRefresh: () async => await Future.delayed(Duration(seconds: 2)),
          ),
          ActionWidget(
            icon: Icon(Icons.backspace_outlined),
            label: "clear",
            action: () {
              clear()
            },
          ),
        ],
        child: ListView(
          physics: BouncingScrollPhysics(),
          children: List.generate(100, (index) => Text(index.toString(), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold))),
        ),
      ),
    );
  }
}
```

使い方としてはScrollableなWidgetを子に持つMultiPullを宣言し、actionWidgetsを渡してあげるだけです。

とっても簡単でいいですね！

### (謎の声) < 「**これってアクション3つじゃないといけないの？？」**

いいえ、そんなことはありません！

テスト段階では１〜５個まで表示を確認できました。

気になる方はお手元で試して見てください。

あまり多いとオーバーフローして表示エラーになりますが。。笑

### 使い所・使用感

- 取り返しのつかない処理をMultiPullに配置するのは考えものですが、「やり直し機能」や「戻る機能」「新しいメモを作成する」なんかは置いておくとかなり使いやすくなるかなと思います！
- 実際実用的なActionWidgetの数は1~3個ですかね？それより多いと流石にごちゃごちゃします！
- MultiPullにしか配置していない処理は極力減らすべき！重要な処理がここにしかないとユーザは見つけられず困ってしまうこと間違いなしですね！
- 感想：めっちゃええやん！w

# まとめ

楽しく実装させて頂きました！

個人的にとても好きなUIなので個人開発でも取り入れられたら嬉しいなと思っています。

ここまで読んでくださりありがとうございました。

## toridoriについて

現在、toridoriではモバイルアプリエンジニア・バックエンドエンジニアを募集しています。
興味のある方はぜひご応募ください。

https://toridori.co.jp/recruit/jobs/