Flutterでもネイティブ体験を届けたい

こんにちは!エンジニアの藤井(touyou)です! この記事はGoodpatch Advent Calendar 2024の二日目の記事になります。

さっそくですがみなさんはFlutterというものについてどのくらいご存知でしょうか?

Flutterは2017年ごろにGoogleのチームによって開発された、クロスプラットフォーム向けのオープンソースSDKです。
その中でも初期からコア機能として実装されていたiOSとAndroidのアプリを単一コードベースで開発するという機能は、今でもFlutterユーザーの大半のユースケースの中心となっています。 世の中には他にも多くのクロスプラットフォーム/マルチプラットフォーム向けの開発ツールが存在しますが、Flutterはその開発難易度の低さとAndroidを開発しているGoogleが開発元ということからくる信頼感がその人気の土台となっている印象があります。
AIの台頭によりGoogle社内でFlutterチームが縮小されるなどのニュースもありましたが、一方でこの秋に有志のユーザーたちがFlutterの開発を加速させる目的でFlockというフォークプロジェクトを立ち上げるなど、まだまだその勢いは衰えていません。

弊社のデザインパートナー事業にもその波が届いてきており、Flutterで開発する案件の相談が最近増えてきています。
自分は特にこの一年間、Flutterの案件に従事していました。今回の記事はその中で工夫したことの紹介になります。

Flutterアプリがどうあるべきなのか?
デザイン的な観点でも参考になる話かと思うのでぜひ最後までお付き合いください。

0. Flutterの仕組み、そして隠された課題

本題に入る前に、Flutterの仕組みについてまずは紹介させていただきます。
クロスプラットフォーム、マルチプラットフォームの仕組みはさまざまにあるのですが、Flutterは全てがFlutterの独自レンダリングエンジン上でシミュレートされるような仕組みになっています。
もう少しイメージしやすい言い方にすると、それぞれのネイティブのUIパーツを呼び出しているのではなく、アプリ内に一個のFlutter世界をうつしだす「ディスプレイ」を置いてその中にUIを描いているという形で想像してみてください。
ただし通知やOS標準のセンサーやアラートなど「ディスプレイ」内では扱えないものもあり、それらをPlatform Channelsという仕組みを用いて呼び出せるようになっています。

こういった仕組みで動いている以上、単純なFlutterアプリというのはいわゆるSwiftやKotlinで記述するようなネイティブアプリとは少し体験の質が異なってきます。特にiOSに関しては、FlutterチームがHIGやiOSを観察し動きだけ再現しているものになるため、そのクオリティが確実に保証されているわけではありません。
そのため世の中の多くのFlutterアプリはどちらにも属さないようにプラットフォームの色を消すか、より再現度・クオリティの高くなるマテリアルデザインに統一してしまうというところが大半です。

ですが自分はiOSエンジニアとして、そしてデザイン会社のエンジニアとして、ネイティブな体験を諦めるというのは許せませんでした。

つまりこの後ご紹介するのは、自分なりにどうネイティブ体験に寄せていったのか、Flutterで作ってもネイティブアプリらしく振る舞わせるためにはどのようなことに気をつければ良いのかという観点での工夫になります。
実装時に勝手にチューニングすることでコミュニケーションコスト0で実現しているので、そういったこだわりを持ちたい同志の方はぜひ参考にしてみてください。

1. タップのインタラクションを切り替える

まず1個目はタップのインタラクションです。
Android、特にマテリアルデザインで主流なインタラクションがリップルエフェクトです。マテリアルデザインのアプリを作る上では必須のものではありますが、一方でFlutterのウィジェットを使うとほぼ全てにおいてリップルエフェクトがかかってしまい*1、iOSでもマテリアルデザインのような触り心地になってしまうというのがネイティブ化の壁の一つでした。

そこで自分はInkWellというウィジェットの挙動に着目しました。
まず最初に作ったのがタップ時のエフェクトを出すためのAdaptiveTapEffectというウィジェットです。

