5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-02-11

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

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

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

pubspec.yaml

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

状態管理用にriverpodを入れています。(今回は使いません)
また、コードを簡単に書くためにflutter_hooksも入れています。

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

完成図

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

story.gif

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

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を使っていない方は見慣れないかもしれませんが、参照されなくなったら自動的に破棄されるPageControllerです。
このようにflutter_hooksは簡単に書くことができるので、お勧めしています。

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

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

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

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

になります。

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

  • スクロールしている割合(値の範囲 0 ~ 1)
scrollRate = pageController.offset / pageController.position.maxScrollExtent;
  • 現在のページの割合(値の範囲 0 ~ 1)
pageRate = index / ( length - 1 );

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

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

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

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

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

実装に戻る

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

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

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

...

// 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;
 }, []);

...

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

// 変形用の値
 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をしてそれを回避します。

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

いざ、ビルド、、、

できた!!!

story.gif

コード

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

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)

参考

FlutterのMatrix4Dでtransformする方法
Matrix4 class
【Flutter】PageViewでスライド時にアニメーションさせる実装

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?