魅せろ! Flutter で目を惹く UI デザインを実装する

ogp

はじめに

こんにちは、enechain でソフトウェアエンジニアをしている @tomohiko-tanihata です。

先日、国内最大級の Flutter カンファレンスである FlutterKaigi2023 が開催されました。私たち enechain は、モバイルアプリに Flutter を採用しており、昨年に引き続き、今年も FlutterKaigi の Silver スポンサーとして協賛しました!

チームのみんなでセッションに応募したところ、私のセッションが採択され、登壇する機会を得ました 🎉

セッションのタイトルは「魅せろ!Flutter で目を惹く UI デザインを実装する」で、目を惹く UI デザインを実現するための 4 つの方法について紹介しました。

当日の発表資料には GIF をたくさん貼っており、PDF で公開するのに不向きなスライドになっていたので、記事として FlutterKaigi2023 での私の登壇内容を詳しくまとめていきます。後日、YouTube での配信も予定されているので、実際の登壇の様子に興味のある方は FlutterKaigi の YouTube チャンネルをぜひチェックしてみてください!

目次

背景

今年は UI デザインに関するいくつかの大きな発表がありました。

  • Flutter 3.7 で Custom Fragment Shader をサポート
  • Google I/O 2023 での next-gen UI の発表
  • Flutter Forward での 3D オブジェクトの描画

このように Flutter で目を惹く UI デザインを実現する取り組みが加速しています。まずは UI デザインに関する技術になぜ私が興味を持ったのかについて説明したいと思います。

2022 年、Flutter 公式と Very Good Venture 社が共同で「FlutterPuzzle Hack」というハッカソンを開催しました。知人のデザイナーと 2 人で参加し、「JUST LOOK UP」というパズルゲームを作成して応募しました。満足できるレベルまでは作り込めたので、もしかしたら入賞するかもしれないという淡い期待を抱いて結果発表を待っていたのですが、結果は落選でした。少し審査員の目を疑っていたのですが、入賞作品を見るとその完成度の高さには明らかな差があり、落選は納得のいくものでした。入賞作品はアニメーションがふんだんに取り入れられ、圧倒的な世界観で引き込まれる魅力的なものばかりでした。その経験から、人を魅了する UI デザインの実装方法に深い興味を抱くようになりました。

本記事では、ユーザーの目を惹く UI デザインを実装するための 4 つの手法を紹介します。

  • RenderObject のカスタマイズ
  • Rive を用いた 2D アニメーション
  • Fragment Shader を活用したグラフィックスの描画
  • Impeller を利用した 3D オブジェクトの描画

1. RenderObject のカスタマイズ

前回の記事「RenderObject カスタマイズの勘所」を 15 分ほど話したので、こちらに説明を委ねます。まだ読んでない方はチェックしてみてください!

2. Rive を用いた 2D アニメーション

Rive はインタラクティブなグラフィックを実現するためのデザインプラットフォームで、簡単にリッチな 2D アニメーションが実現できます。 Flutter Puzzle Hack の際に Rive のデザイナーが Flutter の公式マスコットキャラクター Dash にアニメーションを付けて話題になっていました。

Lottie との比較

Lottie の大きな弱点として Adobe の After Effects がないとアニメーションが作れないということが挙げられますが、Rive は Editor によるデザインから Runtime による実行までサポートしており、Rive のエコシステムで全て完結するという優位性があります。 ただし、Lottielab のリリースによりこの点における優位性は下がったのですが、2023 年 10 月に Lottie の創設者 Hernan Torrisi 氏の Rive への加入が発表されたり Bevy Engine のサポートを発表したりと破竹の勢いで伸びているように感じます。

より詳細な比較は公式がまとめているのでそちらをご参照ください。

Rive Editor

Rive Editor はデザインとアニメーションを作成するための GUI ツールです。Design モードでは Figma のような操作感で Node を使って構造化していきながらグラフィックを作成することが可能です。Animation モードでは Node に対して細かにアニメーションを付与していくことができます。

作成したアニメーションは.riv 形式でダウンロードします。 ここで注意が必要なのが.riv 形式でしかダウンロード出来ない点です。 svg や png でのエクスポートに対応していないため、画像形式で使う予定がある場合は Figma などでグラフィックを作成してからそれを Rive に取り込むといいかもしれません。

Rive Runtime

作成したアニメーションをアプリに組み込むためのオープンソースのライブラリ群で、Flutter はもちろんのこと、Web、iOS、Android、macOS などといった様々なプラットフォームに対応しています。 Lottie と比較して軽量でパフォーマンスが良いと言われています。

Rive Editor を使って.riv 形式のアセットの用意をした後に、こちらの手順で Flutter でアニメーションを描画することが出来ます。

ステップ 1: Rive ライブラリをインストール

