【Flutter】InkWellの波紋が表示されない・はみ出す原因と対策

対象者

  • UI/UXデザインに関心のあるFlutterアプリ開発者
  • インタラクティブな要素を実装の参考ほしい方
  • タッチエフェクトが出なかったり、欠けたりするのが気になりだしたワイ

はじめに

FlutterのUIデザインにおいて、インタラクティブな要素に対する視覚的フィードバックは非常に重要です。特に、タッチエフェクト(波紋エフェクトやハイライト)はユーザーの操作に直接的な反応を示し、より直感的なインターフェースを提供します。一般的に、InkWell ウィジェットを使用するだけでこれらのエフェクトが自動的に適用されますが、時には波紋が表示されなかったり、意図した範囲を超えて広がるなどの問題が生じることがあります。

この記事では、実際に私が経験したいくつかの問題と、それを解決するために試した方法を共有します。InkWell の波紋がうまく表示されない原因と対策、波紋がコンポーネントの境界を超えて拡がる問題の解決策など、具体的なテクニックを詳しく解説していきます。

Flutterを使ったアプリ開発でタッチエフェクトに関する問題に直面した場合、この記事が有用な参考資料となることを願っています。

仕組み

なんとなく、以下で理解してます。間違っているかもしれないので、正確には各資料の確認をお願いします。

Material Design の State layers

3: Container = Material ウィジェットの child
2: State layer =Material ウィジェットの表面
1: Content = Material ウィジェット

  • SacffoldウィジェットやCardウィジェットなど各ウィジェットがMaterialウィジェットを内部的に持っている

  • Materialウィジェットの上には状況に応じて表示が変わる「State layer」という階層がある。ここでタッチイベントや無効化があった場合、Materialウィジェットの表示が変わるようになっている

  • State layerの上には、Containerがある

  • Containerのベースが透明の場合は State layer が見えるので、タッチエフェクトが見える

    • Textウィジェット
    • 色の設定のないContainerウィジェット
  • Containerのベースが不透明の場合は State layer が隠れて、タッチエフェクトが見えない

    • Imageウィジェット
    • 色の設定のあるContainerウィジェット

対策

  • 色の設定があるウィジェットのchildにMaterialウィジェットを設定して、タッチエフェクトを表示させる
  • 色つきContainerウィジェットではなく、Inkウィジェットを使う
    • Container ウィジェットはサイズ、色、形状、ボックスデコレーションなどを指定でき、アプリケーションのUIコンポーネントを構築する際に広範に利用される
    • Ink ウィジェットは、InkWellInkResponse など、マテリアルデザインのインタラクティブなエフェクト(タッチ反応の波紋など)を描画するために特化されている。

具体例

では具体的に見ていきましょう。ご自分のソースでタッチエフェクトが表示されないときの参考にしてください(表示されないから、この記事を見ている?)

Container

良い例) 透明な背景のContainerにInkWellを使用

この例では、InkWellウィジェットがContainerウィジェットの中に配置されていますが、Containerには色の指定がありません(透明)。この設定により、InkWellのタッチエフェクト(波紋エフェクト)が見えるようになっています。InkWellはその親ウィジェットのマテリアルプロパティ上でタッチエフェクトを生成するため、背景が透明な場合はそのエフェクトが表示されます。

InkWell(
  onTap: () {},
  child: Container(
    padding: padding,
    child: Text('Tap me!'),
  ),
),

悪い例) 色指定のあるContainer内のInkWell

このケースでは、Containerに黄色の背景が設定されており、その中にInkWellが含まれています。Containerが不透明な背景色を持っているため、InkWellのタッチエフェクトは表示されません。
これは、InkWellが波紋を描画しようとするためですが、Matarialの上に不透明なContainerがあるためエフェクトが隠れてしまいます。

InkWell(
  onTap: () {},
  splashColor: Colors.red,
  child: Container(
    color: Colors.yellow,
    padding: padding,
    child: Text('No Effect'),
  ),
),
Container(
  color: Colors.yellow,
  padding: padding,
  child: InkWell(
    splashColor: Colors.red,
    child: Text('No Effect'),
  ),
),