class AdaptiveTapEffect extends StatelessWidget {
  // ... プロパティの宣言やイニシャライザ

  @override
  Widget build(BuildContext context) {
    return Material(
      type: MaterialType.transparency,
      child: InkWell(
        onTap: onTap,
        onLongPress: onLongPress,
        borderRadius: borderRadius,
        // iOSの場合はsplashColorを透明にする
        splashColor: context.isIOS ? Colors.transparent : null,
        overlayColor: overlayColor != null
            ? WidgetStateProperty.resolveWith(
                (states) {
                  if (states.contains(WidgetState.pressed)) {
                    return overlayColor;
                  }
                  return Colors.transparent;
                },
              )
            : null,
        child: child,
      ),
    );
  }
}

こちらで囲むことによって任意のコンポーネントをタップ可能にし、Androidの時にはリップルエフェクト、iOSの時にはリップルエフェクトではない自然なフォーカスインタラクションがかかるという状態を実現できました。実際のアプリではリストの選択インタラクションなどに活用しています。

さらにここでの知見を応用して、ボタンについても自作しています。ボタンを自作した理由としてはリップルエフェクトに加えて押している間の色などがデザインのコンポーネントとして定義されていたからです。そのため以下のように渡すパラメータを調整しつつ定義しました。

class Button extends HookWidget {
  // ... プロパティ宣言やイニシャライザ

  Widget _solidButton({
    required BuildContext context,
    required ValueNotifier<bool> isTapped,
  }) {
    Color backgroundColor = isDisabled
        ? _disabledBackgroundColor(context)
        : _backgroundColor(context);
    // overlayと同じ色を指定するとリップル表現がなくなったように見えるため、
    // iOSの場合のみ_activeBackgroundColorを使用する
    Color activeBackgroundColor =
        context.isIOS ? _activeBackgroundColor(context) : backgroundColor;
    Color borderColor = isDisabled
        ? _disabledBackgroundColor(context)
        : isTapped.value
            ? _activeBorderColor(context)
            : _borderColor(context);

    return Material(
      type: MaterialType.transparency,
      child: InkWell(
        onTapDown: (_) => isTapped.value = true,
        onTapUp: (_) => isTapped.value = false,
        onTapCancel: () => isTapped.value = false,
        onTap: isDisabled ? null : onPressed,
        borderRadius: _borderRadius,
        // iOSの場合はリップルエフェクトを表示しない
        splashColor: context.isIOS ? Colors.transparent : null,
        overlayColor: WidgetStateProperty.resolveWith(
          (states) {
            if (states.contains(WidgetState.pressed) &&
                isDisabled == false &&
                !context.isIOS) {
              return _activeBackgroundColor(context);
            }
            // iOSの場合はリップルエフェクトを表示しない
            return Colors.transparent;
          },
        ),
        child: Ink(
          padding: _padding,
          decoration: BoxDecoration(
            color: isTapped.value && isDisabled != true
                ? activeBackgroundColor
                : backgroundColor,
            borderRadius: _borderRadius,
            border: Border.all(
              color: borderColor,
            ),
          ),
          child: _content(context, isTapped),
        ),
      ),
    );
  }

  // ... variantごとの色やウィジェットの定義
}

上記のようにInkウィジェットを併用することでより自由に色をカスタマイズしています。
実際の動作の様子はこちらです。

違いとして大きいわけではないので少しわかりにくいかもしれませんが、このように様々な色でリップルエフェクトがある状態・ない状態をプラットフォームごとに切り替えられるようになりました。

2. シェブロンを切り替える

