[Flutter] InkResponse と InkWell と Ink、違いを説明できますか？

[Flutter Advent Calendar#2](https://qiita.com/advent-calendar/2020/flutter-2) の11日目です。昨年は[Effective Dartまとめ](https://qiita.com/mkosuke/items/42c19d7edbf111f7fb71)で参加させていただきました。今年はMaterialウィジェットについて書こうと思いましたが、量が半端なくなりそうでしたので、掲題のようにターゲットを絞った内容にしてみました。

# `InkResponse`と`InkWell`と`Ink`

どれもタッチ時のsplash(Ripple)エフェクトを表現してくれるウィジェットで、FlutterのMaterial Designのアプリケーションには欠かせないものですが、名前だけでは違いはよくわかりません。本記事ではこれらの違いを詳しく紹介します。

まずは`InkResponse`と`InkWell`の比較、その後、`Ink`について紹介します。

また、ソースコード上ではタッチエフェクト(Ripple Effect)のことをsplashと表現しているため、本記事でもsplashと表現を統一するようにしています。

### 環境

Flutter 1.22.4
(2020/12/8時点のstable最新版)

## TL;DR,

<dl>
  <dt>InkResponse</dt>
    <dd>・タッチ時にウィジェット中央に円形のsplashが表示される</dd>

  <dt>InkWell</dt>
    <dd>・InkResponseの継承クラスで、ウィジェットに対してsplashを付けたい場合は基本InkWellの方を使うことが多い</dd>
    <dd>・splashは矩形で、タッチ座標がsplashの起点となる</dd> 

  <dt>Ink</dt>
    <dd>・Materialウィジェットとsplashを効かせたいウィジェットの間に、不透明なウィジェットがある場合に利用</dd>
    <dd>・InkResponseやInkWellと併用する</dd>
</dl>


# `InkResponse`と`InkWell`

まずは挙動をみてみます。

||InkResponse|InkWell|
|---|---|---|
||![inkresponse.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/ad827a7e-fc17-b44b-2f0d-d87872aa4439.gif)|![inkwell.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/19ecf381-2f3d-0c65-9bca-918b4f338fb1.gif)|
|概念図|![InkResponse](https://flutter.github.io/assets-for-api-docs/assets/material/ink_response_large.png)[公式Doc](https://api.flutter.dev/flutter/material/InkResponse-class.html)より|![InkWell](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png)[公式Doc](https://api.flutter.dev/flutter/material/InkWell-class.html)より|
|hover/press/focus時のhighlight|ウィジェット中央に円形|ウィジェット全体の矩形|
|press時のsplash|円形のままウィジェット中央へ移動|矩形の中を同心円状に広がる|


`InkResponse`は、ウィジェットの中央に円形のエフェクト(highlight)が表示されます。サンプルは矩形のボタンにしていますが、アイコンボタンなどが合っていると思います。一方、`InkWell`はボタンなどでよく見る矩形で一般的なMaterial Designのウィジェットのような挙動をしています。

ここでの用語として`highlight`と`splash`の違いは把握しておきましょう。highlightはhover/pressの瞬間に表示されるもので、splashはアニメーションを伴うエフェクト表示です。

## コードレベルの比較

実は、`InkWell`は`InkResponse`を継承しており、以下のようになっています。

```dart:ink_well.dart
class InkWell extends InkResponse {
  const InkWell({
    Key key,
    Widget child,
    GestureTapCallback onTap,
    GestureTapCallback onDoubleTap,
    GestureLongPressCallback onLongPress,
    GestureTapDownCallback onTapDown,
    GestureTapCancelCallback onTapCancel,
    ValueChanged<bool> onHighlightChanged,
    ValueChanged<bool> onHover,
    MouseCursor mouseCursor,
    (以下略)
  }) : super(
    key: key,
    child: child,
    onTap: onTap,
    onDoubleTap: onDoubleTap,
    onLongPress: onLongPress,
    onTapDown: onTapDown,
    onTapCancel: onTapCancel,
    onHighlightChanged: onHighlightChanged,
    onHover: onHover,
    mouseCursor: mouseCursor,
    // パラメータを固定しているのは以下の2項目のみ
    containedInkWell: true,
    highlightShape: BoxShape.rectangle,
    (以下略)
  );
}
```

（InkWell/InkResponseのソースコードは[こちら](https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/ink_well.dart)）

`InkWell`独自の実装は存在せず、コンストラクタで`containedInkWell`と`highlightShape`の2つのパラメータを固定しているだけに過ぎません。このパラメータについてみていきます。



### `containedInkWell`パラメータ

タッチ時のsplashが、ウィジェットの境界に合わせてclipするかどうかを示す、bool型のフラグです。
また、このフラグはsplashの挙動にも影響します。（press/focus/hover時のhighlightは、次のhighlightShapeパラメータで指定します）

<dl>
  <dt>false</dt>
    <dd>・InkResponseのデフォルト値</dd>
    <dd>・ウィジェットの境界を超えてsplashが広がる</dd>
    <dd>・splashはタッチ位置からスタートするが、splashが広がるにつれてウィジェットの中心へ移動</dd>
  <dt>true</dt>
    <dd>・InkWellではtrueが固定で使用されている</dd>
    <dd>・splashはウィジェットの境界を超えない(下記例のようにBoxDecorationなどで指定した角丸は考慮されない)</dd>
    <dd>・タッチ位置を中心にsplashが広がる</dd>
    <dd>・borderRadiusでsplashが広がる矩形の角丸を指定可能</dd>
</dl>

|`containedInkWell`の値|`borderRadius`の値|InkResponseの結果|
|---|---|---|
|false(デフォルト)|指定なし(0)|![contained_false.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/cd25f666-6865-a2e9-3827-f8f915a8093c.gif)* 矩形の角丸はradius=8です|
|true|指定なし(0)|![contained_true.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/69250b68-1991-b94a-d15e-241db304779c.gif)|
|true|20|![contained_true_r.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/129332eb-31c6-111b-e5cc-edd8fab4c187.gif)|





### `highlightShape`パラメータ

`BoxShape`型(enum)で、press/focus/hover状態の時のhighlight表示の形状を定義します。逆に、先程のタッチ時のsplashには影響しません。

<dl>
  <dt>BoxShape.circle</dt>
    <dd>・InkResponseのデフォルト値</dd>
    <dd>・highlightはウィジェットの中央に円形で表示される</dd>

  <dt>BoxShape.rectangle</dt>
    <dd>・InkWellではBoxShape.rectangleが固定で使用されている</dd>
    <dd>・highlightはウィジェットをfillするような矩形で表示される</dd>
    <dd>・borderRadiusでhighlight矩形の角丸を指定可能</dd>
</dl>

|`highlightShape`の値|`borderRadius`の値|InkResponseの結果|
|---|---|---|
|`BoxShape.circle`(デフォルト)|指定なし(0)|![shape_circle.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/358f7390-90db-b6ac-ab2a-51be5f175c9e.gif)* 矩形の角丸はradius=8です|
|`BoxShape.rectangle`|指定なし(0)|![shape_rect.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/926bad4f-cda5-d2e9-f2ad-4c06bc43a480.gif)|
|`BoxShape.rectangle`|24|![shape_rrect.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/4d223bf3-78da-e14f-8770-c49290b7bd6a.gif)|

### `containedInkWell`と`highlightShape`のまとめ

簡単にまとめるとこうなります。

||containedInkWell|highlightShape|
|---|---|---|
|影響を受けるもの|splashの形|highlightの形|
|InkResponseのデフォルト値|false(円状になる)|BoxShape.circle(円状になる)|
|InkWellの値|true(矩形になる)|BoxShape.rectangle(矩形になる)|

ちなみに、英語の`inkwell`(wは小文字でひとつの単語)は「インク壺」を示します
以下にCodepenのサンプルコードを示しておきます。


<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="kosuke_mtm" data-slug-hash="wvWVdov" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="wvWVdov">
  <span>See the Pen <a href="https://codepen.io/kosuke_mtm/pen/wvWVdov">
  wvWVdov</a> by m.kosuke (<a href="https://codepen.io/kosuke_mtm">@kosuke_mtm</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

## `InkResponse`の内部実装

`InkResponse`と`InkWell`の違いについては以上ですが、内部実装やその他のパラメータについてもいくつか紹介しておきます。

### `InkResponse`の中身

実は`InkResponse`の挙動は`_InkResponseStateWidget`→`_InkResponseState`へと移譲される形になっています。パラメータもほぼそのまま引き継がれています。

```dart
class InkResponse extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final _ParentInkResponseState parentState = _ParentInkResponseProvider.of(context);
    return _InkResponseStateWidget(...);  // InkResponseのパラメータをほぼそのまま引き継ぎます
  }
}

class _InkResponseStateWidget extends StatefulWidget {
  @override
  _InkResponseState createState() => _InkResponseState();
  ...
}

class _InkResponseState extends State<_InkResponseStateWidget>
    with AutomaticKeepAliveClientMixin<_InkResponseStateWidget>
    implements _ParentInkResponseState {
  (実装のメイン部分はここに集約されている)
}
```

以上を前提として、まだ触れていないパラメータについて紹介していきます。以降で紹介するコードは基本的に`_InkResponseState`の中の実装となります。



#### `hoverColor`/`splashColor`/`focusColor`と`overlayColor`パラメータ

`hoverColor`/`splashColor`/`focusColor`は名前そのままで以下の通りです。

|パラメータ|概要|
|---|---|
|hoverColor|デスクトップアプリなどでポインタがウィジェットの上に差し掛かった場合に使われるhighlightの色。nullの場合は`ThemeData.hoverColor`が使われる|
|splashColor|splashの色。nullの場合は`ThemeData.splashColor`が使われる|
|focusColor|ウィジェットにフォーカスが当たっている場合のhighlight色。nullの場合は`ThemeData.focusColor`が使われる|


一方、`overlayColor`は`MaterialStateProperty`型になっています。

```dart
final MaterialStateProperty<Color> overlayColor;
```

`MaterialStateProperty`型は、様々な状態におけるcolorをマッピングして保持しているオブジェクトだとイメージしていただければと思います。そのため、役割はほぼ同じです。`overlayColor`が便利となるのは、親ウィジェットから`MaterialStateProperty`を受け取る場合などです。

また、`overlayColor`が指定されている場合は、こちらが優先して使われます。


<details><summary>MaterialStatePropertyについて</summary><div>

`MaterialStateProperty`とは、`MaterialState`というenumをkeyに、colorをマッピングしたオブジェクトと考えて良いです（実際には内部で色々な制御が行われていますが、やっていることはHashMapに似ています）。`MaterialState`は以下のようなenumで多くの状態を持っていますが、`InkResponse`で使われるのは`hovered/focused/pressed`のみとなり、それ以外は使われません。


```dart:materia_state.dart
enum MaterialState {
  hovered,  /// The state when the user drags their mouse cursor over the given widget.
  focused,  /// The state when the user navigates with the keyboard to a given widget.
  pressed,  /// The state when the user is actively pressing down on the given widget.
  // 以降はInkResponseでは使われない
  dragged,  /// The state when this widget is being dragged from one place to another by the user.
  selected, /// The state when this item has been selected.
  disabled, /// The state when this widget disabled and can not be interacted with.
  error,    /// The state when the widget has entered some form of invalid state.
}
```

</div></details>


#### `highlightColor`パラメータ

`overlayColor`ではcolorが３つ含まれていますが、`highlightColor`は含まれていません。これだけは個別で指定する必要があります。理由としては、[ソースコードのコメント](https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/ink_well.dart#L490)にて以下のように説明されています。

> The [overlayColor] doesn't map a state to [highlightColor] because a separate highlight is not used by the current design guidelines.
> See https://material.io/design/interaction/states.html#pressed

Materialデザインのガイドラインでの扱いに依存しているとのことです。

#### `radius`パラメータ

splashやhighlightの円の半径(double型）です。`borderRadius`は`highlightShape`が.rectangleのときの角丸の指定ですので、混乱しないようにしましょう。

```dart:_InkResponseState
  // splashの半径に利用
  InteractiveInkFeature _createInkFeature(Offset globalPosition) {
    ...
    // radiusは直接使われず、splashFactoryの引数に使われる
    InteractiveInkFeature splash;
    splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create(
      ...
      radius: widget.radius,
    );
    return splash;
  }

  // highlightの半径に利用
  void updateHighlight(_HighlightType type, { @required bool value, bool callOnHover = true }) {
    ...
    _highlights[type] = InkHighlight(
      radius: widget.radius,
      ...
    );
    ...
  }
```

radiusは`_InkResponseState`の中では直接使われることはありません。そのまま、splashFactory(`InteractiveInkFeatureFactory`型）や`InkHighlight`の引数として使われます。続いて`InkHighlight`と`splashFactory`パラメータの説明をしていきます。


##### `InkHighlight`

pressed/hover/focus時のhighlight表示に使われます。

```dart:_InkResponseState

enum _HighlightType {
  pressed,
  hover,
  focus,
}

class _InkResponseState ... {
  void updateHighlight(_HighlightType type, { @required bool value, bool callOnHover = true }) {
    ...
    _highlights[type] = InkHighlight(
        controller: Material.of(context),
        referenceBox: referenceBox,
        color: getHighlightColorForType(type),
        shape: widget.highlightShape,
        radius: widget.radius,
        borderRadius: widget.borderRadius,
        customBorder: widget.customBorder,
        rectCallback: widget.getRectCallback(referenceBox),
        onRemoved: handleInkRemoval,
        textDirection: Directionality.of(context),
        fadeDuration: getFadeDurationForType(type),
      );
    ...
  }
```

`_HighlightType`というenumで定義された、pressed/hover/focusそれぞれの場合のhighlightが生成されて、フィールドの_highlightsに保持されているようです。pressed/hover/focusの`InkHighlight`における差分は、colorとfadeDurationです。この部分は本題と少しそれるため、折りたたみの中に記載します。

<details><summary>typeによるInkHighlightの差分</summary><div>

fadeDurationは簡単で、以下のような実装になっています。

```dart:_InkResponseState
Duration getFadeDurationForType(_HighlightType type) {
  switch (type) {
    case _HighlightType.pressed:
      return const Duration(milliseconds: 200);
    case _HighlightType.hover:
    case _HighlightType.focus:
      return const Duration(milliseconds: 50);
  }
  assert(false, 'Unhandled $_HighlightType $type');
  return null;
}
```

また、colorは以下のようになっています。

```dart:_InkResponseState
Color getHighlightColorForType(_HighlightType type) {
  const Set<MaterialState> focused = <MaterialState>{MaterialState.focused};
  const Set<MaterialState> hovered = <MaterialState>{MaterialState.hovered};
  switch (type) {
    case _HighlightType.pressed:
      // overlayColorは参照しない
      return widget.highlightColor ?? Theme.of(context).highlightColor;
    case _HighlightType.focus:
      return widget.overlayColor?.resolve(focused) ?? widget.focusColor ?? Theme.of(context).focusColor;
    case _HighlightType.hover:
      return widget.overlayColor?.resolve(hovered) ?? widget.hoverColor ?? Theme.of(context).hoverColor;
  }
  assert(false, 'Unhandled $_HighlightType $type');
  return null;
}
```

基本的に`InkResponse`の`highlightColor`,`focusColor`,`hoverColor`を使い、nullの場合はThemeから引っ張ってきています。が、focus/hoverの場合はその前に`overlayColor`を参照しています。（前述のとおり、`highlightColor`は`overlayColor`に含まれません）

ちなみに何も指定しない場合の色はThemeDataを見るとわかります。

```dart:theme_data.dart
const Color _kLightThemeHighlightColor = Color(0x66BCBCBC);
const Color _kDarkThemeHighlightColor = Color(0x40CCCCCC);

const Color _kLightThemeSplashColor = Color(0x66C8C8C8);
const Color _kDarkThemeSplashColor = Color(0x40CCCCCC);

factory ThemeData({
            ...
}) {
  hoverColor ??= isDark ? Colors.white.withOpacity(0.04) : Colors.black.withOpacity(0.04);
  focusColor ??= isDark ? Colors.white.withOpacity(0.12) : Colors.black.withOpacity(0.12);
  highlightColor ??= isDark ? _kDarkThemeHighlightColor : _kLightThemeHighlightColor;
  splashColor ??= isDark ? _kDarkThemeSplashColor : _kLightThemeSplashColor;
  ...
}
```

|LightTheme(背景が白)|DarkTheme(背景が黒)|
|---|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/a48d7d0a-9d65-af1f-0aca-52ea6ea4bc49.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/8c1a430a-9c9b-cc9a-223e-34d8d784fa8e.png)|

---

</div></detail>

`InkHighlight`の中で`radius`パラメータ(_radius)を使っている箇所は以下のとおりです。

```dart:ink_highlight.dart
class InkHighlight extends InteractiveInkFeature {
  ...
  void _paintHighlight(Canvas canvas, Rect rect, Paint paint) {
    ...
    switch (_shape) {
      case BoxShape.circle:
        canvas.drawCircle(rect.center, _radius ?? Material.defaultSplashRadius, paint);
        break;
      case BoxShape.rectangle:
        if (_borderRadius != BorderRadius.zero) {
          final RRect clipRRect = RRect.fromRectAndCorners(
            rect,
            topLeft: _borderRadius.topLeft, topRight: _borderRadius.topRight,
            bottomLeft: _borderRadius.bottomLeft, bottomRight: _borderRadius.bottomRight,
          );
          canvas.drawRRect(clipRRect, paint);
        } else {
          canvas.drawRect(rect, paint);
        }
        break;
    }
    canvas.restore();
  }
```

`InkResponse`の`highlightShape`(BoxShape型)を参照しており、`.circle`の場合は指定された`_radius`をそのまま使ってdrawCircleを行っています。nullの場合は`Material.defaultSplashRadius`(35.0で定義されている)を使っています。
一方、`.rectangle`の場合は矩形のため、`_radius`は使われません。代わりに`_borderRadius`の有無でdrawRect/drawRRectの使い分けが行われています。

highlightColorを赤でわかりやすくしてまとめました。

|highlightShape|radius|borderRadius|描画形状|
|---|---|---|---|
|.circle|null|(使用しない)|drawCircle(35.0)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/b99f8df5-302b-0b98-478d-1203120f6702.png)|
|.circle|指定あり|(使用しない)|drawCircle(radius)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/7cf47826-c25e-626e-889c-595c8b5c072c.png)|
|.rectangle|(使用しない)|null|drawRect()![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/b2d35b25-b9cf-deb4-6656-f586df4049ea.png)|
|.rectangle|(使用しない)|指定あり|drawRRect(borderRadius)![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/a5b18dbc-0a25-13f9-5100-2bc3c6ab2fca.png)|

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="kosuke_mtm" data-slug-hash="LYRRLOE" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="InkHighligh">
  <span>See the Pen <a href="https://codepen.io/kosuke_mtm/pen/LYRRLOE">
  InkHighligh</a> by m.kosuke (<a href="https://codepen.io/kosuke_mtm">@kosuke_mtm</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

##### `splashFactory`パラメータ

`splashFactory`は`InteractiveInkFeatureFactory`型です。定義は以下のとおりです。

```dart
abstract class InteractiveInkFeatureFactory {
  const InteractiveInkFeatureFactory();

  @factory
  InteractiveInkFeature create({
    @required MaterialInkController controller,
    @required RenderBox referenceBox,
    @required Offset position,
    @required Color color,
    @required TextDirection textDirection,
    bool containedInkWell = false,
    RectCallback rectCallback,
    BorderRadius borderRadius,
    ShapeBorder customBorder,
    double radius,
    VoidCallback onRemoved,
  });
}
```

このabstractクラスを継承したものとして、以下の２つが提供されています。

|InkSplash.splashFactory|InkRipple.splashFactory|
|---|---|
|デフォルトで使われる|デフォルトよりも外側へ広がっていく|
|![splash_splash.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/7332b608-f99a-14ed-7124-27cfb9add750.gif)|![splash_ripple.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/ebf807e0-27cb-2c8b-1afe-f2bf1ede176f.gif)|
|containedInkWellがfalseの場合は半径35.0固定、trueの場合は矩形全体がカバーできる半径を計算|ウィジェット全体が囲われるように広がる|

詳細はコードをみてもらった方がわかりやすいかと思います。順に説明していきます。

###### `InkSplash`

```dart:InkSplash.splashFactory
class _InkSplashFactory extends InteractiveInkFeatureFactory {
  const _InkSplashFactory();

  @override
  InteractiveInkFeature create({...}) {
    return InkSplash(...);
  }
}
class InkSplash extends InteractiveInkFeature {
  static const InteractiveInkFeatureFactory splashFactory = _InkSplashFactory();

  final double _targetRadius;
  Animation<double> _radius;  // _radiusはアニメーション
  AnimationController _radiusController;

  InkSplash({...
    double radius,
    bool containedInkWell = false,
  }) : ...
       // 引数の[radius]がnullの場合は自前で計算する
       _targetRadius = radius ?? _getTargetRadius(referenceBox, containedInkWell, rectCallback, position) {
    // 初期化処理。計算された[_targetRadius]を使ってsplashアニメーションを定義
    _radius = _radiusController.drive(Tween<double>(
      begin: _kSplashInitialSize,  // 0.0
      end: _targetRadius,
    ));
    ...
  }
  ... 
}

/// 引数で[radius]が指定されなかった場合は自前で計算し、[_targetRadius]に設定します
double _getTargetRadius(RenderBox referenceBox, bool containedInkWell, RectCallback rectCallback, Offset position) {
  if (containedInkWell) {
    final Size size = rectCallback != null ? rectCallback().size : referenceBox.size;
    return _getSplashRadiusForPositionInSize(size, position);
  }
  // 35.0で定義されている
  return Material.defaultSplashRadius;
}

/// タッチ位置とウィジェットの矩形から、タッチ位置を中心に円を描く際、矩形全体を覆うことのできる半径を計算しています
double _getSplashRadiusForPositionInSize(Size bounds, Offset position) {
  final double d1 = (position - bounds.topLeft(Offset.zero)).distance;
  final double d2 = (position - bounds.topRight(Offset.zero)).distance;
  final double d3 = (position - bounds.bottomLeft(Offset.zero)).distance;
  final double d4 = (position - bounds.bottomRight(Offset.zero)).distance;
  return math.max(math.max(d1, d2), math.max(d3, d4)).ceilToDouble();
}
```

言葉で並べると下記のようになります。

1. 引数でradiusが指定されなかった場合は_getTargetRadiusにて計算
 1. `containedInkWell`がtrueの場合は、タッチ位置とウィジェットの矩形から、タッチ位置を中心に円を描く際、矩形全体を覆うことのできる半径を計算(rectCallbackで矩形を指定することも可能)
 2. `containedInkWell`がfalseの場合は35.0の固定値が使用
2. 計算された`_targetRadius`を使ってAnimation(`_radius`)を生成
 1. beginは0.0が指定
 2. endに上で計算された`_targetRadius`が使用される

つまり、
- splashは半径0から指定半径まで広がる
- 半径の指定がない場合は半径35のサイズまで広がる
- ただし、`containedInkWell`=trueの場合は矩形全体が覆われるようにsplashが広がる
という挙動となります。

 
###### `InkRipple`

```dart:InkRipple.splashFactory
class _InkRippleFactory extends InteractiveInkFeatureFactory {
  const _InkRippleFactory();

  @override
  InteractiveInkFeature create({...}) {
    return InkRipple(...);
  }
}

class InkRipple extends InteractiveInkFeature {
  static const InteractiveInkFeatureFactory splashFactory = _InkRippleFactory();

  final double _targetRadius;
  Animation<double> _radius;  // _radiusはアニメーション
  AnimationController _radiusController;

  InkRipple({...
    double radius,
    bool containedInkWell = false,
  }) : ...
       _targetRadius = radius ?? _getTargetRadius(referenceBox, containedInkWell, rectCallback, position)
  {
    // 初期化処理。計算された[_targetRadius]を使ってsplashアニメーションを定義
    _radius = _radiusController.drive(
      Tween<double>(
        begin: _targetRadius * 0.30,
        end: _targetRadius + 5.0,
      ).chain(_easeCurveTween),
    );
    ...
  }
}

/// 矩形の対角線距離の半分を半径として算出しています。
/// [InkSplash]と同様の引数を受け取っていますが、containedInkWell、positionは使っていません。
double _getTargetRadius(RenderBox referenceBox, bool containedInkWell, RectCallback rectCallback, Offset position) {
  final Size size = rectCallback != null ? rectCallback().size : referenceBox.size;
  final double d1 = size.bottomRight(Offset.zero).distance;
  final double d2 = (size.topRight(Offset.zero) - size.bottomLeft(Offset.zero)).distance;
  return math.max(d1, d2) / 2.0;
}
```

流れはほぼ`InkSplash`と同じですね。

1. 引数でradiusが指定されなかった場合は_getTargetRadiusにて計算
 1. タッチ位置とウィジェットの矩形から、タッチ位置を中心に円を描く際、矩形全体を覆うことのできる半径を計算(rectCallbackで矩形を指定することも可能)
 2. `containedInkWell`は見ない
2. 計算された`_targetRadius`を使ってAnimation(`_radius`)を生成
 1. beginは`_targetRadius`の30%サイズ
 2. endは`_targetRadius`プラス5

つまり、必ず矩形全体を覆うサイズまでsplashが広がります。また、splashは矩形の一回り大きなサイズ(+5の分)にすることで見栄えがよくなっています。startはゼロではなく、30％のサイズから開始するのは、アニメーションの動きが大きくなりすぎることを避けるためでしょう。

###### `InkSplash`と`InkRipple`の比較

以上をgifにしてまとめてみました。

||InkSplash|InkRipple|
|---|---|---|
|デフォルト|![ink.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/69c74eec-53f6-0f85-6d36-225f3f2be9b9.gif)highlight/splashの半径は35.0|![ripple.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/edc814d5-a974-2c57-d027-52717043ce36.gif)highlightの半径は35.0、splashは矩形+5.0の円|
|radius=24指定|![ink_r.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/c751c247-b31c-5925-3092-edd2e1361ae8.gif)highlight/splashの半径は指定した24.0|![ripple_r.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/b8938954-1ca8-8c43-3dcd-009317129aac.gif)highlightは24.0、splashは24.0+5.0|
|containedInkWell=true|![ink_well.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/b07caad5-afec-e1de-a752-82a26abb7176.gif)highlightの半径は35.0、splashは矩形状|![ripple_well.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/b235c9db-1b12-245e-db0e-3bbe06f4c33b.gif)highlightの半径は35.0、splashは矩形状|
|radius指定/containedInkWell=true|![ink_r_well.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/6d58eb70-949f-78bc-ed21-7d18b712f124.gif)highlight/splashの半径は24.0|![ripple_r_well.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/a57909ea-216e-43a0-0ce8-46bc3dafc892.gif)highlightの半径は24.0、splashの半径は24+5|

ここまで読んできて頂いた方ならわかると思いますが`35.0`はコード上で定義されたデフォルトの固定値、`+5.0`の一回り大きくエフェクトを描画するための固定マージン値です。また、矩形の場合も実際にはウイジェット全体を囲うことのできる円の半径を計算して描画されていますが、矩形状にclipされているので円であることはわかりづらくなっています。

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="kosuke_mtm" data-slug-hash="qBaZMYY" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="InkSplash/InkRipple">
  <span>See the Pen <a href="https://codepen.io/kosuke_mtm/pen/qBaZMYY">
  InkSplash/InkRipple</a> by m.kosuke (<a href="https://codepen.io/kosuke_mtm">@kosuke_mtm</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>


---


# `Ink`

`InkResponse`と`InkWell`がタッチエフェクトを描画するウィジェットだったのに対し、`Ink`単体ではエフェクトの描画はできません。まず、splashの描画方法についてお話して、その上で`Ink`の役割について説明します。

## splashはどこで描画されるのか？

イメージ的には、それぞれのウィジェットの表面に描画されるように思われるかもしれません。しかし、実際はウィジェットツリーの親の`Material`ウィジェット上で描画されます。つまり、splashの描画面はZ軸方向では他のウィジェットの下に位置することになります。

そのため、`InkResponse`/`InkWell`は必ず`Material`ウィジェットの階層下に配置する必要があります。しかし、`InkResponse`/`InkWell`と`Material`の間に不透明なウィジェット（例えば色のついた`Container`や画像など）が存在すると、`Material`が隠れてしまい、その表面に展開されるsplashが見えなくなってしまいます。
実際にこの事象に悩まされる人は多いんじゃないかと思います。私も過去に悩まされたときに[記事](https://qiita.com/mkosuke/items/3e33d71ef4de74364f87)を書きました。

## `Ink`の使い方

そこで`Ink`の出番です。splashを阻害している不透明なウィジェットの『代わり』に`Ink`ウィジェットを配置して使います。
主な使い方は以下の３パターンです。

1. 背景色を`Material`とは異なる色に変更
2. `Decoration`を使って`Material`とは異なる背景に変更
3. 画像を背景に設定

順に説明していきます。

### (1)背景色を`Material`とは異なる色に変更

`Container`で背景色をつけている場合などに使えます。

```dart:Inkの使用例：(1)背景色を付けたい場合
Material(                   // 親にMaterialが必須
  color: Colors.blue,       // Material自体は青を指定
  child: Center(
    child: Ink(             // ここをContainerにするとsplashが効かないので、Inkに変える
      color: Colors.yellow, // 背景色はここで指定
      width: 200.0,
      height: 100.0,
      child: InkWell(
        onTap: () { },
        child: Center(child: Text('YELLOW'))
      ),
    ),
  ),
)
```

あくまで、『背景色をつけるために`Container`の代わりに`Ink`が使える』というだけで、『不透明なウィジェットがあってもInkで無効化できる』わけではないことに注意してください。（下記表参照）

|コード|実行結果|
|---|---|
|MaterialとInkWellの間に不透明なウィジェットがある場合![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/1df604e6-d47e-a543-527d-ae9ffb2ee763.png)|![ink_err.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/2d44702a-a95b-e74c-f35a-a8abe7b3ff36.gif)|
|Inkを使って背景色を変更![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/d1f9443d-d5eb-a5ef-70a9-edf669b022d8.png)|![ink_ok.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/97c0fbc4-23b4-2e07-d917-c3493f8f5fa2.gif)|
|Inkの誤った使い方![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/7aa33c05-6e91-c596-61ec-aade809f4835.png)|![ink_err2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/26237d4c-80f0-c202-54e9-ebff42af9cec.gif)|

*コードは一部省略しています

### (2)`Decoration`を使って`Material`とは異なる背景に変更

colorを使う場合は単色でしたが、グラデーションをかけたい場合などはこのDecorationを使うことになります。

```dart:Inkの使用例：(2)Decorationを使う場合
Material(
  color: Colors.blue,
  child: Ink(
    decoration: BoxDecoration(      // Decorationを使って背景を変更
      gradient: LinearGradient(
        colors: [Colors.green, Colors.greenAccent],
      ),
    ),
    width: 200.0,
    height: 50.0,
    child: InkWell(
        onTap: () {},
        child: Center(child: Text('Decoration'))
    ),
  ),
);
```

### (3)画像を背景に設定

```dart:Inkの使用例：(3)画像を使う場合
Material(
  color: Colors.grey[800],
  child: Center(
    child: Ink.image(      // Inkのfactoryで画像を指定するものがあります
      image: NetworkImage('https://......'),
      width: 300.0,
      height: 200.0,
      child: InkWell(
        onTap: () { },
        child: Center(child: Text('IMAGE')),
      ),
    ),
  ),
)
```


### (*4)`MaterialType.transparency`

`Ink`を使う方法以外にも、splashを効かせる方法があります。もうひとつ`Material`を置いてしまう方法です。
不透明なウィジェットの上に`Material`を配置し、その`Material`自体は透過にするため、`MaterialType.transparency`を指定することで実現できます。

```dart
Material(
  color: Colors.blue,
  child: Container(
          color: Colors.yellow,  // どうしても不透明なウィジェットがある場合
          width: 200.0,
          height: 50.0,
          child: Material(
            type: MaterialType.transparency,  // このMaterial自体は透過にする
            child: InkWell(
              onTap: () { },
              child: Center(child: Text('MaterialTypeを使う方法')),
            ),
          ),
        ),
)
```

ちなみに`MaterialType`はenumで、他に`canvas`/`card`/`circle`/`button`があります。

以上のことを確認できるサンプルコードを下記においておきます。

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="kosuke_mtm" data-slug-hash="ZEOgybR" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="How to use Ink">
  <span>See the Pen <a href="https://codepen.io/kosuke_mtm/pen/ZEOgybR">
  How to use Ink</a> by m.kosuke (<a href="https://codepen.io/kosuke_mtm">@kosuke_mtm</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

## `Ink`の実装内容

ここからは、気になる方だけ読んでいただければと思います。
まず`Ink`のコンストラクタは以下のようになっています。

```dart:ink_decoration.dart
class Ink extends StatefulWidget {
  Ink({
    Key key,
    this.padding,
    Color color,
    Decoration decoration,
    this.width,
    this.height,
    this.child,
  }) : assert(color == null || decoration == null,
         'Cannot provide both a color and a decoration\n'
         'The color argument is just a shorthand for "decoration: BoxDecoration(color: color)".'
       ),
       decoration = decoration ?? (color != null ? BoxDecoration(color: color) : null),
       super(key: key);

  final Decoration decoration;
```

assertの部分について、`assert(color == null || decoration == null`となっていて、colorまたはdecorationどちらか片方のみが定義されていることが必須となっています。また、color指定時も初期化処理の中でdecorationに変換されてフィールドで保持されるようになっています。

```dart
decoration = decoration ?? (color != null ? BoxDecoration(color: color) : null)
```

Imageを使ったfactoryでも同様にdecorationとして保持されています。

```dart
  Ink.image({
    Key key,
    this.padding,
    @required ImageProvider image,
    ImageErrorListener onImageError,
    ...
  }) : assert(image != null),
       decoration = BoxDecoration(
         image: DecorationImage(
           image: image,
           ...
         ),
       ),
       super(key: key);
```

このように初期化時に生成したdecorationは、splashを描画する`InkDecoration`に渡されることでsplashの背景として描画されます。

```dart:ink_decoration.dart
class Ink extends StatefulWidget {
  ...
  final Decoration decoration;
  ...
}

class _InkState extends State<Ink> {
  InkDecoration _ink;

  ...

  Widget _build(BuildContext context, BoxConstraints constraints) {
    if (_ink == null) {
      _ink = InkDecoration(
        decoration: widget.decoration,        // ここでdecorationが使われる
        controller: Material.of(context),
        referenceBox: context.findRenderObject() as RenderBox,
        onRemoved: _handleRemoved,
      );
    } else {
      _ink.decoration = widget.decoration;
    }
    ...
  }
}
```

# まとめ

名前のよく似た`Ink`/`InkResponse`/`InkWell`の紹介をしました。
長々と難しく書いてしまいましたが、乱暴な言い方をすると、Ripple Effectを使いたい場合は`InkWell`を使い、`InkWell`のsplashが表示されない場合は`Ink`の使用を検討する、とまずは覚えていただければ良いのではないでしょうか。まずは一度使った上で読み直していただけると理解が深まるかと思います。

twitterもよろしければフォローお願いします。
[@kosuke_mtm](https://twitter.com/kosuke_mtm)