良い例) Materialウィジェットを使用した場合

この構成では、Materialウィジェットが背景色として緑色を持ち、その中にInkWellが配置されています。Materialウィジェットは自身がインクエフェクト(波紋エフェクト)をサポートするための基盤を提供します。したがって、InkWellのタップ時に生成される波紋はMaterialウィジェットの範囲内で正しく表示されます.

Material(
  color: Colors.green,
  child: InkWell(
    onTap: () {},
    child: Container(
      padding: padding,
      child: Text('Use Material'),
    ),
  ),
),

良い例) Inkウィジェットを使用した場合

こちらでは、Inkウィジェットが背景色として黄色を持ち、その中にInkWellが配置されています。InkウィジェットはMaterialと同様にインクエフェクトをサポートしており、波紋を描画する際には、自身が持つ背景色の上に波紋が表示される。

InkWell(
  onTap: () {},
  child: Ink(
    color: Colors.yellow,
    padding: padding,
    child: Text('Use Ink'),
  ),
),

Card

Cardはそのままだとタッチエフェクトが角丸になりません。clipBehavior: Clip.hardEdgeの設定をすることで、角丸のタッチエフェクトになります。
「角丸をデフォルトにしろや」と思いますが、パフォーマンス的には角丸設定をしない方がよいので、デフォルトはパフォーマンス優先なのでしょう。(角丸にしたがるのは、日本人の無駄な完璧主義、、、)

Card(
    clipBehavior: Clip.hardEdge,
    child: InkWell(
      onTap: () {},
      child: ListTile(
        title: Text('hardEdge'),
      ),
    ),
),

Image

画像をタップするユースケースは基本です。そのままだとタッチエフェクトが表示されませんので、一工夫しましょう。表示できる方法はいくつかあげましたので、ベターを選んでください。または、ご自分のベストを見つけたら、教えてください!

悪い例) ImageウィジェットにInkWellを適用

このケースでは、Image.network ウィジェットに直接 InkWell を適用しています。Flutterでは、Image ウィジェットは Material ウィジェットを含んでません。、そのため InkWell の波紋エフェクトは表示されません。

InkWell(
  onTap: () => print('Tap image'),
  child: Image.network(
    kUrl,
    width: 150,
  ),
),

良い例) Ink.imageを用いてInkWellを適用した場合

この構成では、Ink.image ウィジェットが画像を背景とし、その上に InkWell を配置しています。Ink.image ウィジェットは、InkWell やその他のインタラクティブなエフェクトを画像の直上に表示するための特別なウィジェットです。これにより、ユーザーが画像をタップした際には、その場所に直接波紋エフェクトが描画されるため、タッチに対する視覚的なフィードバックが得られます。

画像に対してタッチエフェクトを付けるという意味では一番シンプルで、良い構成だと思われます。しかし、Ink.image ウィジェットはborderRadius のようなプロパティを適用することができず、画像の角を丸くすることはできません。これはデザインの柔軟性を制限する可能性があり、特定のUIデザイン要件に合わせることが難しくなります。

Ink.image(
  fit: BoxFit.fill,
  image: NetworkImage(kUrl),
  height: 100.0,
  width: 120,
  child: InkWell(
    onTap: () {},
  ),
),

良い例) Containerの中にMaterialとInkWellを使用した場合

ここでは、Container ウィジェット内に Material を配置し、その上に InkWell を適用しています。Material ウィジェットが透明なため(デフォルトでは色がある)、InkWell の波紋エフェクトはこの Material 表面で正常に表示されます。。

Container(
  height: 100,
  width: 120,
  decoration: BoxDecoration(
    color: Colors.grey[800],
    borderRadius: BorderRadius.all(Radius.circular(16)),
    image: DecorationImage(
      image: NetworkImage(kUrl),
      fit: BoxFit.cover,
    ),
  ),
  child: Material(
    color: Colors.transparent,
    child: InkWell(
      borderRadius: BorderRadius.all(Radius.circular(16)),
      splashColor: Colors.blue.withOpacity(0.5),
      onTap: () {
        print('Image tapped!');
      },
    ),
  )
),

