イケてるアプリにありがちなParallax(パララックス) EffectがついているWalkthroughをFlutterで作ります。
パララックスは「視差」という意味で現代のWebページやスマホのスクロールコンテンツと相性が良く、個人的に好きな表現方法です。
過去にはこんなものをAndroidで作っていたりします。
ついでに当時どうしても作りたくてリリース前に ほぼ1日で作った前作
— AAkira (@_a_akira) April 18, 2019
2016年1月頃はまだandroidでsvgが使えなかったので、ライセンスページなんぞに容量は使えないって事で、ロケットとかはcanvasにパスを使ってKotlinで描くという荒業をしてますw
今見ると凄いシンプル pic.twitter.com/JVrd1tNCe8
https://github.com/cats-oss/android-license-sample
FlutterでParallaxを実現するには
まずFlutterでWalkthroughのような画面を実現するには、PageView というWidgetを使う必要があります。
一番シンプルな使い方はこの形です。
final PageController _pageController = PageController();
PageView(
controller: _pageController,
children: [
Container(color: Colors.blue),
Container(color: Colors.amber),
Container(color: Colors.deepOrange),
Container(color: Colors.green),
],
);
このPageView
にセットするPageController
から得られる値を計算してオブジェクトの配置を行っていきます。
ListenerをセットするとPageViewをスワイプ等で動かした際にコールバックがきます。
@override
void initState() {
super.initState();
_pageController.addListener(() {
// set state
});
}
4画面あるPageViewの例です。
※ 横幅の430は端末により異なる(例として430にしています)
PageController
から取得できる値はこのようになっていて
- Viewport Dimension
PageController.position.viewportDimension
が1ページ分のPagerViewのサイズになります。このようにMarginがない場合は画面サイズになります。
- Offset
PageController.offset
がスクロールの距離になります。
この2つの値を使うとPageViewの位置を比で表せます。
PageViewにはbuilderもあって、indexの位置を取ることもできます。
offsetで現在のスクロール量が取れるので各ページごとのスクロール比率を計算できます。
今回はページが画面の中心にある時の0
を基準として-1.0~1.0
で比を表しました。
PageView.builder(
controller: _pageController,
itemCount: _children.length,
itemBuilder: (context, index) {
final item = _children[index];
item.valueNotifier.value = (_pageController.offset /
_pageController.position.viewportDimension) -
index;
return item;
},
),
表示毎の各ページの値
- ページ1が表示されている時
ページ1 | ページ2 |
---|---|
0.0 | -1.0 |
- ページ1とページ2が半分ずつ表示されている時
ページ1 | ページ2 |
---|---|
0.5 | -0.5 |
- ページ2表示されている時
ページ1 | ページ2 | ページ3 |
---|---|---|
-1.0 | 0.0 | 1.0 |
オブジェクトを動かす
次にページの表示比率を使ってオブジェクトの動きを計算していきます。
比率をWidgetに通知するためValueNotifier
を持ったWidgetを作成しました。この辺りは設計なので好きなやり方でやってください。
abstract class PageItem extends StatelessWidget {
ValueNotifier<double> get valueNotifier;
}
class PageContents extends StatelessWidget {
const PageContents({
Key? key,
required this.backgroundColor,
required this.children,
}) : super(key: key);
final Color backgroundColor;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Stack(children: children),
);
}
}
Pageごとの操作
ValueListenableBuilder
経由でPageViewから値を伝えます。
class Page1 extends PageItem {
@override
final ValueNotifier<double> valueNotifier = ValueNotifier<double>(0);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: valueNotifier,
builder: (context, double ratio, child) {
return PageContents(
backgroundColor: Colors.blue,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 64),
child: Transform.translate(
offset: Offset(200 * ratio, 0),
child: Text(
'Make everyday cooking fun',
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headline3
?.merge(const TextStyle(color: Colors.white)),
),
),
),
Container(
alignment: Alignment.bottomCenter,
margin: EdgeInsets.symmetric(vertical: 300),
child: Transform.translate(
offset: Offset(700 * ratio, 0),
child: SvgPicture.asset(
'assets/shelves.svg',
width: 400,
),
),
),
Container(
alignment: Alignment.bottomCenter,
margin: EdgeInsets.symmetric(vertical: 100),
child: Transform.translate(
offset: Offset(300 * ratio, 0),
child: SvgPicture.asset(
'assets/woman1.svg',
width: 100,
),
),
),
Container(
alignment: Alignment.bottomCenter,
margin: EdgeInsets.symmetric(vertical: 100),
child: Transform.translate(
offset: Offset(600 * ratio, 0),
child: SvgPicture.asset(
'assets/table.svg',
width: 350,
),
),
),
],
);
},
);
}
}
初期配置は Positioned
Widget等で行います。このページはContainer
のAlignment
で行っています。
ポイントはTransformの部分です。
translateはChild Widgetを任意の方向に移動させます。
x軸は+方向が右側、-方向が左側になるので、この場合左方向へのスワイプ(ページめくり)で右側に動きます。
ParallaxはOffsetの変化量をWidget毎に変えることで実現します。
女性の絵は移動量300になります。ratioは-1.0~1.0の間で推移するのでページの中心にいる時は初期配置でスワイプすると300まで移動します。
テーブルの絵は移動量600になるので、女性の絵より2倍移動することになりスワイプすると、テーブルはより動いて見え、女性はスワイプの動作と合わせると若干止まって見えます。
これがParallaxの仕組みです。
Transform.translate(
offset: Offset(300 * ratio, 0),
child: SvgPicture.asset(
'assets/woman1.svg',
width: 100,
),
),
Transform.translate(
offset: Offset(600 * ratio, 0),
child: SvgPicture.asset(
'assets/table.svg',
width: 350,
),
),
Scaleの操作
Parallaxは移動量の操作が基本ですが、Scaleの操作も入れることで表現の幅が広がります。
やり方はかんたんでTransform.scale()
を使うだけです。
ポイントはratio > 0 ? 0 : ratio
の部分です。
単純に scale: ratio + 1
にしてしまうとページめくりの際に2倍のサイズに拡大するのできれいなアニメーションになりません。
そこでratio > 0
の部分を入れることでページが表示される最中は拡大表示して、めくる時はそのままの拡大率にしています。
Transform.scale(
scale: (ratio > 0 ? 0 : ratio) + 1,
child: SvgPicture.asset(
'assets/food1.svg',
width: 150,
),
),
Indicator(おまけ)
Parallaxができたのでこれはおまけです。 よくWalkthroughにはページ数と現在の位置を示すために下の方に点々が置かれます。 ちゃんとした名前はおそらくないですが、Dot Indicatorと呼ばれることが多い気がします。
class DotsIndicator extends StatelessWidget {
const DotsIndicator({
Key? key,
required this.dotsCount,
required this.position,
this.dotColor = Colors.black45,
this.activeDotColor = Colors.white,
this.dotSize = const Size.square(8),
this.activeDotSize = const Size.square(12),
this.dotSpacing = const EdgeInsets.all(6.0),
}) : assert(dotsCount > 0),
assert(position >= 0),
assert(position < dotsCount),
super(key: key);
final int dotsCount;
final double position;
final Color dotColor;
final Color activeDotColor;
final Size dotSize;
final Size activeDotSize;
final EdgeInsets dotSpacing;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List<Widget>.generate(dotsCount, _dot),
);
}
Widget _dot(int index) {
final state = min(1.0, (position - index).abs());
final size = Size.lerp(activeDotSize, dotSize, state);
final color = Color.lerp(activeDotColor, dotColor, state);
final shape = ShapeBorder.lerp(
const CircleBorder(),
const CircleBorder(),
state,
);
return Container(
width: size!.width,
height: size.height,
margin: dotSpacing,
decoration: ShapeDecoration(
color: color,
shape: shape!,
),
);
}
}
PageController.page
で現在のページ(index)番号がとれるので PageController.addListener
内で値を更新します。
class _Body extends StatefulWidget {
@override
State<StatefulWidget> createState() => _BodyState();
}
class _BodyState extends State<_Body> {
double? _currentIndex = 0;
@override
void initState() {
super.initState();
_pageController.addListener(() {
setState(() {
_currentIndex = _pageController.page;
});
});
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
PageView.builder(
controller: _pageController,
itemCount: _children.length,
itemBuilder: (context, index) {
final item = _children[index];
item.valueNotifier.value = (_pageController.offset /
_pageController.position.viewportDimension) -
index;
return item;
},
),
Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(vertical: 24),
child: DotsIndicator(
dotsCount: _children.length,
position: _currentIndex ?? 0,
),
),
],
);
}
}
まとめ
Flutterでイケてるアプリでよく見るParallax Effectを使ったWalkthroughを作ってみました。
アプリをダウンロードして最初に表示されるので、ユーザへのインパクトを上げるには結構効果的だと思います。
私自身普段からたくさんのアプリを見ていますが、ダウンロードしていい感じのWalkthroughが出ると 「オッ!👀 期待できるアプリだ!」ってなります。
イケてるアプリを作っていきましょう💪
コードはGitHubにあげています。
Credit
今回のサンプルに使った絵はこちらのものを使っています。
Make everyday cooking funはCookPad, Incのスローガンです。いつもお世話になっています