対象者
- 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
ウィジェットは、InkWell
やInkResponse
など、マテリアルデザインのインタラクティブなエフェクト(タッチ反応の波紋など)を描画するために特化されている。
具体例
では具体的に見ていきましょう。ご自分のソースでタッチエフェクトが表示されないときの参考にしてください(表示されないから、この記事を見ている?)
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
ウィジェットは、画像や色などの装飾とともに波紋エフェクトをサポートしています。このケースでは、Ink
の decoration
プロパティを使用して背景画像を設定し、その上に 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
の波紋エフェクトを適切に表示するためには、Material
や Ink
ウィジェットと組み合わせることが重要であることがわかります。これにより、タッチエフェクトを効果的にユーザーに提供することが可能となります
TabBar
以下のブログ記事を参考にしてください。ここを起点にして、タッチエフェクトの沼にはまりました。
InkResponse について
InkWellとの違い
InkWell
はタップ時に水面の波紋効果を示すことで、ユーザーにフィードバックを直感的に提供します。一方、InkResponse
はこの波紋効果の範囲や形状を柔軟に調整でき、例えば円形や角が丸い矩形など、特定のデザイン要件に合わせてフィードバックのスタイルを変更することが可能です。
InkResponseの挙動
InkResponse
はタッチフィードバックに利用されるウィジェットで、ユーザーの操作に対して視覚的なエフェクトを提供します。このウィジェットには多くのカスタマイズオプションがあります。
highlightShape の設定
InkResponse
のhighlightShape
プロパティは、ユーザーがウィジェットをタッチした際に表示されるハイライトの形状を定義します。この設定により、ハイライトのビジュアルスタイルが変更され、アプリのテーマやデザインに合わせたフィードバックを実現できます。
-
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,
),
),
],
),
-
Material
ウィジェット: このウィジェットは背景色として緑色を持ち、その中にInkWell
ウィジェットが含まれています。InkWell
はタップ時に波紋エフェクトを生成し、ユーザーのタップ操作に反応します。 -
InkWell
ウィジェット: タップ可能な領域を提供し、ユーザーがこの領域をタップすると、指定されたアクション(この例では何もしませんが、通常は何かの機能をトリガーします)が実行されます。 -
IgnorePointer
ウィジェット: このウィジェットはIcon
を子として持ち、アイコンの上でのタップ操作を無視するように設定されています。これにより、アイコン上でも下層のInkWell
が反応するようになり、どこをタップしても同じ動作がトリガーされます。
この技術を使うことで、アプリケーションのインタラクティブな要素を細かく制御でき、ユーザー体験を向上させることができます。特に複雑なUIデザインやインタラクティブな要素が多いアプリケーションで有効です。
Q&A
Q1: InkResponse
とInkWell
、Inkの違いは何ですか?
A1: InkWell
, InkResponse
, そして Ink
はFlutterでインタラクティブなUI要素を実現するためのウィジェットですが、それぞれ異なる特性と使用シナリオを持っています。
-
InkWell
: これは最も一般的に使用されるウィジェットで、タップ時に水面の波紋効果を提供します。簡単に使用でき、タッチフィードバックを提供するためによく用いられます。 -
InkResponse
:InkWell
よりもカスタマイズ性が高いウィジェットです。InkWell
と同様にタッチフィードバックを提供しますが、波紋の範囲や形状(円形、長方形など)、波紋の色など、より詳細な設定が可能です。大きなエリアや特定の形状にフィードバックを必要とする場合に便利です。 -
Ink
: このウィジェットは、InkWell
やInkResponse
と組み合わせて使用されることが多く、主にインクエフェクト(タッチ反応の波紋など)を描画するために設計されています。Ink
ウィジェットは背景画像や色を持つことができ、その上に波紋効果を重ねることが可能です。特に画像やカスタムペイントを背景に持つ場合に使用されます。
Q2: なぜContainer
ウィジェットに色を設定するとInkWell
のタッチエフェクトが表示されないのですか?
A2: Container
に色を設定すると、その下にある波紋エフェクトの発生元のMaterial
の表示を遮ってしまうためです。
InkWell
はその親ウィジェットのMaterial
で波紋エフェクトを生成しますが、不透明な背景があるとエフェクトが隠れてしまいます。この問題を回避するには、Material
やInk
ウィジェットを使用して、波紋エフェクトをサポートする基盤を提供する必要があります。
Q3: Card
ウィジェットの波紋がはみ出します。どうすればよいですか?
A3: デフォルトでは、Card
は波紋や影などをクリッピングしないため、特に角が丸いカードで波紋がはみ出ることがあります。
カードの波紋をカードの形状に合わせてクリップするには、CardのclipBehaviorプロパティをClip.antiAliasやClip.hardEdgeに設定します。これにより、波紋エフェクトがカードの境界内に制限され、見た目がより整った印象になります。
Q4: 画像にタッチイベントの波紋(Rippleエフェクト)を付けたいです。どうすれば良いですか
A4: こちらにサンプルソースをいくつか用意したので、一番良いのを選んでください!
まとめ
本記事では、InkWell、InkResponse、そしてInkについて解説する記事にするつもりでした。
しかしサンプルソースを作成していくと、ウィジェットの一般解説では役にたたず、実際の応用例をまとめた方が良いと取り組みました。Cardウィジェットの波紋がはみ出す問題や、色付きContainerや画像でInkWellの波紋が表示されない問題など、具体的な解決策を提供できました。
この情報が、Flutterを使用してより洗練されたインタラクティブなUIを構築する際の助けになることを願っています。
もし、さらに詳細なカスタマイズや特定の問題に関するアドバイスが必要な場合は、ぜひTwitter等でご連絡ください。共有された知識が他の開発者の支援にもつながるかもしれません。
参考
-
FlutterKaigi 2023 Add Material touch ripples by 株式会社ナビタイムジャパン / 松村 航裕
こちらの動画を見て、TabBarのタッチエフェクトが欠けていたことが気になりだし、沼にはまることになりました、、 -
Material 3 のState layers
ソース(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))),
);
}
}