良い例) Inkウィジェットを使用してInkWellを適用した場合

Ink ウィジェットは、画像や色などの装飾とともに波紋エフェクトをサポートしています。このケースでは、Inkdecoration プロパティを使用して背景画像を設定し、その上に InkWell を配置しています。Ink ウィジェットが Material ウィジェットの機能を内包しているため、波紋エフェクトが正しく表示されます。

Ink(
  height: 100,
  width: 120,
  decoration: BoxDecoration(
    color: Colors.grey[800],
    borderRadius: BorderRadius.all(Radius.circular(16)),
    image: DecorationImage(
      image: NetworkImage(kUrl),
      fit: BoxFit.cover,
    ),
  ),
  child: InkWell(
    borderRadius: BorderRadius.all(Radius.circular(16)),
    splashColor: Colors.blue.withOpacity(0.5),
    onTap: () {
      print('Image tapped!');
    },
  )),

これらの例から、InkWell の波紋エフェクトを適切に表示するためには、MaterialInk ウィジェットと組み合わせることが重要であることがわかります。これにより、タッチエフェクトを効果的にユーザーに提供することが可能となります

TabBar

以下のブログ記事を参考にしてください。ここを起点にして、タッチエフェクトの沼にはまりました。

【Flutter】TabBarカスタマイズでUIをもっと魅力的に

InkResponse について

InkWellとの違い

InkWellはタップ時に水面の波紋効果を示すことで、ユーザーにフィードバックを直感的に提供します。一方、InkResponseはこの波紋効果の範囲や形状を柔軟に調整でき、例えば円形や角が丸い矩形など、特定のデザイン要件に合わせてフィードバックのスタイルを変更することが可能です。

InkResponseの挙動

InkResponseはタッチフィードバックに利用されるウィジェットで、ユーザーの操作に対して視覚的なエフェクトを提供します。このウィジェットには多くのカスタマイズオプションがあります。

highlightShape の設定

InkResponsehighlightShapeプロパティは、ユーザーがウィジェットをタッチした際に表示されるハイライトの形状を定義します。この設定により、ハイライトのビジュアルスタイルが変更され、アプリのテーマやデザインに合わせたフィードバックを実現できます。

  • BoxShape.rectangle
    ハイライトは四角形の形状をしています。四角形や長方形のボタンなどに適しています。
    borderRadiusでハイライトの四角形の角丸を設定できます。

  • BoxShape.circle
    ハイライトは円形になります。これはデフォルトの設定です。
    この形状はアイコンボタンや任意の円形のインタラクティブエレメントに最適です。円形のハイライトは、タッチポイントを中心に均等に広がります。

containedInkWell の設定

containedInkWell プロパティは、波紋エフェクトがウィジェットの境界内に収まるかどうかを制御します。

  • true
    波紋エフェクトは InkResponse ウィジェットの形状に合わせてクリップされます。これにより、エフェクトはウィジェットの境界内に収まり、外部に拡散しないように制限されます。例えば、円形のボタンでこの設定を有効にすると、タップによる波紋はボタンの円形の境界内で完結し、外側には漏れ出しません。これは、デザインがクリーンで整った見た目を保つのに役立ちます。

  • false
    波紋エフェクトはInkResponseウィジェットの外形に制約されずに広がります。この挙動は、InkResponse ウィジェットが他の要素と組み合わされる場合や、ウィジェットが他のUIコンポーネントに重なっている場合に有用です。波紋が自由に拡散することで、インタラクションのフィードバックがよりダイナミックに感じられます。

