July 31, 2021

FlutterでWalkthroughをParallaxする

walkthrough preview

イケてるアプリにありがちなParallax(パララックス) EffectがついているWalkthroughをFlutterで作ります。

パララックスは「視差」という意味で現代のWebページやスマホのスクロールコンテンツと相性が良く、個人的に好きな表現方法です。
過去にはこんなものをAndroidで作っていたりします。

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にしています)

pager view

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
pager view ratio
  • ページ1とページ2が半分ずつ表示されている時
ページ1ページ2
0.5-0.5
pager view ratio
  • ページ2表示されている時
ページ1ページ2ページ3
-1.00.01.0
pager view ratio

オブジェクトを動かす

次にページの表示比率を使ってオブジェクトの動きを計算していきます。 比率を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等で行います。このページはContainerAlignmentで行っています。

ポイントは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,
  ),
),
walkthrough preview page1-2

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,
  ),
),
walkthrough preview page3-4

Indicator(おまけ)

Parallaxができたのでこれはおまけです。 よくWalkthroughにはページ数と現在の位置を示すために下の方に点々が置かれます。 ちゃんとした名前はおそらくないですが、Dot Indicatorと呼ばれることが多い気がします。

dots_indicator.dart
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内で値を更新します。

walkthrough_page.dart
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のスローガンです。いつもお世話になっています

© AAkira 2023