HEXA BLOG

プログラム

HEXA BLOGプログラム2017.2.14

UnityのScrollRectを拡張しよう

Switchの予約ができてゼルダが待ち遠しい某(なにがし)です
スプラトゥーン2の試射会もとても楽しみにしています

 

 

そういえば今日はバレンタインですね。

 

 

しかし、今回はUnityのプログラムネタです。ごめんなさい。
新しいことではないですが、uGUIでよくある問題の対処法をひとつ紹介します。

 

 

uGUIはとっつきやすく便利ですが、

 

スクロールビュー上のボタン押しにくい(クリックしにくい)

 

という問題に悩まされる人は多いんじゃないかと思います。

 

 

スクロールビュー上のボタンをドラッグするとスクロールが始まりますが、
この時点でボタンに指を離したイベントが発生して押下状態がキャンセルされ、
クリックイベントの対象から外れてしまいます

 

だから、少しでもドラッグするとボタンがクリックできず、押しにくいわけですね・・・

 

マウスならまだマシですが、
これがタッチ操作のときは気になるので、なんとかしたいと思います

 

 

考えられる方針は、

 

  1. ドラッグイベントを開始するドラッグ距離の閾値を与える(ドラッグ判定の遅延)。
  2. ドラッグ開始前に押下していたボタンに、
    ドラッグ終了時にクリックイベントを直接飛ばす(ドラッグで押下状態をキャンセルしない)。

 

こんなところでしょうか。

 

 

1.は一瞬でできます

 

EventSystemコンポーネントのDrag Threshold(pixelDragThreshold)がまさにドラッグ判定の閾値です。
これを大きめの値に設定すれば良いのですが、すべてのドラッグ判定に影響するのがイマイチな感じです

 

それに、スクロールビューのスクロール方向とは異なる向きの移動にも反応するため、
なんだか少しやりたいことと違う気がします

 

 

イベントの発行はBaseInputModuleの仕事であり、
普通なら、その派生クラスであるTouchInputModuleStandaloneInputModuleに任せていると思います。

 

BaseInputModuleを継承して、独自のイベント管理を実装するのも良い気がしますが、
それは仰々しいのでイベント処理側で簡単に対応します

 

 

今回は2.の方針で考えます。

 

 

実装の前に仕組みへの理解を深めるため、まずUnityのイベント処理の流れを簡単に説明します。

 

  1. タッチ入力されると、ポインティングされた座標にカメラからレイキャストを飛ばす。
    (対象はRaycastTargetにチェックを入れたGraphic派生コンポーネントを持つゲームオブジェクト)
  2. レイキャストに一番手前でヒットしたオブジェクトから親オブジェクトへと辿っていき、
    そのときのイベントに対応したイベントハンドラを持つゲームオブジェクトを探す。
    (例えば、OnPointerDownイベントならIPointerDownHandlerが対応)
  3. 見つけたゲームオブジェクトに対してイベント処理のメッセージを送信する。

 

起点となるオブジェクトを決定して、親を辿ってイベントハンドラを探す、
という流れを理解すれば、イベントシステムの使い方が見えてきます。

 

 

これもあるある話だと思うのですが、
スクロールビューの要素となるゲームオブジェクトにEventTriggerを持たせると、
ドラッグイベントも全てここで奪われてスクロールできなくなるといった問題が起きます。
上の処理内容を知らないと混乱しますよね。

 

 

このあたりの挙動はドキュメントでは詳しく説明されていなかったりしますが、
uGUIって実はブラックボックスではないんです。

 

Unity-Technologies / UI — Bitbucket

 

UnityさんがBitbucket上でuGUIのソースコードを公開しています。
何かと困ったらソースコードを見てみるのが早いです。

 

 

実際にソースコードを見てみると、PointerEventDataオブジェクトに
押下中オブジェクト(pointerPress)ドラッグ中オブジェクト(pointerDrag)への参照を持たせて、
これをそのままイベントシステムで利用しているのがわかります。

 

ドラッグイベント処理では、pointerPressとpointerDragが別のオブジェクトだった場合、
pointerPressにOnPointerUpイベントを送信して、参照をnullに差し替えています。

 

とすると、それまでにpointerPressで持っていた参照を独自で記憶するしかないですね

 

 

ということで、ScrollRectを拡張します。(ここまで長かった・・・)

 

  1. OnInitializePotentialDragでpointerPressを記憶
  2. OnEndDragのときの指の位置を見て、改めてレイキャストを飛ばして
    記憶していたpointerPressにヒットすれば、OnPointerClickイベントを送信する。

 