その他のプロパティ

  • splashColor: タップ時に表示される波紋エフェクトの色を指定します。この色はウィジェットがタッチされたときの視覚的フィードバックに直接影響を与え、ユーザーに対する反応が明確に伝わりやすくなります。
  • highlightColor: タップを保持している間にウィジェット上に表示されるハイライトの色です。この色はユーザーがアクションを長押しした際のフィードバックを強化し、インタラクティブな要素がより目立つようにします。
  • radius: 波紋エフェクトの最大半径を指定します。このプロパティによって波紋が拡がる範囲が決定され、UI要素のサイズや配置に合わせて調整することができます。
  • onTap: タップアクションが発生したときに呼び出されるコールバック関数です。このプロパティを通じて、ユーザーのタップ操作に応じた具体的な処理を実行することが可能です。
  • onDoubleTap: ダブルタップアクションが発生したときに呼び出されるコールバック関数です。一度のタップではなく、連続して2回タップした場合に特定の動作を起こすために使用されます。
  • onLongPress: 長押しアクションが発生したときに呼び出されるコールバック関数です。ユーザーが要素を長く押した際に特別なメニューやアクションを提供するのに適しています。
  • onTapCancel: タップが開始された後、ユーザーがタッチを移動させるなどしてタップがキャンセルされたときに呼び出されるコールバック関数です。この関数を通じて、タップが完了しなかった場合のクリーンアップや状態のリセットを行うことができます。

特定のWidgetをタップしたくないとき

FlutterでUIを設計する際、しばしばユーザーが視覚的には区別されている要素に対して、同じアクションを期待する場合があります。例えば、情報アイコンと背景のコンテンツが重なっているとき、どちらをタップしても同じ反応を示すようにしたい場合です。このような設計要求を満たすために IgnorePointer ウィジェットが役立ちます。
以下の例では、メインのコンテンツの上に情報アイコンがありますが、アイコンへのタップもコンテンツへのタップとして扱われます。

Stack(
  children: <Widget>[
        Material(
          color: Colors.green,
          child: InkWell(
                onTap: () {},
                child: Container(
                  padding: EdgeInsets.all(32.0),
                  child: Text('Tap me!'),
                ),
          ),
        ),
        IgnorePointer(
          child: Icon(
                Icons.info_outline,
                size: 64,
          ),
        ),
  ],
),
  1. Material ウィジェット: このウィジェットは背景色として緑色を持ち、その中に InkWell ウィジェットが含まれています。InkWell はタップ時に波紋エフェクトを生成し、ユーザーのタップ操作に反応します。

  2. InkWell ウィジェット: タップ可能な領域を提供し、ユーザーがこの領域をタップすると、指定されたアクション(この例では何もしませんが、通常は何かの機能をトリガーします)が実行されます。

  3. IgnorePointer ウィジェット: このウィジェットは Icon を子として持ち、アイコンの上でのタップ操作を無視するように設定されています。これにより、アイコン上でも下層の InkWell が反応するようになり、どこをタップしても同じ動作がトリガーされます。

この技術を使うことで、アプリケーションのインタラクティブな要素を細かく制御でき、ユーザー体験を向上させることができます。特に複雑なUIデザインやインタラクティブな要素が多いアプリケーションで有効です。

Q&A

Q1: InkResponseInkWell、Inkの違いは何ですか?

A1: InkWell, InkResponse, そして Ink はFlutterでインタラクティブなUI要素を実現するためのウィジェットですが、それぞれ異なる特性と使用シナリオを持っています。

  • InkWell: これは最も一般的に使用されるウィジェットで、タップ時に水面の波紋効果を提供します。簡単に使用でき、タッチフィードバックを提供するためによく用いられます。

  • InkResponse: InkWellよりもカスタマイズ性が高いウィジェットです。InkWellと同様にタッチフィードバックを提供しますが、波紋の範囲や形状(円形、長方形など)、波紋の色など、より詳細な設定が可能です。大きなエリアや特定の形状にフィードバックを必要とする場合に便利です。

  • Ink: このウィジェットは、InkWellInkResponse と組み合わせて使用されることが多く、主にインクエフェクト(タッチ反応の波紋など)を描画するために設計されています。Ink ウィジェットは背景画像や色を持つことができ、その上に波紋効果を重ねることが可能です。特に画像やカスタムペイントを背景に持つ場合に使用されます。

Q2: なぜContainerウィジェットに色を設定するとInkWellのタッチエフェクトが表示されないのですか?