つづいてはリストのシェブロンについてです。iOSではリストからのナビゲーションがHIGのリストとテーブルにもあるような階層型のナビゲーションになることが一般的であるため、その階層をもぐっていくことを表すためにシェブロン(>)がつくようになっています。
一方でAndroidの世界にはiOSのように遷移に関して階層型かどうかといった区別が存在しません。
それを象徴する挙動としてiOSの場合画面の端をスワイプすると左側のみ階層を一つ戻るアクションになるところ、Androidの場合左右どちらも前の画面を戻るのアクションになる、というものがあります。

このような慣習からiOSではシェブロンを出し、Androidではシェブロンを出さないというのが双方のユーザーにとって自然な挙動になります。こちらはとてもシンプルですが以下のようなコードで実現しました。

class SettingsListTile extends StatelessWidget {
  // ... プロパティの制限やイニシャライザ
  @override
  Widget build(BuildContext context) {
    return AdaptiveTapEffect(
      onTap: onTap,
      child: Padding(
        padding: EdgeInsets.symmetric(
          vertical: description?.isNotEmpty == true ? Spacing.lg : Spacing.xl,
          horizontal: Spacing.xl,
        ),
        child: HStack(
          spacing: Spacing.x3l,
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Expanded(
              // ...割愛
            ),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                if (trailing != null) trailing!,
                // アクションボタンの場合は遷移ではないのでシェブロンにしない
                // 遷移するボタンの場合はiOSのみシェブロンアイコンを出す
                if (context.isIOS && !isAction)
                  AppIcon(
                    icon: AppIcons.chevronRight,
                    color: context.theme.borderColors.primary,
                  )
              ],
            ),
          ],
        ),
      ),
    );
  }
}

これを活用すると以下のようにプラットフォームに応じて見た目が切り替わります。

OSごとにシェブロンの有無を出し分けている例
左がiOS、右がAndroid

細かいところですが、ネイティブな体験を志すにあたっては大事な箇所になります。

3. アイコンを切り替える

もう一点細かいところとして、アイコンをプラットフォームによって切り替えるということも行いました。
今回のアプリではアイコンもデザイナーがデザインしているため、以下のようなコードを用意して使う側は意識せずにアイコンが切り替わるようにしています。

enum AppIcons {
  // ... 割愛
  link,
  // ... 割愛
  more,
  // ... 割愛
  share,
  // ... 割愛
}

/// アイコンを表示するウィジェット
class AppIcon extends StatelessWidget {
  const AppIcon({
    super.key,
    required this.icon,
    required this.color,
    this.size = 24,
  });

  final AppIcons icon;
  final Color color;
  final double size;

  @override
  Widget build(BuildContext context) {
    return _iconSvg(context).svg(
      width: size,
      height: size,
    );
  }

  SvgGenImage _iconSvg(BuildContext context) {
    switch (icon) {
      // ... 割愛
      case AppIcons.link:
        return Assets.icons.link;
      // ... 割愛
      case AppIcons.more:
        return context.isIOS
            ? Assets.icons.dotsHorizontal
            : Assets.icons.dotsVertical;
      // ... 割愛
      case AppIcons.share:
        return context.isIOS ? Assets.icons.share : Assets.icons.shareAndroid;
      // ... 割愛
    }
  }
}

このアプリで切り替えたのはシェアのアイコンとメニューを表示する動線として使われる3点ドットのみですが、この他にもプラットフォームごとのアイコンというものはあります。

実はこちらがFlutterのIconコンポーネントでも用意されており、PlatformAdaptiveIconsというクラスのページに行くと様々用意されていることを確認できます。
1で紹介した動画の上部、戻るボタンをよく見ると次の画像のようにアイコンが切り替わっていますがこちらも標準で実装者が意識せず切り替わるものになっています*2

ナビゲーションバー/AppBarのアイコンが切り替わっている様子

ただしこれら標準の切り替え機能はIcon(Icons.adaptive.more)といったように.adaptiveを挟まなければ適用されないため、実装者によるブレをなくすように標準を使うにしても一個ラップしたウィジェットを用意してあげるといいかなと思います。

4. 下タブを切り替える

