#はじめに
初投稿となります。お手柔らかにお願いします。
今回は、タイトルの通り、インスタでストーリーを移動するときの、四角い箱を回すようなUIをFlutterで作成していきたいと思います。
この記事が皆さんのお役に立てると嬉しいです。
#パッケージ
今回の自分が使用したパッケージは以下の通りです。
dependencies:
flutter_hooks: ^0.15.0
hooks_riverpod: ^0.12.1
flutter:
sdk: flutter
状態管理用にriverpodを入れています。(今回は使いません)
また、コードを簡単に書くためにflutter_hooksも入れています。
ただ、hooksを使ったことがない人でも、ドキュメントを読めばStatefulWidgetに置き換えることができると思います。
完成図
今回の完成図はこちらです。
#実装
まずは普通にPageViewとコントローラーを作ります。
ページ(インスタでいうストーリー)には後述するTransformer
というクラスを自作します。
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); // 変更
}, []);
いざ、ビルド、、、
できた!!!
コード
完成したコードはこちらです。
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でスライド時にアニメーションさせる実装