水曜日, 6月 18, 2025
- Advertisment -
ホームニューステックニュースWidgetsApp で実現する自由なデザインの Flutter アプリ開発

WidgetsApp で実現する自由なデザインの Flutter アプリ開発


Apple が WWDC25 にて Liquid Glass を発表しました。

https://www.youtube.com/watch?v=jGztGfRujSE

これに対し「ネイティブの 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.

https://docs.flutter.dev/resources/faq#does-flutter-use-my-operating-systems-built-in-platform-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アプリのスクショ

これを見てわかるとおり、Flutter アプリを動作させるために MaterialApp は必須ではありません

runApp() に Widget を渡している限り MaterialApp がなくても UI は構築できますし、そこで StatefulWidgetInheritedWidget、または Riverpod などを使えば動きのあるアプリも作れるでしょう。

とはいえ実際の開発ではだいたいの場合で MaterialApp を利用します。まずはなぜ MaterialApp を使うのか、また MaterialApp はどんな役割を担っているのかを整理してみましょう。

ドキュメントや material/app.dart の実装を読みながら MaterialApp の役割を整理すると、おおまかに以下のようなことをやっています。

  • 端末設定を読み取って MediaQueryDirectionalityLocalization などを配置
  • 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 の仕事です。具体的には、

  • 端末設定を読み取って MediaQueryDirectionalityLocalization などを配置
  • SharedAppData などのアプリ全体で利用可能な仕組みの準備
  • Navigator の配置とルーティング設定

これらは MaterialApp が自身の build() 内で WidgetsApp を配置することによって実現しているだけです。MaterialApp 自信の仕事は WidgetsApp の上下に ThemeHeroControllerScope といったさまざまなマテリアルコンポーネントのための Widget を配置することです。

つまり、マテリアルデザインやそれに則った Widget を必要としない場合、 MaterialAppWidgetsApp に置き換えることで、そして material.dart ではなく widgets.dart に用意された Widget を主に利用することで、マテリアルデザインの制約に縛られないデザインが実現できる というわけです(もちろんそれなりの工数は必要としますが)。

ではここからは、マテリアルデザインに頼らないアプリ開発を少しだけ体験してみましょう。

ひとつだけ注意ですが、「マテリアルデザインに頼らない」で作りますので、デザインの心得の無いいち開発者である自分が作ると「しょぼい」見た目になってしまうことが予想されます。「実際はちゃんとしたデザイナーが作ってくれたちゃんとしたデザインを忠実に再現することによって見た目が良くなるんだろうな」と想像しながら読んでいただければ幸いです。

main.dart を作る

まずは main.dartWidgetsApp を配置してみましょう。

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 には AppBarFloatingActionButton のようなデフォルト色に従う 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'

エラー内容を読むとどうやらルーティングに関する設定が足りていないようです。builderonGenerateRoute をセットで指定するか、pageRouteBuilder を指定して初期画面を管理する PageRoute を指定しなければならない、ということですね。

MaterialApp のコードを読むとわかりますが、カウンターアプリなどのように単純に MaterialApp.home で初期画面を指定した場合は内部で以下のような pageRouteBuilderWidgetsApp に指定される形になっています。

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 を定義します。

https://docs.flutter.dev/cookbook/animation/page-route-animation

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 が配置され、MediaQueryNavigator などの仕組みが準備完了です。

一覧ページを作る

さて、ここではトップの一覧画面を作ってみましょう。今回は UserListPage という名前でユーザー一覧ページっぽいものを作ってみます。

画面を作るとは言っても Scaffold は使いません。あれはマテリアルデザインに準拠したレイアウト・デザインで AppBarFloatingActionButton などを配置したい場合に使うもので、独自のデザインを実現するには邪魔になってしまいます。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 とセットで使われることの多い ListTilematerial.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 を作る上での注意点として IconsIcon widget ではなく)や Colors もマテリアルデザインに則ったアイコンや色の定義であるということが挙げられます。

もし独自のアイコンを配置したい場合は .ttf ファイルに定義して IconData で扱えるようにするか、SVG で作って flutter_svg で表示する方法などを検討する必要があります。色についても const Color(0xff000000) の形式で自分で定義してください。

詳細ページを作る

さて、次は「タップしたら詳細画面に遷移する」という部分を作ってみましょう。

ここでも考えること、やることはいろいろあります。

  • タップ操作のフィードバックを実装
  • 画面遷移アニメーションの実装
  • 「戻る」UI の実装

普段は InkWellMaterialPageRouteAppBar といった material.dart の Widget がよしなにやってくれる部分を自作する必要があります。具体的なエフェクトなどは実際はデザイナーと議論しながらということになりますので、ここではひとつの例として実装方法を確認していきたいと思います。