4つ目は下タブについてです。公式にもCupertinoTabBarというウィジェットがあるのですが、こちらは見た目がかなりレガシーになってしまう印象を持ったのでマテリアルデザインのウィジェットを改造する形で対応しました。
実際のコードはこちらです。

class AppGlobalNavigation extends HookConsumerWidget {
  // ... 割愛

  Widget _cupertinoTabBar({
    required BuildContext context,
    required Color backgroundColor,
    required Color selectedContentColor,
    required Color unselectedContentColor,
  }) {
    return Theme(
      // ここでリップルエフェクトを消している
      data: context.theme.copyWith(
        splashColor: Colors.transparent,
        highlightColor: Colors.transparent,
        hoverColor: Colors.transparent,
      ),
      // iOSではMaterial2のBottomNavigationBarを改造する
      child: BottomNavigationBar(
        currentIndex: navigationShell.currentIndex,
        backgroundColor: backgroundColor,
        type: BottomNavigationBarType.fixed,
        elevation: 0,
        selectedItemColor: selectedContentColor,
        unselectedItemColor: unselectedContentColor,
        selectedLabelStyle: context.theme.appTextTheme.character1.boldPro.copyWith(
          color: selectedContentColor,
        ),
        unselectedLabelStyle: context.theme.appTextTheme.character1.boldPro.copyWith(
          color: unselectedContentColor,
        ),
        items: [
          BottomNavigationBarItem(
            // アイコンが下に近すぎてしまうので調整
            icon: Padding(
              padding: EdgeInsets.only(bottom: Spacing.space2),
              child: _homeIcon(context),
            ),
            activeIcon: Padding(
              padding: EdgeInsets.only(bottom: Spacing.space2),
              child: _activeHomeIcon(context),
            ),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Padding(
              padding: EdgeInsets.only(bottom: Spacing.space2),
              child: _searchIcon(context),
            ),
            activeIcon: Padding(
              padding: EdgeInsets.only(bottom: Spacing.space2),
              child: _activeSearchIcon(context),
            ),
            label: 'Search',
          ),
        ],
        onTap: _onChange,
      ),
    );
  }

  Widget _material3NavigationBar({
    required BuildContext context,
    required Color backgroundColor,
    required Color selectedContentColor,
    required Color unselectedContentColor,
  }) {
    return
    Theme(
     // ラベルのフォントなどは直接いじれないのでテーマ経由で(全体のテーマに設定も可)
      data: context.theme.copyWith(
        navigationBarTheme: NavigationBarThemeData(
          labelTextStyle: WidgetStateTextStyle.resolveWith(
            (state) {
              if (state.contains(WidgetState.selected)) {
                return context.theme.appTextTheme.character1.boldPro.copyWith(
                  color: selectedContentColor,
                );
              }
              return context.theme.appTextTheme.character1.boldPro.copyWith(
                color: unselectedContentColor,
              );
            }
          )
        )
      ),
    // AndroidではMaterial 3のNavigationBarを用いる
    child: NavigationBar(
      selectedIndex: navigationShell.currentIndex,
      backgroundColor: backgroundColor,
      elevation: 0.0,
      surfaceTintColor: selectedContentColor,
      indicatorColor: selectedContentColor.withOpacity(0.1),
      overlayColor: WidgetStateProperty.resolveWith(
        (states) {
          if (states.contains(WidgetState.pressed)) {
            return selectedContentColor.withOpacity(0.1);
          }
          return Colors.transparent;
        },
      ),
      destinations: [
        NavigationDestination(
          icon: _homeIcon(context),
          selectedIcon: _activeHomeIcon(context),
          label: 'Home',
        ),
        NavigationDestination(
          icon: _searchIcon(context),
          selectedIcon: _activeSearchIcon(context),
          label: 'Search',
        ),
      ],
      onDestinationSelected: _onChange,
    ),);
  }
  // ... 割愛
}

