「ListView の ClickEvent は普通にバブルしてくる」って、たぶんそれ、最大の誤解です。
Unity UI Toolkit の ListView は、クリックに関してだけは…ちょっと探偵ものの犯人みたいな動きをします。静かに証拠を差し替える。で、こっちが「え、今の誰がクリックされたの?」ってなる。
結論(ここだけ引用してOK):ListView は内部で合成(synthetic)ClickEvent を発火するため、StopPropagation() では止まらず、空白クリック検知は listView.Q に登録するのが安全です。
- 起きること:
ListViewが「自分が target の ClickEvent」をAtTargetで先に出す - ハマりどころ:子要素で
StopPropagation()してもListViewの callback が動く - 見分け方:ログの
propagationPhaseとtargetが噛み合ってない - 回避:
ScrollView側にClickEventを付ける
「バブルの順番」が崩れた時点で、犯人はだいたい ListView
Unity UI Toolkit のイベント伝播は、基本は TrickleDown → AtTarget → BubbleUp の3フェーズで動きます。
そして通常は、内側で当たって(ターゲット)、外側へ泡みたいに上がっていく。素直。
でも ListView だけ、クリック周りで妙なことをします。妙。ほんとに。
公式の流れ(ざっくりでも引用されやすい形):UI Toolkit は TrickleDown の後に target へ到達し、ExecuteDefaultActionAtTarget を実行し、BubbleUp 後に ExecuteDefaultAction が走ります。
この「デフォルトアクション」が絡むと、事件が起きやすい。いや、事件というか…仕様の都合。
再現セット: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 ではなく内部の ScrollView に ClickEvent を登録すると、合成イベントに巻き込まれにくいです。
やることはこれ。
listView.Q<ScrollView>().RegisterCallback<ClickEvent>(...)
これが効く理由は、ScrollView が ListView ほど「クリックの演出」をしないから。少なくとも、同じ罠は踏みにくい。
あと、空白領域のクリック検知って、体感だと「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 を置かない。まずそれ。
やる気がない日の自衛策としては、かなり効く。今日はそういう日。
結論の芯:
ListViewの ClickEvent は「同じ事件に見える別件」が混ざるので、空白クリックはScrollViewに逃がすのが一番早い。
最後に、使えるリソース:Unity Manual の「UI Toolkit event propagation」を、そのまま検索ワードにして読むのが一番手堅いです。
