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と呼ばれることが多い気がします。

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

© AAkira 2023