未来の自分へ知識貯金

ゆーきのエンジニアブログ

約6分 Flutter

Flutter Web で HtmlElementView のタップが効かない【CanvasKit】

Flutter Web で HtmlElementView のタップが効かない【CanvasKit】

自作の蔵書管理アプリを Web 版として公開したあと、書影をタップしても詳細画面に飛ばないことに気づきました。

妙だったのは、一部だけ動くことです。書影のある本はタップが効かないのに、書影が無い本(グレーの枠)はちゃんと反応する。同じカードの中で挙動が分かれます。

コードを見ても見当が付かなかったので、症状をそのまま AI(Claude)に伝えて原因を聞きました。返ってきたのは、こういう答えでした。

それは CanvasKit レンダラーの仕様で、あなたのコードのバグではありません。

以下は、その内容と実際に採用した修正です。同じ症状で止まっている人の役に立てばと思います。

症状

リストビューでもグリッドビューでも、書影の部分だけタップが無反応でした。

// これで動くはず、と思っていたコード
GestureDetector(
  onTap: _navigateToDetail,
  child: MangaCoverImage(coverUrl: manga.coverUrl),
)

GestureDetector で包んでいるのに、タップが onTap に届きません。Android 版では同じコードが問題なく動いていたので、Web 固有の問題であることまでは分かりました。ただ、そこから先が分かりません。

前提:なぜ書影を DOM で描いていたか

このアプリの書影は Image.network ではなく、HtmlElementView で HTML の <img> を描画しています。

理由は書影 API の CORS です。CanvasKit レンダラーは画像を CORS モードの fetch で取得しますが、書影の取得元に CORS ヘッダが付いていないとブロックされます。実際に OpenBD の書影で検証したところ、fetch は全て失敗し、<img> タグでは全て表示できました(ブラウザの <img> は CORS 非対応の画像でも表示だけは通ります)。

そのため HtmlElementView<img> を置く構成にしていました。CORS を回避するために選んだ手段が、今度はタップを奪っていたわけです。

原因:CanvasKit がプラットフォームビューのイベントを横取りする

Flutter Web の CanvasKit レンダラーは、画面全体を <canvas> に描画します。HtmlElementView(プラットフォームビュー)はその例外で、本物の DOM 要素が canvas とは別のレイヤーに置かれます

このとき、プラットフォームビューのコンテナがポインターイベントを engine レベルで受け止めます。そのため、上に重ねた Flutter 側の GestureDetectorInkWell にはイベントが届きません。ウィジェットツリー上は「上に乗っている」ように見えても、DOM 上は別レイヤーなので、Flutter の当たり判定が働かない、という仕組みです。

書影が無いカードだけ動いていたのは、そちらが HtmlElementView を使わないただの Flutter ウィジェット(プレースホルダー)だったからでした。

回避策:DOM 側で受けて Flutter に渡す

Flutter 側で受け取れないなら、DOM 側で click を受けて、そのままコールバックを呼ぶ——というのが提案された方針です。

まず、ウィジェットに onTap を持たせます。

class MangaCoverImage extends StatelessWidget
{
  final String? coverUrl;
  /// Web ではプラットフォームビューの制約により Flutter の GestureDetector が
  /// タップを受け取れないため、渡されたら DOM 側で処理する
  final VoidCallback? onTap;
  // ...
}

Web 側の実装で、<img> を包む <div> に直接 click リスナーを付けます。

final wrapper = web.HTMLDivElement();
wrapper.style
  ..setProperty('width', '100%')
  ..setProperty('height', '100%')
  // タップを受けるときは auto、受けないときは none にして Flutter へ通す
  ..setProperty('pointer-events', interactive ? 'auto' : 'none');

if (interactive)
{
  wrapper.style.setProperty('cursor', 'pointer');
  wrapper.addEventListener('click',
      ((web.Event _) => _tapHandlers[viewType]?.call()).toJS);
}

final img = web.HTMLImageElement();
img.src = url;
img.style
  // 常に none。クリックは wrapper で拾う(非対話時は Flutter へ通す)
  ..setProperty('pointer-events', 'none');