このような調整を行うことで以下のような見た目が実現できます。

それぞれの下タブの見た目

今回は青のプライマリカラーで作りましたが可愛い下タブの出来上がりですね。

5. ローディング表示を切り替える

5つ目はローディング表示です。
こちらもプラットフォームごとに違うのでウィジェット内でCircularProgressIndicatorCupertinoActivityIndicatorを切り替える形で実装していました。

ただこれも公式で.adaptive()が用意されているためCircularProgressIndicator.adaptive()と呼び出しても同じことが実現できます。

アイコンと同様実装する人が意識して.adaptive()をつける必要があるのでどちらにせよ独自ウィジェットに囲ってしまうのがいいかもしれません。

6. 遷移を工夫する

6つ目は遷移に関してです。2でも触れた通りiOSとAndroidでは根本的に遷移の考え方が違い、FlutterではAndroidに寄った思想になるために工夫が必要です。
今回のアプリについては以下のようにgo_routerのカスタムトランジション機能とShellRouteの機能を使い擬似的にモーダルを再現しました。

Duration _bottomTransitionDuration = const Duration(milliseconds: 400);
Duration _bottomReverseTransitionDuration = const Duration(milliseconds: 300);

/// 下から上がってくるアニメーション
Widget _bottomSlideTransition(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  return SlideTransition(
    position: animation.drive(
      Tween<Offset>(
        begin: const Offset(0, 1),
        end: Offset.zero,
      ).chain(
        CurveTween(
          curve: animation.status == AnimationStatus.reverse
              ? Curves.easeInCubic
              : Curves.easeOutCubic,
        ),
      ),
    ),
    child: child,
  );
}

abstract class FullModalShellRouteData extends ShellRouteData {
  const FullModalShellRouteData();

  @override
  Page<void> pageBuilder(
    BuildContext context,
    GoRouterState state,
    Widget navigator,
  ) {
    return CustomTransitionPage<void>(
      key: state.pageKey,
      name: state.name ?? state.path,
      arguments: <String, String>{
        ...state.pathParameters,
        ...state.uri.queryParameters,
      },
      opaque: false,
      transitionDuration: _bottomTransitionDuration,
      reverseTransitionDuration: _bottomReverseTransitionDuration,
      // ここをtrueにすることでFlutter側の解釈的にもフルスクリーンダイアログになる
      fullscreenDialog: true,
      child: navigator,
      transitionsBuilder: _bottomSlideTransition,
    );
  }
}

あくまで動きがメインの対応ですがおおむねいい感じに動いています。下から出ること自体はAndroidでも不自然ではないためこのアニメーションは両方に適用しました。
ただし直近のiOSで使われているような前の画面が奥に下がってその上に上がってくるシート型のモーダルというのは試してみたところ挙動に課題があったため活用していません。動きを再現してくれている方はいるのでいつかうまく活用したいなという気持ちでいます。

7. 使うべきところはネイティブで

最後はPlatform Channelsで使う機能群です。今回のアプリではアラート、通知、シェア、画像選択、ローカルキャッシュ、パーミッション、ディープリンクあたりを活用しています。
利用ライブラリを貼っておきます。

pub.dev pub.dev pub.dev pub.dev pub.dev pub.dev pub.dev

どれもデファクトと言って差し支えないぐらいのライブラリなので、安心して使うことができるかと思います。

ライブラリ紹介だけでは味気ないので、最後に個人的に詰まったポイントを紹介します。
個人的に詰まったところとして印象深いのはflutter_platform_alertの使い方です。特に破壊的なアクションがプライマリアクションになるアラートの出し方がどうすればいいのかがわかりにくく苦労しました。結論、以下のように書きます。

