tomo_manaのブログ

tomo_manaのブログ

Unityで一からゲームを作る方法を紹介しています。

Unity学習#21 (Unity 2019.4.1f1) リストにカーソル矢印を追加する(#13, 16, 17 不具合修正)

第16回で実装した「リストにカーソル矢印を追加する」処理について、第17回で複数リストに対応した時に、以下の不具合が出ていました。
(1) リストを表示した時に、最初の1個目が選択状態にならない(NullReferenceException)
(2) 2つ目のリストを表示した時に、1つ目のリストのカーソルが消える

この不具合の修正についてまとめます。

概要

第16回、17回の内容は以下の通りです。
tomo-mana.hatenablog.com
tomo-mana.hatenablog.com

原因と対策

出ていた不具合は以下の通りで、それぞれ以下のように修正します。

(1) リストを表示した時に、最初の1個目が選択状態にならない(NullReferenceException)
原因:初期化タイミングの問題でした。
対策:以前AwakeやOnEnableなどのコールタイミングを調べた結果を基に、初期化タイミングを見直します。

(2) 2つ目のリストを表示した時に、1つ目のリストのカーソルが消える
原因:第13課のWindowスタックと第17課のEventSystemの組み合わせで発生していました。
 a) 2つ目のリストにOnSelect() が飛ぶとき、1つ目のリストにOnDeselect() が飛びます。そのため1つ目のリストのカーソルが消えます。
 b) 2つ目のリストを非表示にする時、1つ目のリストに何のメッセージも飛びません。

これらは、2つのリストを一つの選択管理(EventSystem.SetSelectedGameObject)で管理していたために発生していました。

(キー入力でウィンドウを表示する仕組み)

f:id:tomo_mana:20201121201027p:plain
EventSystem.SetSelectedGameObject() 実行

(次のリストにフォーカスが移る時に、カーソルが消える)

f:id:tomo_mana:20201121201200p:plain
次のリストにフォーカスを移した時

(前のリストにフォーカスが移る時に、前のリストにメッセージが飛ばないのでカーソルが消えたまま)
図の赤字のメッセージが来ることを期待しているメッセージですが、当然トリガが無いのでイベントは来ません。

f:id:tomo_mana:20201121201229p:plain
前のリストにフォーカスを戻した時

対策:選択管理にEventSystemを使わず、リスト単位で選択管理をするように修正します(独自イベントの追加:ExecuteEvents の活用)。

修正の概要・手順

最初に初期化タイミングについて見直し、その後リストにイベントを追加する処理を行います。
(1) 初期化タイミング(参照エラー):描画を伴わない初期化・関連付けは Awake() で実行できます。また、リストを表示する度に初期化する状態を OnEnable() で初期化します。
(2) リストへのイベント追加:ExecuteEvents.Execute() を準備し、EventSystem.SetSelectedGameObject() とインターフェースを合わせます。

f:id:tomo_mana:20201120231004p:plain
修正の概要

先に修正後のコードを載せます。その後、それぞれの修正についてまとめます。

修正コード

Window

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ListItemWindow : Window, ItemPrefabAdapter
{
    public override void Awake()
    {
        base.Awake();
        child.SetAdapter( this );
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        if(gameObject.name == "ListItem"){
            return context.itemContext.itembag.Length;
        } else
        if(gameObject.name == "QueryUse"){
            int i, c = 0;
            int s = context.itemContext.itemId;
            for(i = 0; i < 3; i++){
                
                if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
                    c++;
                }
            }
            return c;
        } else {
            return 0;
        }
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
                return context.itemContext.itembag[no].name;
            } else
            if(gameObject.name == "QueryUse"){
                int i, c = 0;
                int s = context.itemContext.itemId;
                for(i = 0; i < 3; i++){
                    if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
                        if( c == no ){
                            return context.itemContext.queryUseString[i];
                        } else {
                            c++;
                        }
                    }
                }
            }
        }
        return string.Empty;
    }
    
    public void SetSelected(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
                context.itemContext.itemId = no;
            }
        }
    }
}

ScrollView

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // ScrollRect

public class ScrollViewController : MonoBehaviour
{
    [SerializeField] string[] itemString;
    
