FlutterでInstagramのストーリーの動きをトレース

#はじめに
初投稿となります。お手柔らかにお願いします。

今回は、タイトルの通り、インスタでストーリーを移動するときの、四角い箱を回すようなUIをFlutterで作成していきたいと思います。
この記事が皆さんのお役に立てると嬉しいです。


#パッケージ
今回の自分が使用したパッケージは以下の通りです。

```yaml:pubspec.yaml

dependencies:
  flutter_hooks: ^0.15.0
  hooks_riverpod: ^0.12.1
  flutter:
    sdk: flutter
```

状態管理用に[riverpod](https://pub.dev/packages/riverpod)を入れています。(今回は使いません)
また、コードを簡単に書くために[flutter_hooks](https://pub.dev/packages/flutter_hooks)も入れています。

ただ、hooksを使ったことがない人でも、ドキュメントを読めばStatefulWidgetに置き換えることができると思います。

# 完成図

今回の完成図はこちらです。

![story.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/c64038f7-f69c-318e-5165-9024bc7cb03a.gif)



#実装
まずは普通にPageViewとコントローラーを作ります。
ページ(インスタでいうストーリー)には後述する`Transformer`というクラスを自作します。

```dart:story_view.dart

class StoryView extends HookWidget {
   StoryView({Key key}) : super(key: key);
 
   @override
   Widget build(BuildContext context) {
     final _pageController = usePageController();
 
     return Scaffold(
       appBar: AppBar(
         title: const Text('Appbar'),
       ),
       body: SafeArea(
         child: PageView.builder(
           itemCount: 4,
           controller: _pageController,
           itemBuilder: (context, index) {
             return Transformer(); // 後で作成
           },
         ),
       ),
     );
   }
 }
```

`usePageController`という書き方を[flutter_hooks](https://pub.dev/packages/flutter_hooks)を使っていない方は見慣れないかもしれませんが、参照されなくなったら自動的に破棄される`PageController`です。
このように[flutter_hooks](https://pub.dev/packages/flutter_hooks)は簡単に書くことができるので、お勧めしています。

### アニメーションで用いる値

ここで、ちょっとした算数が必要になってきます。

いま、コントローラーやPageViewから手に入る値は、

- `length`       ページ全体のアイテム数
- `index`       ページがそれぞれ何ページ目か
- `pageController.position.maxScrollExtent`    ページ全体でスクロールできる距離
- `pageController.offset`    今スクロールしている時のポジション(一ページ目が0)

になります。

これらから、組み合わせていくと、

- スクロールしている割合(値の範囲 0 ~ 1)

```dart
scrollRate = pageController.offset / pageController.position.maxScrollExtent;
```

- 現在のページの割合(値の範囲 0 ~ 1)

```dart
pageRate = index / ( length - 1 );
```

という値を得ることができます。
また、ページが選択されている状態の場合、**常にふたつは同じ値**をとります。
当然ですよね。現在選択されているページが4ページ中2ページ目(index = 1)だとすると、

```dart
pageRate =  1 / (4 - 1); // 1/3
```

```dart
scrollRate = '1ページ目から2ページ目までの距離' / '4ページ目(全体)までの距離' // 1/3
``` 

ここで、それぞれの割合の違いが、細かい値をとるかとらないかだけであることがわかっていれば大丈夫です。
なので、

```dart
value = pageRate - scrollRate; // -1 < value < 1
```

という値が最終的に出てきます。この値はページをスライドさせるたびに滑らかに-1から1へと移動していきます。
これはFlutterでアニメーションを作ったことのある人ならわかると思いますが、`Tween`の値でよく見かけます。なので、この値でアニメーションするPageViewが作れそうです。

### 実装に戻る
頭を使ったと思うので、ここからはサクサクとコードを書いていきましょう。

先ほど述べた`Transformer`を作成します。

まずは、コントローラーにリスナーを追加しましょう。
スクロールを感知したら、先ほどの`value`を更新していきます。

```dart
...

// value用のステート
final _value = useState<double>(0);
 
 // スクロールするたびにvalueを更新するコールバック関数を定義
 VoidCallback _update = () {
       _value.value = index -
           pageController.offset /
               pageController.position.maxScrollExtent *
               (length - 1.0);
 };
 
 useEffect(() {
       // コントローラーにリスナーを追加
       pageController.addListener(_update);
       return null;
 }, []);

...
```

本命の変形するウィジェットも書いていきましょう。

```dart
// 変形用の値
 final _threeD = Matrix4.identity()
       ..setEntry(3, 2, 0.001)
       ..rotateY(-0.4 * pi * _value.value);
 
 // 変形するウィジェット
 return Transform(
       alignment: _value.value > 0
           ? FractionalOffset.centerLeft
           : FractionalOffset.centerRight,
       transform: _threeD,
       child: Container(
         width: MediaQuery.of(context).size.width,
         height: MediaQuery.of(context).size.height,
         decoration: BoxDecoration(
           borderRadius: BorderRadius.circular(10),
           color: Colors.blueAccent,
         ),
         child: Center(child: Text('テキスト')),
       ),
 );
```

スクロールするたびに値が変化するvalueを用いて画面の向こう側に回転していくように見えるMatrix4を定義しています。これをTransformのtransformに入れてあげるだけで、あとはいい感じに回転するはずです。

しかし、そううまくはいきません。エラーが発生しました。

```
A ValueNotifier<double> was used after being disposed.
```

これは、PageViewの仕様にあります。PageViewは選択されているページと両隣のページしかビルドされません。
つまり、`useState`が参照されなくなるため自動的に破棄されてしまいます。その後、破棄された`ValueNotifier`を`_update`で更新しようとしているため、怒られています。
riverpodは便利ですが、意図しないときに破棄されてしまうなどしてエラーが出る時が多々あるため注意が必要です。

なので、disposeされると同時に、removeListenerをしてそれを回避します。

```dart
 useEffect(() {
       pageController.addListener(_update);
       return () => pageController.removeListener(_update); // 変更
 }, []);
```

いざ、ビルド、、、

できた！！！

![story.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/c64038f7-f69c-318e-5165-9024bc7cb03a.gif)

# コード

完成したコードはこちらです。

```dart:story.dart

 import 'dart:math';
 
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 
 class StoryView extends HookWidget {
   StoryView({Key key}) : super(key: key);
 
   @override
   Widget build(BuildContext context) {
     final _pageController = usePageController();
 
     return Scaffold(
       appBar: AppBar(
         title: const Text('Appbar'),
       ),
       body: SafeArea(
         child: PageView.builder(
           itemCount: 4,
           controller: _pageController,
           itemBuilder: (context, index) {
             return Transformer(
               index: index,
               pageController: _pageController,
               length: 4,
             );
           },
         ),
       ),
     );
   }
 }
 
 class Transformer extends HookWidget {
   const Transformer({Key key, this.pageController, this.index, this.length})
       : super(key: key);
   final int index;
   final int length;
   final PageController pageController;
 
   @override
   Widget build(BuildContext context) {
     final _value = useState<double>(0);
 
     VoidCallback _update = () {
       _value.value = index -
           pageController.offset /
               pageController.position.maxScrollExtent *
               (length - 1.0);
     };
 
     useEffect(() {
       pageController.addListener(_update);
       return () => pageController.removeListener(_update);
     }, []);
 
     final _threeD = Matrix4.identity()
       ..setEntry(3, 2, 0.001)
       ..rotateY(-0.4 * pi * _value.value);
 
     return Transform(
       alignment: _value.value > 0
           ? FractionalOffset.centerLeft
           : FractionalOffset.centerRight,
       transform: _threeD,
       child: Container(
         width: MediaQuery.of(context).size.width,
         height: MediaQuery.of(context).size.height,
         decoration: BoxDecoration(
           borderRadius: BorderRadius.circular(10),
           color: Colors.green,
         ),
         child: Center(child: Text('テキスト')),
       ),
     );
   }
 }
```

# まとめ・感想

実装している時は気にしていなかったのですが、コーディングを終えてからコード量の少なさに驚きました。
ここから、レイアウトなどを加えていくと、もちろんコードは膨張していきますが、その基礎となるUIの部分でここまで簡潔にかけるのは、Flutterだからこそだと思います。(特に[riverpod](https://pub.dev/packages/riverpod))


# 参考

[FlutterのMatrix4Dでtransformする方法](https://qiita.com/wim/items/e45609574edf6f190ba6)
[Matrix4 class](https://api.flutter.dev/flutter/vector_math_64/Matrix4-class.html)
[【Flutter】PageViewでスライド時にアニメーションさせる実装](https://www.egao-inc.co.jp/programming/flutter_page_animation/)