cocos2d-xで、スクロールビューの中身にメニューを配置したい。
簡単なことのようだが、実は問題がある。
メニューのアイテムをタッチすると、画面がスクロールできないのである。
つまり、アイテムの配置されていない箇所に指を触れてからスワイプしないと、画面がスクロールさせられないのである。
これは不便だしかっこわるい。
どうしてこうなっているのかというと…
メニュー(Menu)にtouchBeganイベントが処理されると、イベントが飲み込まれ、ScrollViewのtouchBeganイベントが処理されなくなるのである。
◯優先度について
cocos2d-xでは、EventDispatcherクラスにタッチイベントを登録する際(タッチ以外にも、マウスやキーボードイベントなども登録できる)、優先度を指定して登録する。
2種類の方法がある。
getEventDispatcher()->addEventListenerWithSceneGraphPriority(_touchListener, this);
getEventDispatcher()->addEventListenerWithFixedPriority(_touchListener, -100);
1つめの方法は、シーングラフの優先度と同じ優先度になる。
つまり、描画順序と同じく、シーン上の親から子への順序でイベントが処理される。
2つめの方法は、第2引数で指定した固定優先度で実行される。値が小さいほど優先度が高い。マイナス値も使えるので、-INT_MAXが一番優先度が高くなる。
混ぜて使う場合、
固定優先度がマイナス値のもの。-INT_MAX→-1
↓
シーングラフ優先度が高いものから低いもの
↓
固定優先度が0またはプラス値のもの。0→INT_MAX
という順番で処理される。
実装はEventDispatcher::dispatchEventToListeners()にある。
さて、メニューとスクロールビュー、どちらのタッチイベントが先に処理されるかというと、この優先度順序に依る訳である。
実装部分を見てみると、
void ScrollView::setTouchEnabled(bool enabled)
{
// :
//略
// :
_touchListener = EventListenerTouchOneByOne::create();
_touchListener->onTouchBegan = CC_CALLBACK_2(ScrollView::onTouchBegan, this);
_touchListener->onTouchMoved = CC_CALLBACK_2(ScrollView::onTouchMoved, this);
_touchListener->onTouchEnded = CC_CALLBACK_2(ScrollView::onTouchEnded, this);
_touchListener->onTouchCancelled = CC_CALLBACK_2(ScrollView::onTouchCancelled, this);
_eventDispatcher->addEventListenerWithSceneGraphPriority(_touchListener, this);
// :
//略
// :
}
bool Menu::initWithArray(const Vector<MenuItem*>& arrayOfItems)
{
// :
//略
// :
auto touchListener = EventListenerTouchOneByOne::create();
touchListener->setSwallowTouches(true);
touchListener->onTouchBegan = CC_CALLBACK_2(Menu::onTouchBegan, this);
touchListener->onTouchMoved = CC_CALLBACK_2(Menu::onTouchMoved, this);
touchListener->onTouchEnded = CC_CALLBACK_2(Menu::onTouchEnded, this);
touchListener->onTouchCancelled = CC_CALLBACK_2(Menu::onTouchCancelled, this);
_eventDispatcher->addEventListenerWithSceneGraphPriority(touchListener, this);
// :
//略
// :
}
という風に、どちらもシーングラフの優先度通りに実行されるようになっている。
当然といえば当然なのだが、これだと困る。
どんなときでも常にメニューが優先されるからだ(スクロールビューよりメニューが手前に表示されるはずなので)。
だからメニューのアイテム上をタッチしたときは、touchBeganイベントはスクロールビューに回ってこない。
メニューをタップしたいときはこれでいいが、スクロールしたいときは困る。
「メニューをタップしてそのまま離したときは、メニューのtouchBegan,touchEndedを処理。
メニューをタップして指を滑らしたときは、スクロールビューのtouchBegan,touchMoved,touchEndedを処理」
というふうにしたいのだ。
ということは、touchBeganの時点では、スクロールビューとメニューの両方で処理を走らせる必要がある。
◯swallow設定
cocos2d-xのイベントには、スワローというメンバ変数がある。
スワローの意味は「飲み込む」。
複数のイベントリスナーが登録されているとき、1つのイベントは上記で見た優先度順に処理されていく訳だが、
その途中でswallowがtrueになっているイベントリスナーがあると、そこで処理が終了してしまう。それ以降のイベントリスナーには処理がわたらないようになっている(これが飲み込む、の意)
で、もう一度さっきの実装コードを見てみると、メニューは
touchListener->setSwallowTouches(true);
と描いている。
つまり、タッチイベントはメニューに処理されると、それ以降他のノードには渡されなくなるのだ。もちろんスクロールビューにも、である。
なるほど、だからメニュー上をタップするとスクロールしないのか。
逆に、スクロールビューではスワロー設定をしていないので、デフォルトのfalseのままである。
ということは、
「メニューより先にスクロールビューでタッチイベントを処理すればいい」
のである。
スクロールビューで処理しても、優先度が低いイベントリスナーにも処理を渡すからだ。
ということで、スクロールビューのイベント登録部分を改造する。
void ScrollView::setTouchEnabled(bool enabled)
{
// :
//略
// :
_touchListener = EventListenerTouchOneByOne::create();
_touchListener->onTouchBegan = CC_CALLBACK_2(ScrollView::onTouchBegan, this);
_touchListener->onTouchMoved = CC_CALLBACK_2(ScrollView::onTouchMoved, this);
_touchListener->onTouchEnded = CC_CALLBACK_2(ScrollView::onTouchEnded, this);
_touchListener->onTouchCancelled = CC_CALLBACK_2(ScrollView::onTouchCancelled, this);
//_eventDispatcher->addEventListenerWithSceneGraphPriority(_touchListener, this);
_eventDispatcher->addEventListenerWithFixedPriority(_touchListener, -100); //<- br=""> // :
//略
// :
}
優先度値はマイナス値ならとりあえず何でもいい。
これで試してみると、メニューをタップしてもちゃんとスクロールするようになる!!
◯メニュータップのタッチ処理を止める
しかし、ちょっと動かしてみると少し違和感がある。
確かにメニュー上をタップしてもスクロールするようになるのだが、
スクロールしたあと指を離したときに、メニューアイテムが押されるのである。
指をメニューアイテムの無い場所で離せば何も起きないが、
要するにメニューのタッチイベントがまだ生きているのである。
よく見てみれば、タップしたメニューアイテムはselected状態のスプライトが表示されたままに鳴っている。
メニューのtouchBeganで始まった処理が有効で、指を離したときにtouchEndedが発生すると、その通りメニューアイテムを選択してしまうのである。
これはちょっといやだ。
スクロールした時点で、メニューのタッチイベントは終わらせたい。
そういう処理はなかったか?
もちろんある。
onTouchCancelledだ。
これはタッチイベントが中断されたときに呼び出されるメソッドだ。
本来はタップ中に電話がかかってきてアプリが中断されたときとかに呼ばれる想定だが、今回の目的はまさにこのメソッドを呼ぶことにある。
つまり、「スクロールビューのスクロールが発生した時点で、登録されている他のすべてのイベントリスナーのonTouchCancelledを呼び出す」
ということである。
onTouchMovedやonTouchEndedは呼び出させないのだ。
さてどうするか。
そもそもonTouch***系を呼び出している部分のコードを見てみる。
void EventDispatcher::dispatchTouchEvent(EventTouch* event)
{
//略
auto oneByOneListeners = getListeners(EventListenerTouchOneByOne::LISTENER_ID);
//略
auto onTouchEvent = [&](EventListener* l) -> bool { // Return true to break
EventListenerTouchOneByOne* listener = static_cast<EventListenerTouchOneByOne*>(l);
//略
if (eventCode == EventTouch::EventCode::BEGAN)
{
if (listener->onTouchBegan)
{
isClaimed = listener->onTouchBegan(*touchesIter, event);
//略
}
}
else if (listener->_claimedTouches.size() > 0
&& ((removedIter = std::find(listener->_claimedTouches.begin(), listener->_claimedTouches.end(), *touchesIter)) != listener->_claimedTouches.end()))
{
isClaimed = true;
switch (eventCode)
{
case EventTouch::EventCode::MOVED:
if (listener->onTouchMoved)
{
listener->onTouchMoved(*touchesIter, event);
}
break;
case EventTouch::EventCode::ENDED:
if (listener->onTouchEnded)
{
listener->onTouchEnded(*touchesIter, event);
}
//略
break;
case EventTouch::EventCode::CANCELLED:
if (listener->onTouchCancelled)
{
listener->onTouchCancelled(*touchesIter, event);
}
//略
break;
//略
}
}
//略
dispatchEventToListeners(oneByOneListeners, onTouchEvent);
//略
}
void EventDispatcher::dispatchEventToListeners(EventListenerVector* listeners, const std::function<bool(EventListener*)>& onEvent)
{
//略
for (; i < listeners->getGt0Index(); ++i)
{
auto l = fixedPriorityListeners->at(i);
if (l->isEnabled() && !l->isPaused() && l->isRegistered() && onEvent(l))
{
shouldStopPropagation = true;
break;
}
}
//略
for (auto& l : *sceneGraphPriorityListeners)
{
if (l->isEnabled() && !l->isPaused() && l->isRegistered() && onEvent(l))
{
shouldStopPropagation = true;
break;
}
}
//略
for (; i < size; ++i)
{
auto l = fixedPriorityListeners->at(i);
if (l->isEnabled() && !l->isPaused() && l->isRegistered() && onEvent(l))
{
shouldStopPropagation = true;
break;
}
}
//略
}
dispatchEventToListeners()の中で、優先度順のループが3つあり(固定優先度がマイナス値のもの、シーングラフ優先度のもの、固定優先度がプラス値のもの)、順番にonEventメソッドを呼んでいる。
onEventメソッドの実体はdispatchTouchEventの中のonTouchEvent というラムダ関数で、
この中でイベントリスナーを処理し、それぞれonTouchXXX関数を呼び出している。
スクロールビューのonTouchMovedの中でスクロールが発生したと判断したときに、
それ以降のイベントリスナーについてはすべてonTouchCancelledを呼び出すようにすればいい。
ということで、EventDispatcherクラスに_touchEventForceCancelled というフラグを用意し、以下のように改造する。
CCEventDispatcher.h
class EventDispatcher : public Ref
{
void setTouchEventForceCancelled(){_touchEventForceCancelled = true;} //<-追加
bool _touchEventForceCancelled; //<- br="">};
CCEventDispatcher.cpp
void EventDispatcher::dispatchTouchEvent(EventTouch* event)
{
_touchEventForceCancelled = false; //<-追加
//略
else if (listener->_claimedTouches.size() > 0
&& ((removedIter = std::find(listener->_claimedTouches.begin(), listener->_claimedTouches.end(), *touchesIter)) != listener->_claimedTouches.end()))
{
isClaimed = true;
if (_touchEventForceCancelled){ //<-追加
eventCode = EventTouch::EventCode::CANCELLED; //<-追加
} //<-追加
switch (eventCode)
//略
}
CCScrollView.cpp
void ScrollView::onTouchMoved(Touch* touch, Event* event)
{
//略
if (std::find(_touches.begin(), _touches.end(), touch) != _touches.end())
{
if (_touches.size() == 1 && _dragging)
//略
if (_dragging)
{
//略
_eventDispatcher->setTouchEventForceCancelled(); //<-追加
}
//略
}
とする。
解説。ScrollViewのonTouchMovedで、スクロール発生と判断されたタイミングで、EventDispatcherに、「以降のタッチイベントをすべてキャンセルさせろ」というフラグを立てる。
すると、それ以降のEventDispatcher::dispatchTouchEvent()内の処理で、タッチイベントはすべてEventTouch::EventCode::CANCELLEDに強制的に書き換える、ということである。
これで、メニュー上をタップしても、指を滑らせてスクロールすると、メニューのタッチイベントはキャンセルされる。
スプライトは選択状態のものから通常状態のものに戻るし、指を離してもアイテムは選択されない。
◯スクロールビューの無効化
スクロールビューにはもう一つバグがある。それは
「不可視状態でもタッチイベントが処理され、スクロールしている」
ということである。
正確なことを言うと、
スクロールビュー自体にsetVisible(false) を呼び出していれば処理されないのだが、
スクロールビューの祖先ノードでsetVisible(false)をしていて、スクロールビュー自体の_isVisibleがtrueのままの場合、処理されてしまう。
普通、他の種類のクラス(メニューとか)だと、親が_isVisible==falseなら処理しないようになっているので(普通に考えてそれが正しい)、スクロールビューがバグっている。
スクロールビューはextension扱いだし、どうもこういう不具合がまだ多い。
これに対処するのは割と簡単で、
onTouchXXX()系のすべての関数の先頭に以下のコードを書き足せば良い。
for (Node *c = this->_parent; c != nullptr; c = c->getParent())
{
if (c->isVisible() == false)
{
return;
}
}
このコードは、Menu.cppからコピペしたものである。
PR