A2: Containerに色を設定すると、その下にある波紋エフェクトの発生元のMaterialの表示を遮ってしまうためです。
InkWellはその親ウィジェットのMaterialで波紋エフェクトを生成しますが、不透明な背景があるとエフェクトが隠れてしまいます。この問題を回避するには、MaterialInkウィジェットを使用して、波紋エフェクトをサポートする基盤を提供する必要があります。

Q3: Cardウィジェットの波紋がはみ出します。どうすればよいですか?

A3: デフォルトでは、Cardは波紋や影などをクリッピングしないため、特に角が丸いカードで波紋がはみ出ることがあります。
カードの波紋をカードの形状に合わせてクリップするには、CardのclipBehaviorプロパティをClip.antiAliasやClip.hardEdgeに設定します。これにより、波紋エフェクトがカードの境界内に制限され、見た目がより整った印象になります。

Q4: 画像にタッチイベントの波紋(Rippleエフェクト)を付けたいです。どうすれば良いですか

A4: こちらにサンプルソースをいくつか用意したので、一番良いのを選んでください!

まとめ

本記事では、InkWell、InkResponse、そしてInkについて解説する記事にするつもりでした。
しかしサンプルソースを作成していくと、ウィジェットの一般解説では役にたたず、実際の応用例をまとめた方が良いと取り組みました。Cardウィジェットの波紋がはみ出す問題や、色付きContainerや画像でInkWellの波紋が表示されない問題など、具体的な解決策を提供できました。

この情報が、Flutterを使用してより洗練されたインタラクティブなUIを構築する際の助けになることを願っています。
もし、さらに詳細なカスタマイズや特定の問題に関するアドバイスが必要な場合は、ぜひTwitter等でご連絡ください。共有された知識が他の開発者の支援にもつながるかもしれません。

参考