flutter pub add rive

ステップ 2: pubspec.yaml にアセット指定

flutter:
  assets:
    - assets/rive/

ステップ 3: Flutter 側からアセット呼び出す

RiveAnimation.asset(
  'assets/rive/meteo.riv',
  fit: BoxFit.contain,
),

試しに隕石と地球のアニメーションを作成して Flutter で描画させてみました。GIF だと伝わらないですが実際には滑らかにアニメーションするのでこんな簡単なアニメーションでも割と洗練された見た目になります。

Rive はいつ使うべきか

2D アニメーションを Flutter で実現する際は Rive が最も有力な候補になると思います。公式ドキュメントと公式 YouTube チャンネルが充実しているため、学習のハードルが低いと思います。 今回紹介しませんでしたが、State MachineManipulating Shapesなどの機能がものすごく使いやすく強力なのでアニメーションの定義の自由度が非常に高いです。Rive のシニアデザイナー drawsgood 氏が公開している作品は、どれも非常に洗練されたデザインで Rive の可能性を示してくれているのでぜひ見ていただきたいです。余談ですが、先日 X でペルソナ 5 のメニュー画面を Rive で完全再現している様子を公開されており Rive で出来ることの自由度の高さを改めて思い知らされました。

引用元: https://x.com/drawsgood/status/1712178192562155798?s=20

3. Fragment Shader を活用したグラフィックスの描画

Fragment Shader を使うと複雑なグラフィックスを描画することができます。 Flutter では 3.7 のリリースで Custom Fragment Shader のサポートが発表されました。

Fragment Shader とは各ピクセルの色や透明度を計算するための小さなスクリプトで、GPU で各ピクセルの rgba 値が並列で計算されます。 仮にピクセルの xy 値で rgba が決まるような場合だと [r, g, b, a] = F[x, y] という式で関係が表せますが、この関数 F こそが Fragment Shader です。

Flutter での使い方

実際に Flutter で使ってみましょう。

ステップ 1: GLSL ファイルの作成

まずは GLSL(OpenGL Shading Language)ファイルを作成します。これは C 言語がベースの言語になっています。

// GLSLのバージョン指定
#version 460 core

// Flutterが提供するコードをインポート
#include <flutter/runtime_effect.glsl>

// インプットを定義
uniform vec2 uResolution;

// アウトプットとなるカラーを定義
out vec4 fragColor;

void main() {
    vec2 uv = vec2(FlutterFragCoord().xy) / uResolution;

    fragColor = vec4(uv.x, uv.x, uv.x, 1.0);
}

Flutter から渡されるインプット uResolution は Fragment Shader を描画する Size の xy を要素に持つ vec2 になります。アウトプット fragColor はピクセルの rgba 値を要素に持つ vec4 になります。

ステップ 2: Flutter から GLSL ファイルを呼び出す

FragmentProgram を使って GLSL ファイルを呼び出します。

import 'dart:ui';

import 'package:flutter/material.dart';

// FragmentProgram APIを使用
late final FragmentProgram program;

Future<void> main() async {
  // アセットを読み込む
  program = await FragmentProgram.fromAsset('assets/shaders/sample.frag');
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CustomPaint(
        // Shaderを渡す
        painter: _Painter(program.fragmentShader()),
      ),
    );
  }
}

CustomPainterpaint メソッド内で Shader を描画します。

class _Painter extends CustomPainter {
  const _Painter(this.shader);

  final FragmentShader shader;

  @override
  void paint(Canvas canvas, Size size) {
    // GLSLファイルのuResolutionのxyに値を渡す
    shader
      ..setFloat(0, size.width)
      ..setFloat(1, size.height);
    canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

ここではライブラリに頼らない方法を説明しましたが、flutter_shaders を使うとより簡潔に記述できるのでチェックしてみてください。

サンプル 1: 線形グラデーション

左から右にかけて徐々に白くなる線形グラデーションの例を見ていきましょう。

#version 460 core

#include <flutter/runtime_effect.glsl>

uniform vec2 uResolution;

out vec4 fragColor;

void main() {
    vec2 uv = vec2(FlutterFragCoord().xy) / uResolution;

    fragColor = vec4(uv.x, uv.x, uv.x, 1.0);
}

1 行目で Flutter の座標系から uv に座標変換されます。正規化されているため、x と y はそれぞれ最小値 0 から最大値 1 にスケーリングされます。 その uv.x の値が rgb に入ってくるため uv.x が 0 に近い左側では黒くなり、1 に近い右側では白くなります。

サンプル 2: ネオンサイングラデーション

次にネオンサインのように内側と外側に向けて放射状にグラデーションする場合を見ていきましょう。

#version 460 core

#include <flutter/runtime_effect.glsl>

uniform vec2 uResolution;

out vec4 fragColor;

void main() {
    vec2 uv = vec2(FlutterFragCoord().xy) / uResolution;

    vec2 p = (uv * 2.0 - 1.0);

    float t = 0.02 / abs(0.5 - length(p));

    fragColor = vec4(vec3(t), 1.0);
}

1 行目は先程と同様に uv に座標変換されます。 2 行目でさらに p に座標変換されます。描画領域の中心を原点とする xy それぞれ、最小値-1、最大値+1 を取り得る座標系に変換されます。 3 行目で t が計算されますが特徴としては原点 p からの距離 length(p)

  • 0 付近では 0 に近い
  • 0.5 に近くなるにつれ無限大に発散する
  • 0.5 より十分大きくなるにつれ 0 に漸近する

となります。よってネオンサインのように内側と外側に向けて放射状にグラデーションします。

Fragment Shader はいつ使うべきか

数学的に表現しやすい形状やアニメーションを表現する場合は Fragment Shader を使用すると良いでしょう。 実はこちらのサンプル程度の簡単なグラデーションであればわざわざ GLSL ファイルを用意せずとも CustomPainterpaint メソッド内で直接 Shader を定義することで実現ができます。 しかし、ShaderToy にあるような凝った Shader を描画する際は GLSL ファイルなしでは実現できません。 もし興味があれば The Book of Shaders で Shader の世界に入門してみてください。最後に Flutter Web で Shader を読み込んで描画させてみたものを載せておきます。

引用元: https://www.shadertoy.com/view/Wt33Wf

4. Impeller を利用した 3D オブジェクトの描画

glb ファイルを直接読み込んで 3D モデルを表示できます。2023 年 11 月時点では experimental な機能になります。

Impeller とは Flutter の新しいレンダリングエンジンです。Shader compilation jank の解決策として開発が進められました。これまでのレンダリングエンジン Skia ではランタイムでシェーダーコンパイルしていたので初回描画時にカクツキが発生するという大きな課題がありました。Impeller ではビルド時にシェーダーコンパイルを実施することでこの課題を解消しています。公式 YouTube で詳しく説明されているので確認してみることをオススメします。

3D オブジェクトを描画するための手順

手順の詳細は https://github.com/flutter/flutter/wiki/Impeller-Scene を参照してください。 ざっくりいうと

  1. 最新の Flutter Engine をフラグ指定してビルド
  2. ビルドした Flutter Engine を使ってflutter run

の 2 ステップです。

サンプル: glb ファイル呼び出し

flutter_scene という experimental なライブラリを使って実際に 3D オブジェクトを描画してみました。 Scene ウィジェトの node にアセットを指定することで呼び出せるようです。

class DashWidget extends StatelessWidget {
  const DashWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Scene(node: Node.asset("models/dash.glb"));
  }
}

node に 27 匹の Dash を生成して実行してもパフォーマンス的には全然問題なさそうでした。

class DashWidget extends StatelessWidget {
  const DashWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Scene(node: manyNodes(Node.asset("models/dash.glb")));
  }
}

Node manyNodes(Node input) {
  return Node(children: [
    for (double x = -1; x <= 1; x++)
      for (double y = -1; y <= 1; y++)
        for (double z = -1; z <= 1; z++)
          Node(position: Vector3(x * 4, y * 4, z * 4), children: [input])
  ]);
}

本当は Dash を歩かせることも出来るはずなのですが、私の環境だと Dash の動きがカクつくという問題が発生したため Dash のアニメーションを停止した状態で描画させています。 もしかすると Flutter Engine のビルドが上手くいっていないのかもしれません。はやく stable な機能になってこのあたりの煩雑さを解消して欲しいと感じています。

3D オブジェクトの描画はいつ使うべきか

現時点では experimental な機能なので stable としてリリースされるのを待ちましょう。有効にするにはローカル環境で Flutter Engine をビルドする必要があります。公式でも将来的にサポートすると公言しているので非常に楽しみな機能です。

さいごに

本記事では、目を惹く UI デザインを実装する 4 つの手法を紹介しました。Blur やお気に入りボタンのアニメーションなど、使い方を工夫すればゲーム以外のアプリにも使える技術なので、ぜひぜひ今日紹介した手法をご自身のアプリに取り込んでいただけると嬉しいです。

そしてハッカソンなどのチャレンジしやすい機会があればぜひ活用していただき、イケてる UI を作ってみてください! ちなみに 2020 年に Flutter Clock Challenge、2022 年に Flutter Puzzle Hack と隔年で開催されているので、もしかすると来年 2024 年に Flutter 公式のハッカソンが開催されるかもしれないとひそかに期待しています。

enechain では、事業拡大のために共に UI にこだわりがある仲間を募集しています。

herp.careers