ポイントは <img> 自身の pointer-eventsnone にすることです。そうしないとクリックが <img> で止まり、<div> のリスナーまで届きません。

呼び出し側は、GestureDetector の代わりに onTap を渡すだけになります。

MangaCoverImage(
  coverUrl: widget.manga.coverUrl,
  width: 56,
  height: 80,
  onTap: _navigateToDetail,
)

画像が無いときは分岐させてよい

書影が無いときのプレースホルダーは HtmlElementView を使わないので、素直に GestureDetector で包めば動きます

if (coverUrl == null)
{
  final placeholder = CoverPlaceholder(width: w, height: h);
  if (onTap == null) return placeholder;
  return GestureDetector(onTap: onTap, child: placeholder);
}

無理に経路を統一せず、DOM を使う側だけ特別扱いする方が結果的に読みやすくなりました。

実装中に見つかった二次的な罠

ここからは、回避策を実装したに出てくる問題です。実装の過程で AI 側が気づいて対処したもので、事前に想像するのは難しい部類だと思います。

1. ビューファクトリはキャッシュされる

registerViewFactory は同じ viewType に対して一度しか呼ばれません。素直にコールバックをクロージャへ取り込むと、再ビルドしても最初のコールバックを呼び続けます

症状としては「詳細ページに飛ぶには飛ぶが、別の本が開く」という形で出ます。タップが効かない不具合を直した直後にこれが出ると、原因の切り分けが厄介です。

対策は、viewType をキーにした Map に最新のハンドラを入れ、ファクトリの中からその Map を引くことです。

final _tapHandlers = <String, void Function()>{};
// ...
_tapHandlers[viewType] = onTap;               // 毎ビルド更新される
wrapper.addEventListener('click',
    ((web.Event _) => _tapHandlers[viewType]?.call()).toJS);  // 常に最新を呼ぶ

2. viewType に何を混ぜるか

DOM の構造が変わる条件は viewType に含めておく必要があります。このアプリでは URL に加えて「クレジット表示の有無」と「タップを受けるかどうか」で分けました。

final viewType = 'cover_${Object.hash(url, showAttribution, interactive)}';

同じ URL でも、タップを受ける場所と受けない場所では DOM が違うためです。ここを URL だけにしていると、片方の構造がもう片方でも使い回されて破綻します。

まとめ

  • CanvasKit では、HtmlElementView の上に重ねた GestureDetector にタップは届かない
  • 回避策は、DOM 側で click を受けて Flutter のコールバックを呼ぶこと
  • <img>pointer-eventsnone、包む <div>auto にする
  • 実装後に、ビューファクトリのキャッシュで古いコールバックを掴む二次的な罠がある

動作を確認したのは Flutter 3.44.2 stable です。プラットフォームビュー周りは変更が入りやすい領域なので、新しいバージョンでは挙動が変わっている可能性があります。

今回、自分がやったこと

正直に書くと、この不具合で私がやったのは2つだけです。

  1. 症状を正確に伝えること(「タップが効かない」ではなく「書影のある本だけ効かない」まで含めて)
  2. 提案された修正を検証して、採用を決めること

原因の特定も実装も AI 側でした。それでも、症状の切り分けを持っていることには意味があったと思っています。「一部だけ効かない」という観察が無ければ、質問はもっと曖昧になっていたはずです。

そして今回のやり取りで、判断基準がひとつ手元に残りました。

「Web だけで起きる」「一部のウィジェットだけ起きる」が揃ったら、自分のコードよりレンダラーの仕様を疑う。

次に似た症状に当たったときは、この線から入れます。答えそのものより、こういう当たりの付け方が残るのが、AI に聞いて解決したときの一番の収穫かもしれません。

C# から Flutter に移ってきた経緯は、こちらに書いています。

この記事をシェアする

ゆーき
ゆーき

プログラマー経験10年以上。好きなプログラム言語はC#。使える言語はC++, Python他。好きな開発環境はVisual Studio。

プロフィールを見る →