    protected ContentManager content;
    protected MenuContext context;
    
    private bool resized = false;
    private ItemPrefabAdapter adapter;
    
    void Awake()
    {
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
        content.SetAdapter( adapter );
    }
    
    void OnEnable()
    {
        resized = false;
    }
    
    public void SetContext(MenuContext context)
    {
        this.context = context;
    }
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    public void Update()
    {
        if( resized == false ){
            Vector2 contentSize = content.GetContentSize();
            
            if(contentSize != Vector2.zero){
                // ScrollView を Content と同じサイズにする
                RectTransform viewRect = gameObject.transform as RectTransform;
                Vector2 view = viewRect.sizeDelta;
                view.x = contentSize.x;
                view.y = contentSize.y;
                viewRect.sizeDelta = view;
                resized = true;
            }
        }
    }
    
    public void OnDisable()
    {
        resized = false;
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        content.Navigate(value);
    }
}

ContentManager

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;            // TextMeshProUGUI
using UnityEngine.EventSystems;

public class ContentManager : MonoBehaviour
{
    // リストの子要素のプレハブ
    [SerializeField] GameObject scrollNodePrefab;
    
    // 表示用のリストの子要素 GameObject のプール
    // リストは最大8個まで表示するとする。
    private List<GameObject> itemPool;
    private int itemMax = 8;
    
    // リストに表示する内容
    private string[] itemString;
    private int max;
    private ItemPrefabAdapter adapter;
    
    // 現在選択されているリストの要素
    private int itemSelectedNo;
    private INodeSelectable selected;
    
    // リスト更新リクエスト
    private bool listUpdateRequest = false;
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    // アイテムサイズ取得
    public Vector2 GetContentSize()
    {
        if( (adapter != null) && (itemPool != null) ){
            max = adapter.GetItemMax();
            
            RectTransform rectTransform = itemPool[0].transform as RectTransform;
            
            // max がゼロの場合、必ず1つはデータを作って返すこととする
            // デフォルト文字列「この操作はできません」「できる操作がありません」など
            if( max == 0 ){
                max = 1;
                Debug.Log(gameObject + ".GetContentSize zero");
            } else
            if( max > itemMax ){
                max = itemMax;
                Debug.Log(gameObject + ".GetContentSize");
            }
            return new Vector2(
                rectTransform.rect.width,
                rectTransform.rect.height * max
            );
        } else {
            Debug.Log(gameObject + ".GetContentSize Adapter is not Initialized");
            return Vector2.zero;
        }
    }
    
    void Awake()
    {
        InstanceListPrefabs();
    }
    
    private void InstanceListPrefabs()
    {
        // リスト表示数を制限
        if( itemMax > 8 ){
            itemMax = 0;
        } else if( itemMax < 2 ){
            itemMax = 2;
        }
        
        // リストの子要素のプールを作成
        itemPool = new List<GameObject>();
        
        // プレハブのインスタンス化
        int i;
        for(i = 0; i < itemMax; i++){
            GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
            itemPool.Add( itemObj );
            itemObj.transform.SetParent( gameObject.transform, false );
        }
    }
    
    private void UpdateListItems()
    {
        GetContentSize();
        
        int i = 0;
        for ( ; i < max; i++ ){
            TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
            itemText.text = adapter.GetItem(i);
            itemPool[i].SetActive(true);
        }
        for ( ; i < itemMax; i++ ){
            itemPool[i].SetActive(false);
        }
        
        if ( itemSelectedNo >= max ){
            itemSelectedNo = max - 1;
        }
        SetSelectedGameObject( itemPool[itemSelectedNo] );
    }
    
    void OnEnable()
    {
        itemSelectedNo = 0;
        listUpdateRequest = true;
        selected = null;
    }
    
    void Update()
    {
        if( listUpdateRequest == true ){
            UpdateListItems();
            listUpdateRequest = false;
        }
    }
    
