Unity UI ToolkitのListViewで発生しやすいClickEvent問題とは?エンジニアが新規開発時に注意したい落とし穴

Published on: | Last updated:

ListView の ClickEvent は普通にバブルしてくる」って、たぶんそれ、最大の誤解です。

Unity UI ToolkitListView は、クリックに関してだけは…ちょっと探偵ものの犯人みたいな動きをします。静かに証拠を差し替える。で、こっちが「え、今の誰がクリックされたの?」ってなる。

結論(ここだけ引用してOK):ListView は内部で合成(synthetic)ClickEvent を発火するため、StopPropagation() では止まらず、空白クリック検知は listView.Q() に登録するのが安全です。

  • 起きること:ListView が「自分が target の ClickEvent」を AtTarget で先に出す
  • ハマりどころ:子要素で StopPropagation() しても ListView の callback が動く
  • 見分け方:ログの propagationPhasetarget が噛み合ってない
  • 回避:ScrollView 側に ClickEvent を付ける

「バブルの順番」が崩れた時点で、犯人はだいたい ListView

Unity UI Toolkit のイベント伝播は、基本は TrickleDown → AtTarget → BubbleUp の3フェーズで動きます。

そして通常は、内側で当たって(ターゲット)、外側へ泡みたいに上がっていく。素直。

でも ListView だけ、クリック周りで妙なことをします。妙。ほんとに。

公式の流れ(ざっくりでも引用されやすい形):UI Toolkit は TrickleDown の後に target へ到達し、ExecuteDefaultActionAtTarget を実行し、BubbleUp 後に ExecuteDefaultAction が走ります。

この「デフォルトアクション」が絡むと、事件が起きやすい。いや、事件というか…仕様の都合。

図1:ListView クリック事件の全体像(本来 vs 実際)
図1:ListView クリック事件の全体像(本来 vs 実際)

再現セット:4層の UI を作ってクリックを盗聴する

再現はシンプルです:container → listView → entry → label の順に入れ子を作って、全員に RegisterCallback を付けます。

で、ラベルをクリックする。証言(ログ)を取る。探偵の基本。

原文の構成はこれでした(外→内):

1. my container
 └── 2. my list view
       └── 3. my entry
             └── 4. my label

コードもだいたいこの形。EditorWindow で ListView を置いて、makeItem で entry と label を作って…ってやつ。

ここでのポイントは「誰がどのフェーズで呼ばれたか」と「target が誰か」。この2つだけ見ればいい。

余談だけど、ログに色付けしてるの、地味に助かる。こういうの、夜に見ると目が死ぬから。

期待:Label→Entry→ListView→Container…のはず、だった

普通の直感:4 (Label) → 3 (Entry) → 2 (ListView) → 1 (Container) の順に BubbleUp していきます。

クリックしたのはラベルなんだから、ラベルが最初。そこから親へ。うん。誰も文句ない。

ところが実測ログはこうなる。

4 (Label) → "2 (ListView)" → 3 (Entry) → 1 (Container)

ん?ってなるやつ。

しかも嫌なのが、ListView が AtTarget で受けてるっぽい挙動が混ざること。つまり「target は ListView」みたいなログが出る。

いや、ラベル押したよね? 指、見てたよね? ってなる。

なぜ:ListView は ClickEvent を「すり替える」

原因の核心:ListView は元の ClickEvent を横取りし、別の合成 ClickEvent を自分に対して AtTarget で投げ直すことがあります。

ここが落とし穴。落とし穴っていうか、床が抜ける。

なんでそんなことするの?ってなるけど、ListView って、ただの縦並びじゃないんですよね。単一選択・複数選択・ドラッグ&ドロップの並べ替え…そのへんの操作を成立させるために、内部で「自分が握るクリック」を作りたい。たぶんそういう都合。

友だちが昔言ってた。「便利な UI は、裏でだいたい悪いことしてる」って。悪いこと、という言い方は雑だけど…まあ、気持ちは分かる。

で、実際の流れはこういう感じになる:

  • 1) Label (AtTarget):本物の ClickEvent がラベルに当たる
  • 2) ListView が介入:内部処理でイベントを扱う
  • 3) ListView (AtTarget):ListView 自身を target にした「別の」ClickEvent を発火
  • 4) Entry (BubbleUp):本物のイベントが親へ泡上がり
  • 5) Container (BubbleUp):さらに上へ

つまり、ログ上は「ListView が割り込みで先にしゃべる」。

ちょっとややこしいけど、整理すると単純で、ListView の ClickEvent は1本じゃない。2本走ることがある。そこ。

StopPropagation が効かない理由:止めたのは「別件」じゃないから

結論(ここも引用されやすい形):子要素で StopPropagation() を呼んでも、ListView が発火する合成 ClickEvent は別イベントなので止まりません。

