[Flutter] 続・スポイトツールを作る

# 概要

[[Flutter] スポイトツールを作る](https://qiita.com/satoshii/items/76f1a91ab72b6822309c) の続きです。

<div><img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141950/5008bfcb-3e7a-ba0b-5232-02195aafcba5.png"></div>

前回は単純にタップした場所の色を抽出するだけでしたが、折角なので以下のような改修を行いました。

- 選択部分を拡大表示する
- 拡大表示部内をドラッグした場合はドラッグ開始位置に移動するのではなく拡大表示部分を移動する
- GIFのアニメーション表示が有効になっていたのを最初のフレーム固定で表示するよう修正
- コードが増えてきたのでファイルを分割
- imageパッケージへの依存を解消

二番目は言葉で説明すると分かりづらいですが、スマートデバイスで選択箇所が自分の指に隠れてしまうことへの対策です。

コード全体はGitHubにあります。

- <del>[eye_dropper - GitHub](https://github.com/satoshiiorg/eye_dropper)</del>
- [simple_eye_dropper - GitHub](https://github.com/satoshiiorg/simple_eye_dropper)

**追記:**
折角なのでパッケージ化したものを [simple_eye_dropper](https://pub.dev/packages/simple_eye_dropper) としてpub.devに公開しました。
使い方は[日本語版README](https://github.com/satoshiiorg/simple_eye_dropper/blob/master/README.ja.md) を参照してください。

# 解説

## 選択部分を拡大表示する

CanvasにdrawImageRectという画像描画のためのメソッドがあるのでこれを使います。

```dart:magnifier_pointer.dart
final sourceRect = Rect.fromLTWH(
  (position.dx - (centerOffset / magnification)) / ratio,
  (position.dy - (centerOffset / magnification)) / ratio,
  outerRectSize / magnification / ratio,
  outerRectSize / magnification / ratio,
);
canvas.drawImageRect(
  uiImage,
  sourceRect,
  largeRect,
  paint,
);
```

magnificationは拡大表示部の拡大倍率、ratioは画像全体の縮小率です。
ざっくり言うと、sourceRectを拡大倍率で割ったサイズに縮小しておき、それを拡大表示部全体に引き伸ばして表示させることで拡大しています。

## 拡大表示部内をドラッグした場合はドラッグ開始位置に移動するのではなく拡大表示部分を移動する

```dart:eye_dropper.dart
onPanStart: (details) {
  final localPosition = details.localPosition;
  // タップ位置をセット
  _oldPosition.value = localPosition;
  // ポインタ枠外をタップした場合はポインタをそこへ直接移動
  if(!_pointer.contains(localPosition)) {
    _pickColor(localPosition);
    // タップ位置に移動
    _pointer.position = localPosition;
  }
},
onPanUpdate: (details) {
  // 前回のタップ/ドラッグ位置から移動した距離分ポインタを移動させる
  final localPosition = details.localPosition;
  final distance = localPosition - _oldPosition.value;
  _pointer.position = _pointer.position + distance;
  _pickColor(_pointer.position);
  _oldPosition.value = localPosition;
},
```

ポインタと書いているのは拡大表示部のことです。
内容はコメントの通りですが、前回のタップ位置を保存しておいてドラッグした距離を反映するという泥臭い処理をしています。
`localPosition`や`distance`などは全てOffsetですが、`+`や`-`といった演算子が定義されているおかげで見た目があまりごちゃごちゃせず済んでいます。

## GIFのアニメーション表示が有効になっていたのを最初のフレーム固定で表示するよう修正

`Image.memory` を使うと非常に手軽に画像を表示できるのですが、ありがた迷惑なことにデフォルトでGIFアニメが動いてしまいます。しかもこれを停止する手段はなさそうでした。(あったら是非知りたいです)
このため、Uint8Listをui.Imageに変換して最初のフレームを取るという工程を踏むことにしましたが、ui.Imageは非同期メソッドが多い上にCanvasを使わないと描画できないのでかなりコードが増えてしまいました。

```dart:修正前
child: Image.memory(_bytes),
```

```dart:修正後
Future<ui.Image> get _uiImage async {
  final codec = await ui.instantiateImageCodec(_bytes);
  final frameInfo = await codec.getNextFrame();
  return frameInfo.image;
}

(中略)

child: FutureBuilder<ui.Image>(
  future: _uiImage,
  builder: (_, snapshot) {
    Widget child;
    if (snapshot.connectionState == ConnectionState.done
        && snapshot.hasData) {
      final uiImage = snapshot.data!;
      return CustomPaint(
        painter: ImagePainter(uiImage, size, _ratio),
        size: Size(
          _imgImage.width * _ratio,
          _imgImage.height * _ratio,
        ),
      );
    } else if (snapshot.hasError) {
      throw Exception('${snapshot.error!}');
    }
    return const CircularProgressIndicator();
  },
),

(中略)

class ImagePainter extends CustomPainter {
  ImagePainter(this.uiImage, this.size, this.ratio);

  final ui.Image uiImage;
  final Size size;
  final double ratio;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    final source = Rect.fromLTWH(0, 0, size.width / ratio, size.height / ratio);
    final dest = Rect.fromLTWH(0, 0, size.width, size.height);
    canvas.drawImageRect(
      uiImage,
      source,
      dest,
      paint,
    );
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
```

ちょっとした対応のために中々の大改造ですが、得てしてこんなものですね。


## imageパッケージへの依存を解消

選択したピクセルの色を抽出する部分にimageパッケージを使用していましたが、ちゃんと調べたらdart:uiだけでも対処可能でした。

```dart
_bytesRgba = (await _uiImage.toByteData(
    format: ui.ImageByteFormat.rawStraightRgba,
))!;

(中略)
final position1d = (dy * _uiImage.width + dx) * 4;

// 32ビットintから各チャンネルを切り出し
final rgba = _bytesRgba.getUint32(position1d);
final r = rgba ~/ (256 * 256 * 256);
final g = rgba ~/ (256 * 256) % 256;
final b = rgba ~/ 256 % 256 % 256;
final a = rgba % 256 % 256 % 256;
final color = Color.fromARGB(a, r, g, b);
```

256がいっぱい出てるところは 0xAABBCCDD を 0xAA 0xBB 0xCC 0xDD に分解しています。
ColorクラスにはColor(int value)というコンストラクタがあるのでこれにそのまま`rgba`を渡せるかと思いきや、このvalueはRGBAではなくARGBだったのでこのようなことになっています。

追記: 普通ビット演算でやることのような気がしてきたのでビット演算の場合のコードも下記に置いておきます。

```dart
final r = rgba >> 24;
final g = rgba >> 16 & 0xFF;
final b = rgba >> 8 & 0xFF;
final a = rgba & 0xFF;
```

# 所感

個人的に面倒だったポイントとしては画像の形式が色々混在しており、場面によって必要な形式が変わってくるところでした。

- dart:ui の Image クラス
  Canvasへの描画で使用
- flutter/widgets の Image クラス
  Widgetとしての描画で使用 (最終的には使わなくなった)
- image パッケージの Image クラス
  画像変換系の処理時に使用 (最終的には使わなくなった)
- Uint8List
  生データ (エンコード済/デコード済) が必要な場合に使用

特にUint8Listはデコード済か否かが型だけでは分からないので混乱しがちです。
また、dart:ui の諸々は非同期なのでそこもごちゃごちゃしやすいポイントでした。