    void Callback( INodeSelectable receiver, BaseEventData eventData )
    {
        if( selected != null ){
            selected.OnDeselect(eventData);
        }
        selected = receiver;
        
        if( selected != null ){
            selected.OnSelect(eventData);
        }
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        // 座標系は上がプラスだが、リストは下に行くほど大きい値になる
        if(value.y < 0){
            // 座標上は下=リストの要素番号は増える
            itemSelectedNo++;
            if( itemSelectedNo >= max ){
                itemSelectedNo = 0;
            }
        } else if(value.y > 0){
            // 座標上は上=リストの要素番号は減る
            itemSelectedNo--;
            if( itemSelectedNo < 0 ){
                itemSelectedNo = max - 1;
            }
        }
        adapter.SetSelected( itemSelectedNo );
        
        SetSelectedGameObject( itemPool[itemSelectedNo] );
        // 上記関数内で OnDeselect(), OnSelect() がコールされます
    }
    
    void SetSelectedGameObject( GameObject go ){
        ExecuteEvents.Execute<INodeSelectable>(
            target: itemPool[itemSelectedNo],
            eventData: null,
            functor: Callback
        );
    }
    
}

ChangeSprite

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // Image
using UnityEngine.EventSystems; // INodeSelectable(IEventSystemHandler), BaseEventData

public class ChangeSprite : MonoBehaviour, INodeSelectable
{
    [SerializeField] Sprite sourceImageSelected;
    private Image image;
    private Sprite sourceImageDefault;
    private bool selected;
    private bool started = false;
    private bool keepSelectedOnce = false;
    
    void Awake()
    {
        image = gameObject.GetComponent<Image>();
        sourceImageDefault = image.sprite;
    }
    
    void OnEnable()
    {
        selected = false;
        keepSelectedOnce = false;
        image.sprite = sourceImageDefault;
    }
    
    public void OnSelect(BaseEventData eventData)
    {
        if( sourceImageSelected ){
            selected = true;
            image.sprite = sourceImageSelected;
        }
    }
    
    public void OnDeselect(BaseEventData eventData)
    {
        if( selected ){
            image.sprite = sourceImageDefault;
            selected = false;
        }
    }
}

参照エラーの修正

以下に示す表の赤字の処理を、Start() から Awake()、OnEnable() に移動しました。

f:id:tomo_mana:20201120232421p:plain

transform.getChild() は Awake() の時点で使用できるため、親ゲームオブジェクトから子ゲームオブジェクトに参照渡しする関数はAwake() に移動できました。また、リストを表示する度に初期化する、たとえばリストの大きさを更新するためのフラグ(ここではresizedなど)はOnEnable() か Update() しかないのですが、OnEnable() の方が管理しやすいため、OnEnable() に移しました。

新しいリストを表示すると古いリスト側のカーソルが消える不具合の修正

ゲーム画面上のすべてのオブジェクトを一元管理する EventSystem の代わりに、ExecuteEvents を使用することでもう少しスコープを狭めた管理ができることに気が付きました。今回の場合はWindow単位で(もっというとContent→リストの子要素プレハブの間)で限定的にイベントを発行することで、リストが複数表示されている状態でもカーソルが消えなくなります。

(修正後のイメージ)

f:id:tomo_mana:20201122120226p:plain
修正後イメージ(シーケンス図)

ExecuteEventsの使い方は、Unityのマニュアルではいまいちわかりにくかったのですが、すでに試している方がいましたので、参考にさせていただきました。

qiita.com

(以下のページにもまとめてあります)
tomo-mana.hatenablog.com

以下の順に実装します。

送信側:
1) ExecuteEvents.Execute を実装する。
2) インターフェースを作る。
3) コールバックを作る。
受信側:
4) インターフェースをイベント受信側に実装する。

ExecuteEvents.Execute を実装する

 イベントを受信する側が実装すべき関数群を定めたインターフェース型名と、その関数群を呼ぶ処理をまとめたfunctorコールバック名をここで先に決めます。この時点ではインターフェース型とコールバックの実体をまだ定義していないのでコンパイルエラーになりますが、先に定義ラベルを決めておく方がこの後の作業がぶれなくなります。

ExecuteEvents.Execute<INodeSelectable>(
    target: itemPool[itemSelectedNo],
    eventData: null,
    functor: Callback
);

インターフェースを定義する(IEventSystemHandler 継承)

