【Flutter】パララックス効果の実装方法!手軽に奥行きのあるUIを作ろう!

対象者

  • Flutterでパララックス効果をしたい人

はじめに

Flutterでパララックス効果を使ってみたいと思っていました。
公式のサンプルを見ました。難しい。ちょー長い。諦めました。Flowとか頑張りたくない。
日本語でわかりやすそうな記事がありましたが、FlutterHooksを使っている。FlutterHooks、使わない。そのせいか、理解ができない。ただ、なんとなく実装の方向性は分かったので、参考にしてsetState版を作りました。

パララックス効果とは

背景と前景の速度や方向が異なることによって、奥行きのある視覚的効果を生み出すことです。例えば、移動する車の窓から外の景色を見ると、車の速度に応じて景色が流れるように見えます。
この効果は、Webデザインやモバイルアプリ、ビデオゲームなどのデジタルコンテンツにおいてよく使用されています。パララックス効果を使うことで、ユーザーはよりインタラクティブな体験をすることができ、より深い視覚的なインパクトを受けることができます。

前提条件

公式のパララックスのサンプルの画像を使います。これは、ほぼ正方形の画像です(一つ目を確認すると、1200×1224でした)。これを16:9の横長の窓から見て、画像の表示する場所をスクロールと一緒にずらしていきます。
表示する画像の全体

説明

どのように画像をずらすのか

Image.networkの引数alignmentでずらす方法を定義してます。設定すると、以下のように画像が移動されます。

  • -1であれば画像の一番上が窓の一番上
  • 1であれば画像の一番下が窓の一番下
Image.network(
    key: _keyList[index],
    urls[index],
    fit: BoxFit.fitWidth,
    alignment: Alignment(0, gap),
)

画像の画面上の位置を取得するため、keyでGlobalKeyを割り当ててます。
横長に表示するため、BoxFit.fitWidthを使ってます。
alignmentでgapを渡して、窓の中で動的に画像がずれるようにしてます。

ずらす距離を計算する

画像の画面のY座標を取得し、画面の高さで割って、画像が画面の一番上なら1,一番下なら-1になるように以下のように調整します。

final box = _keyList[index].currentContext?.findRenderObject();
final gap = box is RenderBox
    ? 2 * box.localToGlobal(Offset.zero).dy / screenHeight - 1
    : 0.0;

画面を更新する

スクロールしたら、画面が更新するように NotificationListener でsetStateしてます。

NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    setState(() {});

    return false;
},

まとめ

以上でパララックス効果の説明をしました。割とシンプルに収まったので、別のところでも十分流用できるソースになっているかと思います。公式のサンプルは本気すぎて、流用できそうもない、、、

参考

全ソース

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
      ),
      home: const MyHomePage(title: 'Flutter Parallax Demo Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _keyList = List.generate(urls.length, (index) => GlobalKey());

  @override
  Widget build(BuildContext context) {
    final screenHeight = MediaQuery.of(context).size.height;

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: NotificationListener<ScrollNotification>(
          onNotification: (notification) {
            setState(() {});

            return false;
          },
          child: ListView.builder(
              itemCount: urls.length,
              itemBuilder: (context, index) {
                final box = _keyList[index].currentContext?.findRenderObject();
                if (box is RenderBox) {
                  print('Image $index: ${box.localToGlobal(Offset.zero).dy}');
                }

                final gap = box is RenderBox
                    ? 2 * box.localToGlobal(Offset.zero).dy / screenHeight - 1
                    : 0.0;

                return Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: AspectRatio(
                    aspectRatio: 16 / 9,
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(16),
                      child: Image.network(
                        key: _keyList[index],
                        urls[index],
                        fit: BoxFit.fitWidth,
                        alignment: Alignment(0, gap),
                      ),
                    ),
                  ),
                );
              }),
        ),
      ),
    );
  }
}

const urlPrefix =
    'https://docs.flutter.dev/cookbook/img-files/effects/parallax';
final urls = [
  '$urlPrefix/01-mount-rushmore.jpg',
  '$urlPrefix/02-singapore.jpg',
  '$urlPrefix/03-machu-picchu.jpg',
  '$urlPrefix/04-vitznau.jpg',
  '$urlPrefix/05-bali.jpg',
  '$urlPrefix/06-mexico-city.jpg',
  '$urlPrefix/07-cairo.jpg',
];