タップ操作のフィードバック

まずは先ほどの _ListTile を「タップしたら画面遷移」できるようにするために GestureDetector を配置します。ただし、GestureDetectorInkWell と違って押した際の 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(
            ... 以下略

タップ時が伝わるUIの例

この例はシンプルに背景色を変えつつ中身を少し縮めているだけですが、たとえばタップ中のアニメーションを作り込んだり 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 に渡したり、CurveTween と組み合わせることで独自のアニメーションを実現します。今回は FadeTransition を使ってふわっと透過で切り替わる画面遷移を作ってみました。

画面遷移アニメーションを自作する例

animationsecondaryAnimation の違いなど、より詳しくは以下のドキュメントで説明されていますので、興味のある方は読んで試してみてください。

https://docs.flutter.dev/cookbook/animation/page-route-animation

ただし「画面遷移はプラットフォーム標準が良い」という場合はここだけ 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 を使っておくのが良さそうです。

その他

画面遷移という点で補足すると、ダイアログを表示する showDialogmaterial.dart に用意された仕組みで、中では DialogTheme などの MaterialApp が用意するスタイルを UI に適用しています。

そのため、独自にデザインしたダイアログを表示したい場合は showGeneralDialog() を利用してください。その際もダイアログの表示方法、アニメーションなどを定義する pageBuilder が必要になります。

同様に、showBottomSheetshowModalBottomSheet などもマテリアルデザインを前提とした作りになっています。「下から出てくる UI」を実現したい場合はそのようなアニメーションを PageRouteBuilder で定義して Navigator.of(context).push() すると良いでしょう。内部実装を読めばわかりますが、 showDialogshowModalBottomSheet も結局中で呼び出しているのは Navigator.of(context).push() ですので、同じことを独自アニメーションでやってあげれば良いでしょう。(ちなみに showBottomSheetScaffoldsetState() で画面下部に Widget を重ねて表示してくれているだけで、Navigator は使っていないという違いがあります。)

「下から出てくる表現」という点では DraggableScrollableSheet は特定のテーマを持たない widgets.dart の Widget ですので、こちらも検討すると良さそうです。

背景色を共通化する

さて、ここまで UserListPageUserDetailPage のどちらにも同じ背景色を指定しましたが、背景色やテキストスタイルなどのアプリ横断で指定するスタイルは共通化したいところです。

どこかに定数を定義して毎回呼び出す形式でも実現できるのですが、それでは「この 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(
      ... 以下略
    ),
  );
}

このようにすることで、UserListPageUserDetailPage では PageTheme.of(context).backgroundColor でアプリ全体に設定された背景色を適用できるようになります。もし他にもいろいろと共通の画面設定があるのであれば、Scaffold と同じ役割を担当する AppScaffold のような Widget を自作して各ページで使い回すのも良いかもしれません。

他にはテキストサイズなども、デザインルールとしていくつかのサイズが定義されているのであれば、それを保持する AppTextTheme クラスを同じ要領で自作すると良いでしょう。

これらの工夫は material.dartTextTheme の焼き直しになってしまうようにも見えますが、「マテリアルデザインに定義された選択肢(bodyMediumdisplayLarge など)にデザインを合わせる」のと「デザイナーが定義した選択肢をそのままコードで定義する」のではデザイン面・実装面での自由度が全く違うはずです。実装パターンは TextTheme を参考にしながら、デザイナーの定義とコードをピタリ一致する実装を目指すと良いでしょう。

アクセシビリティに対応する

これは見落としがちな部分ですが(そして私も後から指摘されてここを追記していますが)、アプリは目に見える部分だけでなく「アクセシビリティ」も重要です。

// TODO(chooyan): 大事な部分ですので調べて追記します!少々お待ちください。

ここまで、MaterialApp を使わずに独自のデザインを追求したい場合に実装がどうなるのか を簡単なアプリで実験してみました。実際にはここで触れていないような問題がまだまだ出てくることが予想されますが、なんとなくの走り出しのイメージはついたのではないかなと思います。

なお、Riverpod などの状態管理の仕組みやビジネスロジックなどは今回の試みには全く影響しません。状態管理は UI とは独立しているのが Flutter ですので、デザインの試行錯誤とは切り離して考えられるのが扱いやすいですね。

繰り返しですが、Flutter アプリ開発においてマテリアルデザインは必須ではありません。むしろ Flutter が本来想定している「ブランドごとに最適化されたデザイン」を実現する上で障害になる側面すらあるでしょう。

WidgetsApp が単なる「内部で使われる Widget」ではなく、「独自デザインを実現したい場合に積極的に利用を検討するもの」と捉えることで、Flutter アプリの使い所がさらに広がるのでは無いかなと思います。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -