Apple が WWDC25 にて Liquid Glass を発表しました。
これに対し「ネイティブの API を使わない Flutter は時代に置いていかれる」という声が散見されますが、以下の FAQ ページにも書かれている通り Flutter はそもそもネイティブの API に頼らずにプロジェクト・企業ごとのブランドデザインを柔軟に実現できることを優先した設計になっている ため、Flutter フレームワークとしての Liquid Glass の再現は「そもそも重要視していない」と考えるのが正確です。
Modern app design trends point towards designers and users wanting more motion-rich UIs and brand-first designs. In order to achieve that level of customized, beautiful design, Flutter is architectured to drive pixels instead of the built-in widgets.
スマートフォンも登場から20年近くが経過し、老若男女、十人十色さまざまなユーザーがさまざまな理由や用途でスマートフォンを利用します。提供者の目線でも「アプリ」で解決したい課題はさまざまに異なります。Apple の提案する最新のデザインは確かに素晴らしいものですが、それがすべてのユーザー・提供者にとって「最適」かというと話は別と考えられるでしょう。
「ユーザーがほしいもの」や「プロダクトとして与えたいもの」はプロジェクトごとに全く異なります。それを実現するために プラットフォームによるデザイン変更に影響されることなく独自のデザインを追求したいというニーズがあり、Flutter はそれに応えるためのフレームワークである 、というのが Flutter の方針です。
また後ろ向きな理由ではありますが、そもそも「デザイン」自体がプロダクトとして重要でない場合も少なからずあります。見た目にこだわって時間を費やすよりも、少しでも早く少しでも多くの人の課題を解決できるアイデアを世に出して検証したい、というニーズも確かにあります。
いずれにせよ、共通するのは「プラットフォームの都合に振り回されずにアプリを開発したい」という要求であり、それを満たすのが Flutter の独自レンダリングという仕組みです。
補足しておくと、Flutter にできない(やらない)のは「プラットフォーム標準の仕組みを完全再現した Widget を Flutter フレームワークとして提供すること」であり、それに限らなければ PlatformView を使ってネイティブコンポーネントそのものを UI に乗せたり、似た見た目や挙動の Widget をサードーパーティのパッケージとして(誰かが)作ったりと、ある程度の制限はあれど全く手も足も出ないということはないでしょう。そこで微妙な差異が出たとしても、必ずしも全てのユーザーがそこに違和感を持つわけでもなければ、それを理由にアプリをアンインストールするわけでもありません。
前置きが長くなりましたが、要するに Flutter アプリが最も輝くのは 特定のデザイン思想に依存せず、デザイナーのアイデアを忠実に実現したい 場合であると言えます。
しかしながら、Flutter アプリ開発でよく使われる MaterialApp
を含む material.dart
が提供する Widget には、マテリアルデザインを逸脱しないための固定値や決まりがあらかじめ実装されていて、それがデザイナーのアイデアの忠実な再現を妨げる場合が少なくありません。
そこでこの記事では、MaterialApp
を一度捨てて WidgetsApp
で自由なデザインを実現しようとしながら、その際にどのようなハードルが発生するのか、それをどのように乗り越えれば良いかを実験していきたいと思います。
まずは固定観念を取り払うために、最小限の Flutter アプリのコードを見てみましょう。
import 'package:flutter/widgets.dart';
void main() => runApp(
Center(child: Container(width: 50, height: 50, color: Color(0xff00ff00))),
);
このたった 5 行のコードは Flutter アプリとしてちゃんと実行可能です。黒い背景の中央に緑色の四角が表示されます。
これを見てわかるとおり、Flutter アプリを動作させるために MaterialApp
は必須ではありません。
runApp()
に Widget を渡している限り MaterialApp
がなくても UI は構築できますし、そこで StatefulWidget
や InheritedWidget
、または Riverpod などを使えば動きのあるアプリも作れるでしょう。
とはいえ実際の開発ではだいたいの場合で MaterialApp
を利用します。まずはなぜ MaterialApp
を使うのか、また MaterialApp
はどんな役割を担っているのかを整理してみましょう。
ドキュメントや material/app.dart
の実装を読みながら MaterialApp
の役割を整理すると、おおまかに以下のようなことをやっています。
- 端末設定を読み取って
MediaQuery
やDirectionality
、Localization
などを配置 -
SharedAppData
などのアプリ全体で利用可能な仕組みの準備 -
Navigator
の配置とルーティング設定 -
Theme
によるマテリアルコンポーネントやText
のスタイル設定 - (※6/17 追記)アクセシビリティの対応
- その他マテリアルコンポーネントを利用するための準備等
いずれもちゃんとしたアプリを作ろうと思ったら必要なものばかりですね。
Text
ひとつをとってもそれをどのような色やフォントでそれを表示するのか、LTR なのか RTL なのか、端末の文字拡大設定はどうか、など考慮しなければならない情報がたくさんあります。
それらの情報は DefaultTextStyle
等の InheritedWidget
を使って個々の Text
に届けられるのですが、MaterialApp
がなければそれらの Widget が配置されず、Text
を配置した瞬間エラーが発生してしまいます。
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞════════════════════════
The following assertion was thrown building Text("hello", dependencies: [MediaQuery]):
No Directionality widget found.
RichText widgets require a Directionality widget ancestor.
The specific widget that could not find a Directionality ancestor was
...以下略
なんだやっぱり MaterialApp
は必要なんじゃないかと思うかもしれませんが、実はこれらのほとんどは内部的には WidgetsApp
の仕事です。具体的には、
- 端末設定を読み取って
MediaQuery
やDirectionality
、Localization
などを配置 -
SharedAppData
などのアプリ全体で利用可能な仕組みの準備 -
Navigator
の配置とルーティング設定
これらは MaterialApp
が自身の build()
内で WidgetsApp
を配置することによって実現しているだけです。MaterialApp
自信の仕事は WidgetsApp
の上下に Theme
や HeroControllerScope
といったさまざまなマテリアルコンポーネントのための Widget を配置することです。
つまり、マテリアルデザインやそれに則った Widget を必要としない場合、 MaterialApp
を WidgetsApp
に置き換えることで、そして material.dart
ではなく widgets.dart
に用意された Widget を主に利用することで、マテリアルデザインの制約に縛られないデザインが実現できる というわけです(もちろんそれなりの工数は必要としますが)。
ではここからは、マテリアルデザインに頼らないアプリ開発を少しだけ体験してみましょう。
ひとつだけ注意ですが、「マテリアルデザインに頼らない」で作りますので、デザインの心得の無いいち開発者である自分が作ると「しょぼい」見た目になってしまうことが予想されます。「実際はちゃんとしたデザイナーが作ってくれたちゃんとしたデザインを忠実に再現することによって見た目が良くなるんだろうな」と想像しながら読んでいただければ幸いです。
main.dart
を作る
まずは main.dart
に WidgetsApp
を配置してみましょう。
main.dart
import 'package:flutter/widgets.dart';
void main() => runApp(const MainApp());
class MainApp extends StatelessWidget {
const MainApp({super.key});
Widget build(BuildContext context) =>
WidgetsApp(color: const Color(0xFF1E1E1E), home: Container());
}
WidgetsApp
に必須の引数は color
のみです。ここで指定した色は内部で Title
という Widget に渡され、Application Switcher などの各プラットフォーム固有の UI で使われたりするようです。widgets.dart
には AppBar
や FloatingActionButton
のようなデフォルト色に従う Widget は特にありませんので、ここに指定した色が何かの Widget に反映されることはありません。
初期表示される home
にはいったん Container
を置いておき、この状態でアプリを実行すると MaterialApp
と違ってエラーが発生します。
════════ Exception caught by widgets library ═══════════════════════════════════
The following assertion was thrown building MainApp(dirty):
If neither builder nor onGenerateRoute are provided, the pageRouteBuilder must be specified so that the default handler will know what kind of PageRoute transition to build.
'package:flutter/src/widgets/app.dart':
Failed assertion: line 406 pos 10: 'builder != null || onGenerateRoute != null || pageRouteBuilder != null'
エラー内容を読むとどうやらルーティングに関する設定が足りていないようです。builder
と onGenerateRoute
をセットで指定するか、pageRouteBuilder
を指定して初期画面を管理する PageRoute
を指定しなければならない、ということですね。
MaterialApp
のコードを読むとわかりますが、カウンターアプリなどのように単純に MaterialApp.home
で初期画面を指定した場合は内部で以下のような pageRouteBuilder
が WidgetsApp
に指定される形になっています。
material/app.dart
return WidgetsApp(
...省略
pageRouteBuilder: T>(RouteSettings settings, WidgetBuilder builder) {
return MaterialPageRouteT>(settings: settings, builder: builder);
},
home: widget.home,
...省略
);
そこで今回も同じように pageRouteBuilder
を指定したいと思うのですが、MaterialApp
が渡している MaterialPageRoute
は「iOS/Android プラットフォーム固有の画面遷移アニメーションを再現する」機能をもった PageRoute
で、これを使ってしまうと material.dart
や、内部的にはさらに cupertino.dart
にも依存することになってしまいます。
「画面遷移はプラットフォーム標準で良い」ということであればこれをそのまま真似していただければ良いですが、画面遷移アニメーション自体も独自で定義したい場合は以下の公式ドキュメントを参考に独自の PageRouteBuilder
を定義します。
main.dart
Widget build(BuildContext context) => WidgetsApp(
color: const Color(0xFF1E1E1E),
pageRouteBuilder:
T>(settings, builder) => PageRouteBuilder(
pageBuilder: (context, _, _) => builder(context),
transitionsBuilder: (_, _, _, child) => child,
),
home: Container(),
);
本来であればこの PageRouteBuilder
で画面遷移時のアニメーションを定義するわけですが、話を先に進めるためここでは単に home
で指定した Widget をそのまま表示するだけとしておきます。
ここまですればいったんエラーなくアプリを実行でき、真っ黒な画面が表示されるかと思います。これで最低限 WidgetsApp
が配置され、MediaQuery
や Navigator
などの仕組みが準備完了です。
一覧ページを作る
さて、ここではトップの一覧画面を作ってみましょう。今回は UserListPage
という名前でユーザー一覧ページっぽいものを作ってみます。
画面を作るとは言っても Scaffold
は使いません。あれはマテリアルデザインに準拠したレイアウト・デザインで AppBar
や FloatingActionButton
などを配置したい場合に使うもので、独自のデザインを実現するには邪魔になってしまいます。Material 3 や Expressive などの事情に付き合う工数が惜しい場合は使わない選択肢も検討できます。
代わりに背景色を ColoredBox
で設定しつつ、リスト UI は普段通り ListView
を使ってみます。
user_list_page.dart
class UserListPage extends StatelessWidget {
const UserListPage({super.key});
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFF1E1E1E),
child: ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return _ListTile(user: user);
},
),
);
}
}
ListView
とセットで使われることの多い ListTile
は material.dart
のものです。余白等のレイアウトの詳細は内部で ListTileTheme
によって指定され、その中身はマテリアル 3 を利用する設定かどうにで切り替わったりします。つまり、これもまたマテリアルデザインに依存する Widget であり、独自のデザインを実現する上で扱いづらいものであると言えます。
そこで、ここでは _ListTile
を自作します。
user_list_page.dart
class _ListTile extends StatelessWidget {
const _ListTile({required this.user});
final User user;
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFF333333), width: 1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
... 以下略
),
],
),
);
}
}
このようにすることで、以下のようなそれっぽいユーザー一覧画面が作れました。
今回は使っていませんが、細かい UI を作る上での注意点として Icons
(Icon
widget ではなく)や Colors
もマテリアルデザインに則ったアイコンや色の定義であるということが挙げられます。
もし独自のアイコンを配置したい場合は .ttf
ファイルに定義して IconData
で扱えるようにするか、SVG で作って flutter_svg で表示する方法などを検討する必要があります。色についても const Color(0xff000000)
の形式で自分で定義してください。
詳細ページを作る
さて、次は「タップしたら詳細画面に遷移する」という部分を作ってみましょう。
ここでも考えること、やることはいろいろあります。
- タップ操作のフィードバックを実装
- 画面遷移アニメーションの実装
- 「戻る」UI の実装
普段は InkWell
や MaterialPageRoute
、AppBar
といった material.dart
の Widget がよしなにやってくれる部分を自作する必要があります。具体的なエフェクトなどは実際はデザイナーと議論しながらということになりますので、ここではひとつの例として実装方法を確認していきたいと思います。
タップ操作のフィードバック
まずは先ほどの _ListTile
を「タップしたら画面遷移」できるようにするために GestureDetector
を配置します。ただし、GestureDetector
は InkWell
と違って押した際の UI 的なフィードバックはありません。つまり「押した感」は自作する必要があります。
方法はいろいろありそうですが、今回はシンプルに StatefulWidget
を使って「通常の状態」と「触っている状態」で UI を切り替えてみましょう。Animated
系の Widget(Implicit Animation として公式ドキュメントでは紹介されています)を使えばなめらかな切り替えも可能です。
user_list_page.dart
class _ListTile extends StatefulWidget {
const _ListTile({required this.user, required this.onTap});
final User user;
final VoidCallback onTap;
State_ListTile> createState() => _ListTileState();
}
class _ListTileState extends State_ListTile> {
bool isPressed = false;
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (details) => setState(() => isPressed = true),
onTapUp: (details) => setState(() => isPressed = false),
onTap: widget.onTap,
child: Container(
decoration: BoxDecoration(
color: isPressed ? const Color(0xFF111111) : const Color(0xFF000000),
),
child: AnimatedScale(
alignment: Alignment.centerLeft,
scale: isPressed ? 0.90 : 1,
duration: const Duration(milliseconds: 100),
curve: Curves.fastEaseInToSlowEaseOut,
child: Column(
... 以下略
この例はシンプルに背景色を変えつつ中身を少し縮めているだけですが、たとえばタップ中のアニメーションを作り込んだり widget.onTap
を呼び出すタイミングを「2秒以上押し続けたら」のように実装したりと応用することで、さまざまな操作感を自作で実現できそうです。
画面遷移アニメーション
画面遷移は通常と同じく Navigator.of(context).push()
で可能です。
ただし、普段 push()
に渡している MaterialPageRoute
は「プラットフォーム標準のアニメーションで遷移する」ためのクラスです。画面遷移もカスタマイズしようと思ったら PageRouteBuilder
を使ってアニメーションを自作する必要があります。
user_list_page.dart
return _ListTile(
user: user,
onTap: () {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder:
(context, animation, secondaryAnimation) =>
UserDetailPage(user: user),
transitionsBuilder:
(context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
},
);
pageBuilder
は次ページをあらわす Widget を生成するための関数です。MaterialPageRoute
では builder: (context) => UserDetailPage(user: user)
のように書くと思いますが、似たような役割と言えそうです。
transitionsBuilder
はさきほどの pageBuilder
で生成した Widget を child
として受け取り、アニメーションを実装するための関数です。
引数の animation
が「アニメーションの現在地」を 0.0
~ 1.0
の変化で教えてくれるので、これを Animated
系の Widget に渡したり、Curve
や Tween
と組み合わせることで独自のアニメーションを実現します。今回は FadeTransition
を使ってふわっと透過で切り替わる画面遷移を作ってみました。
animation
と secondaryAnimation
の違いなど、より詳しくは以下のドキュメントで説明されていますので、興味のある方は読んで試してみてください。
ただし「画面遷移はプラットフォーム標準が良い」という場合はここだけ MaterialPageRoute
を使うことも可能です。MaterialPageRoute
を使えばアニメーションだけでなく iPhone における「画面端からスワイプ」などの戻るジェスチャーも拾ってくれます。
縦にスライドして画面遷移したい、紙をめくるようなアニメーションを入れたい、など特別な表現を作り込みたい場合は上記の実装例を参考にし、そうで無い場合はいつも通り MaterialPageRoute
を使うような使い分けが検討できそうです。
「戻る」 UI の実装
先述の通り、MaterialPageRoute
を使わない場合はどこかに Navigator.of(context).pop()
を実行する仕組みが必要です。
今回は遷移した先の UserDetailPage
で画面のどこをタップしても元に戻るように作りました。
user_detail_page.dart
class UserDetailPage extends StatelessWidget {
const UserDetailPage({super.key, required this.user});
final User user;
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
height: double.infinity,
width: double.infinity,
... 以下略
とはいえこの辺りはユーザーが触り慣れている「戻る」ジェスチャーが Android, iOS ともあると思いますので、そこからはずれた操作を自然に取り入れるのはデザイン的にもなかなか難しそうに思います。
その意味でも、画面遷移については特にこだわりがない場合は無難に MaterialPageRoute
を使っておくのが良さそうです。
その他
画面遷移という点で補足すると、ダイアログを表示する showDialog
は material.dart
に用意された仕組みで、中では DialogTheme
などの MaterialApp
が用意するスタイルを UI に適用しています。
そのため、独自にデザインしたダイアログを表示したい場合は showGeneralDialog()
を利用してください。その際もダイアログの表示方法、アニメーションなどを定義する pageBuilder
が必要になります。
同様に、showBottomSheet
や showModalBottomSheet
などもマテリアルデザインを前提とした作りになっています。「下から出てくる UI」を実現したい場合はそのようなアニメーションを PageRouteBuilder
で定義して Navigator.of(context).push()
すると良いでしょう。内部実装を読めばわかりますが、 showDialog
や showModalBottomSheet
も結局中で呼び出しているのは Navigator.of(context).push()
ですので、同じことを独自アニメーションでやってあげれば良いでしょう。(ちなみに showBottomSheet
は Scaffold
が setState()
で画面下部に Widget を重ねて表示してくれているだけで、Navigator
は使っていないという違いがあります。)
「下から出てくる表現」という点では DraggableScrollableSheet
は特定のテーマを持たない widgets.dart
の Widget ですので、こちらも検討すると良さそうです。
背景色を共通化する
さて、ここまで UserListPage
と UserDetailPage
のどちらにも同じ背景色を指定しましたが、背景色やテキストスタイルなどのアプリ横断で指定するスタイルは共通化したいところです。
どこかに定数を定義して毎回呼び出す形式でも実現できるのですが、それでは「この Widget の中では色を変えたい」ような要件(FloatingActionButton
の下の Icon
は自動的に色が白くなる、みたいな要件)に柔軟に対応できません。
そこで、簡易的な PageTheme
クラスを InheritedWidget
を使って自作してみましょう。material.dart
が内部でやっていることを参考に作っていきます。
まずは各ページ共通の設定を保持する PageThemeData
クラスを定義します。
page_theme.dart
class PageThemeData {
const PageThemeData({required this.backgroundColor});
final Color backgroundColor;
}
次に、PageTheme
という名前で InheritedWidget
を作成します。InheritedWidget
は子孫の Widget の build()
内から O(1) で高速にアクセスできる仕組みを持つ Widget です。 ここに先ほど作った PageThemeData
を持たせてあげます。
page_theme.dart
class PageTheme extends InheritedWidget {
const PageTheme({super.key, required super.child, required this.data});
final PageThemeData data;
bool updateShouldNotify(PageTheme oldWidget) => data != oldWidget.data;
static PageThemeData of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactTypePageTheme>()!.data;
}
あとは、main.dart
で以下のように WidgetsApp
をラップしてあげましょう。
class MainApp extends StatelessWidget {
const MainApp({super.key});
Widget build(BuildContext context) => PageTheme(
data: const PageThemeData(backgroundColor: Color(0xFF1E1E1E)),
child: WidgetsApp(
... 以下略
),
);
}
このようにすることで、UserListPage
や UserDetailPage
では PageTheme.of(context).backgroundColor
でアプリ全体に設定された背景色を適用できるようになります。もし他にもいろいろと共通の画面設定があるのであれば、Scaffold
と同じ役割を担当する AppScaffold
のような Widget を自作して各ページで使い回すのも良いかもしれません。
他にはテキストサイズなども、デザインルールとしていくつかのサイズが定義されているのであれば、それを保持する AppTextTheme
クラスを同じ要領で自作すると良いでしょう。
これらの工夫は material.dart
の TextTheme
の焼き直しになってしまうようにも見えますが、「マテリアルデザインに定義された選択肢(bodyMedium
や displayLarge
など)にデザインを合わせる」のと「デザイナーが定義した選択肢をそのままコードで定義する」のではデザイン面・実装面での自由度が全く違うはずです。実装パターンは TextTheme
を参考にしながら、デザイナーの定義とコードをピタリ一致する実装を目指すと良いでしょう。
アクセシビリティに対応する
これは見落としがちな部分ですが(そして私も後から指摘されてここを追記していますが)、アプリは目に見える部分だけでなく「アクセシビリティ」も重要です。
// TODO(chooyan): 大事な部分ですので調べて追記します!少々お待ちください。
ここまで、MaterialApp
を使わずに独自のデザインを追求したい場合に実装がどうなるのか を簡単なアプリで実験してみました。実際にはここで触れていないような問題がまだまだ出てくることが予想されますが、なんとなくの走り出しのイメージはついたのではないかなと思います。
なお、Riverpod などの状態管理の仕組みやビジネスロジックなどは今回の試みには全く影響しません。状態管理は UI とは独立しているのが Flutter ですので、デザインの試行錯誤とは切り離して考えられるのが扱いやすいですね。
繰り返しですが、Flutter アプリ開発においてマテリアルデザインは必須ではありません。むしろ Flutter が本来想定している「ブランドごとに最適化されたデザイン」を実現する上で障害になる側面すらあるでしょう。
WidgetsApp
が単なる「内部で使われる Widget」ではなく、「独自デザインを実現したい場合に積極的に利用を検討するもの」と捉えることで、Flutter アプリの使い所がさらに広がるのでは無いかなと思います。
Views: 0