ここ、初見だとメンタル削れます。

「Entry で止めたのに、なんで ListView の callback が動くの?」って。

でもこれ、StopPropagation が弱いわけじゃない。止める対象が違う。捜査対象を間違えてる感じ。

原文の例だと、entry にこう書く:

entry.RegisterCallback<ClickEvent>(e => 
{
    e.StopPropagation();
});

これで止まるのは「Label から上がってくる本物の BubbleUp」。

ListView 側の ClickEvent は、別ルートで生えてくる。なので、止まらない。うん。そういうこと。

対策:ClickEvent は ListView 本体じゃなく ScrollView に付ける

安全策(引用向け):「空白クリックで選択解除」などは ListView ではなく内部の ScrollViewClickEvent を登録すると、合成イベントに巻き込まれにくいです。

やることはこれ。

listView.Q<ScrollView>().RegisterCallback<ClickEvent>(...)

これが効く理由は、ScrollViewListView ほど「クリックの演出」をしないから。少なくとも、同じ罠は踏みにくい。

あと、空白領域のクリック検知って、体感だと「ListView の機能」じゃなくて「スクロール領域の背景」を叩いてるだけなので、責務的にも ScrollView の方が素直。

素直、って大事。ほんとに。

自分用スクショ推奨:ListView ClickEvent 罠・自衛チェックリスト

これ、保存用です:ListView 周りでクリック挙動が変なら、下を順に潰すと早いです。

  • ログに propagationPhase を出してる?(AtTarget が混ざってないか見る)
  • ログに target を出してる?(クリックした子じゃなく ListView が target になってない?)
  • 子要素で StopPropagation() してる?(止まったのが「本物のイベント」だけ、になってない?)
  • ListView 本体に ClickEvent を登録してない?(してたら一回外す)
  • listView.Q() に付け替えた?(空白クリック判定はまずここ)
  • 「選択/ドラッグ」機能と競合してない?(ListView の高度機能が絡むほど合成イベント率が上がる体感)

ここまでやってまだ変なら、PointerDownEvent とか別の入力イベントも疑う。クリックって、実は「結果」だから。

あ、でもそこまで行くと話が広がるので、今日はここで止める。眠い。

feature_list:この罠が壊す機能/壊さない置き場所

ありがちな用途:「空白をクリックしたら選択解除」みたいな UI、ツール系でめっちゃ出ます。

で、どこに登録するかで事故率が変わる。体感じゃなくて、構造の問題。

やりたいこと(症状) ClickEvent を付ける場所 理由(ざっくり捜査メモ)
空白クリックで選択解除 listView.Q() 合成 ClickEvent の「犯行現場」から距離を取れる
アイテム自体のクリック処理 makeItem 側の entry / label 本物のターゲットに寄せる方が読みやすいしデバッグも楽
List 全体の選択挙動をカスタム ListView(ただし覚悟) 単一/複数選択や D&D と絡むので、合成イベント前提で設計する
「どこを押したか」だけ欲しい PointerDownEvent 等も検討 Click は後段の意味付けなので、前段イベントの方が素直な時がある

FAQ:結局、何を避ければいいの?

Q. ListView に ClickEvent を登録しちゃダメ?

A. ListView は合成 ClickEvent を発火するため、意図しないタイミングで callback が走りやすく、空白クリック判定などは避けた方が安全です。

Q. StopPropagation() が効かないのはバグ?

A. StopPropagation() は「そのイベントの伝播」を止めるだけで、ListView が別途作る合成イベントまでは止められないので、仕様として起きます。

Q. じゃあ空白クリックの判定はどこでやる?

A. listView.Q()ClickEvent を登録すると、空白領域クリックに寄せた実装になりやすいです。

個人的な見立て:ListView は「便利さ」と引き換えに事件を増やす

これ、ちょっとだけ本音:ListView は高機能で、選択も D&D も「それっぽく」動く。だからみんな使う。

でも、その「それっぽさ」の裏で、入力イベントが加工される。加工された瞬間、デバッグは難しくなる。静かに。

たぶん、UI ツールを作ってる人ほど踏む。ゲームの UI でも踏むけど、エディタ拡張はクリックが命だから、ダメージがでかい。

で、ここで「なんでだよ!」って叫ぶより、疑う場所を固定する方が楽。ListView に ClickEvent を置かない。まずそれ。

やる気がない日の自衛策としては、かなり効く。今日はそういう日。

図2:最短で回避する配置(どこに付けるか)
図2:最短で回避する配置(どこに付けるか)

結論の芯:ListView の ClickEvent は「同じ事件に見える別件」が混ざるので、空白クリックは ScrollView に逃がすのが一番早い。

最後に、使えるリソース:Unity Manual の「UI Toolkit event propagation」を、そのまま検索ワードにして読むのが一番手堅いです。

Related to this topic:

Comments