次に、先ほど定義したインターフェースを作ります。通常のソースコードと同じ要領でProjectウィンドウ上にインターフェース名のファイル(.cs)を作成して、その中を以下のように修正します。このインターフェースは、IEventSystemHandlerを継承している必要があります。ここでは EventSystem の OnSelect() と OnDeselect() を置き替えるために定義したインターフェース INodeSelectable.cs を作成しました。

INodeSelectable.cs

using UnityEngine.EventSystems;

public interface INodeSelectable : IEventSystemHandler
{
	void OnSelect(BaseEventData eventData);
	void OnDeselect(BaseEventData eventData);
}

functor を定義する

次に、functor に入れるコールバックを定義します。このコールバックの第一引数には、ExecuteEvents.Execute<>(target, eventData, functor) 第一引数であるreceiver は、target (GameObject型) を インターフェース型にキャストしたものが渡されてきます。この receiver に対して、先ほどのインターフェース型で定義した関数を呼び出します。コールバックの第二引数には、ExecuteEvents.Execute() の第二引数がそのまま渡されます。今回は、このコールバックの中で、OnDeselect() と OnSelect() をコールします。

INodeSelectable selected;    // 現在選択されているリストの子要素

void Callback( INodeSelectable receiver, BaseEventData eventData )
{
    if( selected != null ){
        selected.OnDeselect(eventData);
    }
    selected = receiver;
    
    if( selected != null ){
        selected.OnSelect(eventData);
    }
}

EventSystem → ExecuteEvents.Execute に置き換える

EventSystem.SetSelectedGameObject() のインターフェースに合わせるため、ExecuteEvents.Execute のラッパー関数として SetSelectedGameObject() を定義します。そして、EventSystem.SetSelectedGameObject() を、先ほど定義した内部変数 SetSelectedGameObject() に置き換えます。

public class ContentManager : MonoBehaviour
{
    // リストの子要素への選択・非選択通知
//  private EventSystem eventSystem;
    
    void SetSelectedGameObject( GameObject go ){
        ExecuteEvents.Execute<INodeSelectable>(
            target: go,   // itemPool[itemSelectedNo],
            eventData: null,
            functor: Callback
        );
    }
    
    public void Navigate(Vector2 value)
    {
//      eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
        SetSelectedGameObject( itemPool[itemSelectedNo] );
    }
}

IEventSystemHandler継承インターフェースを受信側に実装する

リストの子要素側には、ISelectHandler, IDeselectHandler の代わりに、新しく定義した INodeSelectable を実装させます。今回はISelectHandler, IDeselectHandlerと同じインターフェースで、呼ばれるタイミングも同じのため、実装部だけ修正したら完了です。

ChangeSprite

//public class ChangeSprite : MonoBehaviour, ISelectHandler, IDeselectHandler
public class ChangeSprite : MonoBehaviour, INodeSelectable

(備忘)失敗例

最初、既存のEventSystemを使って実装しようとしましたが、以下の壁にぶつかりました。備忘のため以下も残します。

(1) 次のリストにフォーカスが移る時に、カーソルが消える:

f:id:tomo_mana:20201121201200p:plain
次のリストにフォーカスを移した時

リストの子要素(Node)に ISubmitHandler も実装させて、OnSubmit() が来たら、OnDeselect() を1回だけ無視する → OnSubmit() が OnDeselect() よりも後に来ることがある(解決策見つからない)。

(2) 前のリストにフォーカスが移る時に、前のリストにメッセージが飛ばないのでカーソルが消えたまま:

f:id:tomo_mana:20201121201229p:plain
前のリストにフォーカスを戻した時

リストへのフォーカスを設定/解除する関数 OnFocus()/OnLostFocus() を Window に持たせる → これ自体はうまくいきましたが、(1) が解消できずに断念しました。なお、OnFocus()/OnLostFocus() を実装する場合、引数に reason を入れる必要があります。以下の例では、自分が一番上なら true、そうでなければfalse が来るようにしています。少し分かりづらいです。

//  @param reason
//  = true:アクティブ(OnEnable)になった
//  = false:自分のスタックより上に表示されていたWindowが非アクティブ(OnDisable)になった
void OnFocus( bool reason );

// @param reason
//  = true:自分が非アクティブになる(OnDisable)
//  = false:自分のスタックより上にWindowが表示された
void OnLostFocus( bool reason );

以上です。