実装例を以下に示します。

 

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class CustomScrollRect : ScrollRect
{
	public override void OnInitializePotentialDrag(PointerEventData eventData)
	{
		var lastObj = EventSystem.current.currentSelectedGameObject;

		base.OnInitializePotentialDrag(eventData);
		if(!checkValidEvent(eventData)) return;

		// 直前の選択オブジェクトを記憶
		_lastSelectedObj = null;
		if(lastObj != null) {
			_lastSelectedObj = lastObj;
		}
	}

	public override void OnEndDrag(PointerEventData eventData)
	{
		base.OnEndDrag(eventData);
		if(!checkValidEvent(eventData)) return;

		// 直前の選択オブジェクトがあればイベント通知
		if(_lastSelectedObj != null) {
			EventSystem.current.RaycastAll(eventData, _raycastResults);
			var raycast = getFirstRaycast(_raycastResults);
			_raycastResults.Clear();

			notifyClickEvent(raycast, eventData);
		}

		_lastSelectedObj = null;
	}

	protected static RaycastResult getFirstRaycast(IEnumerable results)
	{
		foreach(var result in results) {
			if(result.gameObject != null) {
				return result;
			}
		}
		return new RaycastResult();
	}

	protected void notifyClickEvent(RaycastResult raycast, PointerEventData original)
	{
		if(_lastSelectedObj == null) return;

		var target = raycast.gameObject;
		if(target == null) return;

		// イベントハンドラを取得
		var handler = ExecuteEvents.GetEventHandler(target);
		if(handler == null) return;
		if(handler != _lastSelectedObj) return;

		// 通知用のイベントデータ生成
		var eventData = copyEventData(original);
		eventData.pointerCurrentRaycast = raycast;
		eventData.pointerPressRaycast = raycast;
		eventData.pointerPress = handler;
		eventData.rawPointerPress = target;
		eventData.eligibleForClick = true;

		// イベント実行
		ExecuteEvents.Execute(eventData.pointerPress, eventData, ExecuteEvents.pointerClickHandler);
	}

	protected bool checkValidEvent(PointerEventData eventData)
	{
		if(eventData.button != PointerEventData.InputButton.Left) {
			return false;
		}

		return IsActive();
	}

	protected PointerEventData copyEventData(PointerEventData original)
	{
		return new PointerEventData(EventSystem.current) {
			selectedObject = original.selectedObject,
			hovered = original.hovered,
			button = original.button,
			clickCount = original.clickCount,
			clickTime = original.clickTime,
			delta = original.delta,
			dragging = original.dragging,
			eligibleForClick = original.eligibleForClick,
			pointerCurrentRaycast = original.pointerCurrentRaycast,
			pointerDrag = original.pointerDrag,
			pointerEnter = original.pointerEnter,
			pointerId = original.pointerId,
			pointerPress = original.pointerPress,
			pointerPressRaycast = original.pointerPressRaycast,
			position = original.position,
			pressPosition = original.pressPosition,
			rawPointerPress = original.rawPointerPress,
			scrollDelta = original.scrollDelta,
			useDragThreshold = original.useDragThreshold,
		};
	}

	private static List _raycastResults = new List();

	private GameObject _lastSelectedObj = null;
}

 

使用中のイベントデータを書き換えると不都合がありそうなので、
強引ですが丸ごとコピーしたものを一部書き換えて使ってます。

 

これだけでだいぶボタンを押しやすくなり、押しやすさ改善の基礎はできました。

 

 

あとは、

 

  • ドラッグ開始時にOnPointerUpイベントが呼ばれてしまうのを
    OnPointerClickとタイミングを合わせる
    (ボタンの押下時アニメーションを継続したい)。
  • 一定距離スクロールしたら、記憶したpointerPressを忘れて、
    クリックイベントが飛ばないようにする。

 

このあたり対応すれば、なんだかとっても良い感じになるでしょう。

 

記事が長くなったので、この先の説明は割愛しますが、
今回の話を理解していれば難しい話ではないと思います。

 

 

実は、去年秋にリリースした「アイテム代は経費で落ちない ~no item, no quest~」では、
ここまで手が回らなかったんです。やり残したことのひとつでした・・・

 

このタイトルはあと少しだけアップデート予定があるので、
3月のアップデート内容にスクロールの挙動改善を入れました
アップデートによってどのように変わるか見てみると、なるほどなと思っていただけると思います

 

それでは、
「アイテム代は経費で落ちない ~no item, no quest~」
をよろしくお願いします(宣伝)

RECRUIT

大阪・東京共にスタッフを募集しています。
特にキャリア採用のプログラマー・アーティストに興味がある方は下のボタンをクリックしてください

RECRUIT SITE 

NEWS