ソース(main.dartにコピペして動作確認用)

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  static const padding = EdgeInsets.symmetric(
    horizontal: 16,
    vertical: 16,
  );

  static const kUrl =
      'https://flutter.salon/wp-content/uploads/2022/11/IMGP0818-768x508.jpg';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Row(
              children: [
                InkWell(
                  onTap: () {},
                  child: Container(
                    padding: padding,
                    child: Text('Tap me!'),
                  ),
                ),
                InkWell(
                  onTap: () {},
                  splashColor: Colors.red,
                  child: Container(
                    color: Colors.yellow,
                    padding: padding,
                    child: Text('No Effect'),
                  ),
                ),
                Container(
                  color: Colors.yellow,
                  padding: padding,
                  child: InkWell(
                    splashColor: Colors.red,
                    child: Text('No Effect'),
                  ),
                ),
              ],
            ),
            Row(
              children: [
                Material(
                  color: Colors.green,
                  child: InkWell(
                    onTap: () {},
                    child: Container(
                      padding: padding,
                      child: Text('Use Material'),
                    ),
                  ),
                ),
                InkWell(
                  onTap: () {},
                  child: Ink(
                    color: Colors.yellow,
                    padding: padding,
                    child: Text('Use Ink'),
                  ),
                ),
              ],
            ),
            Row(
              children: [
                Expanded(
                  child: Card(
                    child: InkWell(
                      onTap: () {},
                      child: ListTile(
                        title: Text('default'),
                      ),
                    ),
                  ),
                ),
                Expanded(
                  child: Card(
                    clipBehavior: Clip.hardEdge,
                    child: InkWell(
                      onTap: () {},
                      child: ListTile(
                        title: Text('hardEdge'),
                      ),
                    ),
                  ),
                ),
              ],
            ),
            InkWell(
              onTap: () => print('Tap image'),
              child: Image.network(
                kUrl,
                width: 150,
              ),
            ),
            Row(
              children: [
                Ink.image(
                  fit: BoxFit.fill,
                  image: NetworkImage(kUrl),
                  height: 100.0,
                  width: 120,
                  child: InkWell(
                    onTap: () {},
                  ),
                ),
                Container(
                    height: 100,
                    width: 120,
                    decoration: BoxDecoration(
                      color: Colors.grey[800],
                      borderRadius: BorderRadius.all(Radius.circular(16)),
                      image: DecorationImage(
                        image: NetworkImage(kUrl),
                        fit: BoxFit.cover,
                      ),
                    ),
                    child: Material(
                      color: Colors.transparent,
                      child: InkWell(
                        borderRadius: BorderRadius.all(Radius.circular(16)),
                        splashColor: Colors.blue.withOpacity(0.5),
                        onTap: () {
                          print('Image tapped!');
                        },
                      ),
                    )),
                Ink(
                    height: 100,
                    width: 120,
                    decoration: BoxDecoration(
                      color: Colors.grey[800],
                      borderRadius: BorderRadius.all(Radius.circular(16)),
                      image: DecorationImage(
                        image: NetworkImage(kUrl),
                        fit: BoxFit.cover,
                      ),
                    ),
                    child: InkWell(
                      borderRadius: BorderRadius.all(Radius.circular(16)),
                      splashColor: Colors.blue.withOpacity(0.5),
                      onTap: () {
                        print('Image tapped!');
                      },
                    )),
              ],
            ),
            Row(
              children: [
                InkResponseContainer(
                  message: 'highlightShape rectangle',
                  borderRadius: BorderRadius.circular(8),
                  highlightShape: BoxShape.rectangle,
                  containedInkWell: true,
                ),
                InkResponseContainer(
                  message: 'highlightShape circle',
                  highlightShape: BoxShape.circle,
                  containedInkWell: true,
                ),
              ],
            ),
            const Row(
              children: [
                InkResponseContainer(
                  message: ' containedInkWell:false',
                  highlightShape: BoxShape.rectangle,
                  containedInkWell: false,
                ),
                InkResponseContainer(
                  message: ' containedInkWell:true',
                  highlightShape: BoxShape.rectangle,
                  containedInkWell: true,
                ),
              ],
            ),
            Container(
                width: 150,
                height: 50,
                decoration: const BoxDecoration(shape: BoxShape.rectangle),
                child: InkResponse(
                    onTap: () => print('InkResponse onTap'),
                    onDoubleTap: () => print('InkResponse onDoubleTap'),
                    onLongPress: () => print('InkResponse onLongPress'),
                    onTapCancel: () => print('InkResponse onTapCancel'),
                    splashColor: Colors.blue,
                    highlightColor: Colors.red,
                    radius: 8,
                    child: Center(child: Text('InkResponse')))),
            Row(
              children: [
                Stack(
                  children: <Widget>[
                    Material(
                      color: Colors.yellow,
                      child: InkWell(
                        onTap: () {},
                        child: Container(
                          padding: EdgeInsets.all(32.0),
                          child: Text('Cannot tap'),
                        ),
                      ),
                    ),
                    Icon(
                      Icons.info_outline,
                      size: 64,
                    ),
                  ],
                ),
                Stack(
                  children: <Widget>[
                    Material(
                      color: Colors.green,
                      child: InkWell(
                        onTap: () {},
                        child: Container(
                          padding: EdgeInsets.all(32.0),
                          child: Text('Tap me!'),
                        ),
                      ),
                    ),
                    IgnorePointer(
                      child: Icon(
                        Icons.info_outline,
                        size: 64,
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class InkResponseContainer extends StatelessWidget {
  const InkResponseContainer({
    super.key,
    required this.message,
    this.borderRadius,
    required this.highlightShape,
    required this.containedInkWell,
  });

  final String message;
  final BorderRadius? borderRadius;
  final BoxShape highlightShape;
  final bool containedInkWell;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 150,
      height: 50,
      decoration: const BoxDecoration(shape: BoxShape.rectangle),
      child: InkResponse(
          onTap: () => print("InkResponse[$message] tapped!"),
          splashColor: Colors.blue,
          borderRadius: borderRadius,
          highlightShape: highlightShape,
          containedInkWell: containedInkWell,
          child: Center(child: Text(message))),
    );
  }
}