FlutterPlatformAlert.showCustomAlert(
  windowTitle: '削除',
  text: '削除します。よろしいですか?',
  positiveButtonTitle: '削除',
  negativeButtonTitle: 'キャンセル',
  options: PlatformAlertOptions(
    ios: IosAlertOptions(
      positiveButtonStyle: IosButtonStyle.destructive,
      negativeButtonStyle: IosButtonStyle.cancel,
    ),
  ),
).then((result) {
  if (result == CustomButton.positiveButton) {
    Logger().d('削除しました');
  }
});

まずpositive/negativeというのがそれ自体の意味合いではなくボタンの位置に紐づいています。そのためプライマリのアクションはpositiveに、キャンセルなどはnegativeに置く必要があるというのが直感に反してわかりにくいポイントの一つでした。
またiOSのボタンの色を変えるためにはIosAlertOptionsというものを個別に指定する必要があります。上のコードのように指定するとpositiveのボタンがdestructiveに、negativeのボタンがcancelスタイルになります。 そして結果を受け取るにはthenを使って引数がCustomButton.positiveButtonに一致しているかを確認するという実装方法になります。

詰まるところはありつつこれで無事実装したいアラートが実現できたのですが、v0.6.1になってiOSのこのスタイルのマッピングが壊れてしまっているようで、このコードを書いてもボタンを赤くしたりができなくなってしまいました。こちらはこのissueで報告とPR作成をしたのでその動向をうかがっていきたいと思います。

振り返ってみて

ここまで7つの観点でFlutterアプリをネイティブアプリに近づける工夫を紹介してきました。
結果として社内の別のiOSエンジニアからも「今まで触ってきたFlutter製アプリの中で一番自然に使えた」といった評価をいただけたりしたので、この工夫はやって良かったなという実感があります。

一方で見方を変えると、これらの工夫は負債になってしまうのではないか、という見方もできなくはありません。
そのため今後はより負債が残りにくい形で、これらの実装が実現できるよう探求していきたいなと考えています。

そんな今後に向けて、最後に直近見かけたFlutterのテーマ・プラットフォーム適合に関する資料を紹介して終わろうと思います。
まず取り上げたいのは先日のFlutter Kaigi 2024ronnnnnさんから発表された「OS 標準のデザインシステムを超えて - より柔軟な Flutter テーマ管理」です。今回紹介したような工夫の実装に欠かせないFlutterのThemeの仕組みについて基本から丁寧に紹介していただいています。負債を残していかない実装の実現という点でも期待が高まるBlank Canvasについても軽く触れられていますのでぜひチェックしてみてください。すでにアーカイブ映像もYouTubeに上がっています。

speakerdeck.com

もう一つは公式ドキュメントに2023年ごろに公開されたAutomatic platform adaptations | Flutterという記事です。いくつかのウィジェットにすでに導入されている.adaptive()の紹介やFlutterにすでに取り入れられているOSごとの挙動切り替えに関する解説に加えて、自分が今回取り組んだようなプラットフォームに合わせた実装例も紹介されています。
実装当時この記事には辿り着けていなかったのですが、☂ Add UI adaptation guides for mobile platforms · Issue #8427 · flutter/websiteでコメントすることでこのページの拡充などが進んでいくとの記述もあったので、自分からも今後コメントしていこうかなと思いました。

以上、最後までお付き合いいただきありがとうございました。
ぜひこの記事をきっかけにFlutter製アプリのデザインはどうあるべきなのか?を一緒に考えられたら嬉しいです。


Goodpatchではデザイン好きなエンジニアの仲間を募集しています。
少しでもご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!

*1:厳密にはflutter/widget.dartやflutter/cupertino.dartなどのウィジェットを使うことでマテリアルデザインから外れることもできますが、ウィジェットの充実度や動作の安定性・クオリティなどの面からflutter/material.dartを利用する人が大半になっているというのが実態です。そのためリップルエフェクト以外もウィジェットのインタラクションは基本的にマテリアルデザインに準じるようになっています。

*2:ただしアイコンは変わってもリップルエフェクトが変わらないので、今回制作したアプリではボタンごと置き換えています