Unity で、気軽に呼び出せるダイアログを作る

#Unity で、気軽に呼び出せるダイアログを作る。

Unity でダイアログを作る方法はすでにいろいろ紹介されていますが、ここでは「気軽に呼び出せる」をコンセプトにしたダイアログを作ってみます。
気軽に呼び出せるとは、「Debug.Log」とか、JavaScript の alert のように使え、ヒエラルキーにオブジェクトを置いておく必要もなく、従って、Scene を気にせずにどこからでも呼び出せるものを目指します。

## 仕様

ダイアログの呼び出し方は、とりあえず以下の3種類を用意します。

```C#
/* メッセージとOKボタンを出し、OKボタンを押すと消える */
MyDialog.Alert("メッセージ");

/* メッセージとOKボタンを出し、OKボタンを押すと指定した処理を実行する */
MyDialog.Alert("メッセージ", Click_OK);

/* メッセージとOKボタンとCanselを出し、ボタンを押すと指定した処理を実行する */
MyDialog.Confirm("メッセージ", Click_OK, Click_Cancel);


/* 上記呼び出しで呼び出されるAction */
void Click_OK() {
	... なんらかの処理
}

void Click_Cancel() {
	... なんらかの処理
}
```

Unity 2020.2.1f1 と、2019.3.15f1 で動作確認しました。

## 実装方法

上記のように呼び出せるようにするため、スクリプトは MonoBehaviour を継承せず、スクリプト内部で GameObjectを生成するようにします。これは、まずは GameObject を用意してスクリプトをアタッチするUnityでの一般的な作りとは、反対方向のアプローチになります。

ただし、Canvas と EventSystem までスクリプトで用意するのは面倒なので、この2つだけはヒエラルキーで作って下さい。ダイアログを表示したい全ての Scene で、Canvas と EventSystem を用意しておく必要があります。
ヒエラルキーに Canvas と EventSystem を作るには、UI - Button などを作成してから削除するのが楽です。

本当に何も考えずにスクリプトだけで GameObject を作ると、フォントサイズとかのプロパティを全てスクリプトで指定することになって面倒なため、プレハブを使います。
ダイアログ(Panel)自身と、その中に表示する文字、ボタンも、全てプレハブで作ります。
これは「prefab in prefab」という状態になり、後で説明しますが、ちょっとしたコツが必要です。
部品を全てプレハブとすることで、画像付きのダイアログなども簡単に拡張可能になります。

部品をプレハブにする関係で、下記の説明では部品に名前を設定しています。
サンプル通りの名前にしなかった場合は、スクリプトの該当箇所を修正してください。

## プレハブと設定

### 1. ダイアログ Panel

ダイアログそのものは、UI - Panel で作ります。
このパネルの中にテキストなどの部品を加えていき、部品の大きさによってダイアログの大きさも変化するように設定します。

1. UI → Panel を作成。名前は「DialogPanel」
 - 表示したい Width を設定。この Width が最終的な見た目に影響します。
 - デフォルト設定では Image の Color のアルファ(「A」の値)が 100 ですが、このパネルを背景色とするため、A を最大値の 255 にしておきます。
2. Add Component で、Layout → Contentet Size Fitter
 - 「Contentet Size Fitter」の Vertical Fit を「Preferrer Size」に
3. Add Component で、 Layout → Vertical Layout Group
 - 「Vertical Layout Group」の 「Control Child Size」→「Height」にチェックを入れます。
   この設定で、部品の Height に応じて「DialogPanel」の Height も変わるようになります。
 - 「Vertical Layout Group」の Padding に適当な値を入れておきます。デフォルトの 0 のままだと、メッセージがパネルの外側にひっついてしまいます。Spacing も設定し、オブジェクト間に隙間を持たせます。

この DialogPanel の Width から「Padding - Left」と「Padding - Right」を引いた値を、DialogPanel 内の部品 Width に使います。以下の説明を簡単にするため、この値を「コンテンツ Width」と表現することにします。

### 2. ダイアログ内の部品

この「DialogPanel」の中に部品を追加します。
(ヒエラルキーで「DialogPanel」を選択してから、右クリックの「UI」で部品を追加します)

#### テキスト

テキストもプレハブ化することにします。
プレハブ部品にしておくことで、後で画像付きダイアログを追加したいときとかの対応が柔軟になります。

4. 「DialogPanel」の中に、UI → Text を作成。名前は「DailogText」とします。
 - 「DailogText」の Width は、上で説明した「コンテンツ Width」にする。
 - 適当にフォントサイズや行間などを設定してください（何かダミーのテキストをセットすると、調整しやすいです）。

#### OK ボタン

まず Panel を作り、その中に Button を配置します。Panel が無いと、Button が左端に寄ってしまいます。

5. 「DialogPanel」の中に、UI → Panel を作成。名前は「OkPanel」。
 - 「OkPanel」の Width を「コンテンツ Width」に。
 - Image の Source Image は「None」に、Color の A は 0 に。
 - 「Raycast Target」のチェックボックスを OFF にしておく。
 ついでに「Maskable」も OFF にして良いと思います。
6. 「OkPanel」の中に、UI → Button を作成。名前は「OkButton」。
 - Button の中の Text の文字列を「OK」に。フォントサイズを調整。フォントサイズに応じて Button の Width・Height も調整。

ここで、「OkPanel」の**「Raycast Target」を OFF にする**のが、超重要です。
普通はヒエラルキーの下にあるものが上に表示され、当たり判定も下にあるものが先になりますが、「prefab in prefab」で加えた部品は、親の当たり判定が子より優先されるようなのです。子は親よりヒエラルキーの下に表示されるので、一般的なヒエラルキーの感覚とは逆になります。
このため、OkPanelの「Raycast Target」が ON(デフォルト) のままだと、Panel の中にある Button が反応しなくなります。
この挙動を説明しているドキュメントが無く、原因を追及するのにかなり苦労しました。

#### OK・Cancel ボタン

OKボタンと同じように、OK・Cancel ボタンを作ります。
「OkPanel」を Duplicate して作るのが楽です。

7. ヒエラルキー上の「OkPanel」を右クリックして Duplicate し、名前を「OkCancelPanel」に変更します。
8. 「OkCancelPanel」の中の「OkButton」も Duplicate し、名前を「CancelButton」に変更します。
 - 「OkButton」と「CancelButton」の Pos X を変更して2つのボタンを横に並べます。
 - 「CancelButton」の中の Text の文字列を「Cancel」に

#### プレハブ化

以上ができたら、全体的なサイズや Padding 値などを調整します。
こんな感じになりましたでしょうか？
![スクリーンショット.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181201/45f12d80-b3fa-df91-7dc8-9718ad9b0cf8.png)
調整が終わったらプレハブ化をおこないます。
プレハブ化の手順は以下の通りです。

- ヒエラルキーを Load するためには Resources フォルダが必要なので、無ければ作ります。
- Resources の下に「Dialog」という専用フォルダを作ります。
ダイアログで使用する全てのプレハブとスクリプトを1つのフォルダーにまとめておくと、Asset → Export で unitypackage ファイルにすることが出来、他のプロジェクトに Import しやすくなるのでお勧めです。
- ヒエラルキーから「DailogText」「DialogOk」「DialogOkCancel」の3つをそれぞれ Projectの「Dialog」にドラッグしてプレハブ化します。プレハブ化したら(表示が青色になったら)ヒエラルキーで右クリックして Delete します。
- 「DialogPanel」をプレハブ化し、ヒエラルキーから Delete。

ここでは4つのプレハブを作るのが目的で、ダイアログがヒエラルキーに依存しなくて良いようにするために、ヒエラルキーからは削除しておくのです。

作られたプレハブは、Contentet Size Fitter の中にあったため、Height が 0 になっています。
そのままでは変更がしにくいので、Height に適当な値を設定した方が良いでしょう。
スクリプトから実際に Instantiate すると、Height は元通り無視されます。

と、ここまで書いておいてナニですが、プレハブを作るのが面倒くさいと思う方のために、unitypackage を[ココからダウンロード](https://drive.google.com/file/d/1fVeVPWMSLNTfg-nrQ9Zmf9HOW5GQEB3X/view?usp=sharing)できるようにしておきました。
これを使って自分流にカスタマイズするのが楽だと思います。

## スクリプト

「Dialog」フォルダ内に C# Scriptを作成し、名前を「MyDialog」とします。
この「MyDialog」をエディタで編集して下記のものに書き換えます。

ダイアログを呼び出す度にプレハブを Load するのは気が引ける(?)ので、全体をシングルトンにしてみました。

また、上述のように MonoBehaviour は継承していないので、プレハブを Instantiate する時にちょっと面倒な指定が必要です。メソッドを参考にしてください。

```C#
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/**
<summary>
	気軽に呼び出せるダイアログ
</summary>
*/
public class MyDialog {
	private static MyDialog m_Instance = null;

	Transform CanvasTr;				// ヒエラルキー上にある Canvas の Transform

	GameObject DialogPanel;			// ダイアログ外観

	Dictionary<string, GameObject> LoadedPF;	// Loadした全プレハブ部品

	Action ActionOK = null;			// OKボタンが押された時の処理
	Action ActionCancel = null;		// Cancelボタンが押された時の処理

	/// <summary>
	///	シングルトンの為のインスタンス
	/// </summary>
	private static MyDialog Instance {
		get {
			if (m_Instance == null) {
				m_Instance = new MyDialog();
			}
			return m_Instance;
		}
	}

	/// <summary>
	///	コンストラクタ
	/// </summary>
	private MyDialog() {
		CanvasTr = GameObject.Find("Canvas").transform;		// Canvas だけは、ヒエラルキーから拾う
		LoadedPF = new Dictionary<string, GameObject>();	// プレハブ保持用の連想配列
	}

	/// <summary>
	///	メッセージをダイアログで表示して「OK」ボタンで待つ
	/// </summary>
	/// <param name="mess">表示するメッセージ</param>
	/// <param name="funcOk">「OK」ボタンが押された時の処理</param>
	static public void Alert(string mess, Action funcOk = null) {
		MyDialog dialog = Instance;
		
		dialog.DialogPanel = dialog.Instantiate("DialogPanel");	// ダイアログ外観

		dialog.AddObj("DialogText").GetComponent<Text>().text = mess;	// メッセージ

		dialog.ActionOK = funcOk;
		dialog.AddObj("OkPanel").transform.Find("OkButton").GetComponent<Button>().onClick.AddListener(dialog.ClickOkButton);
	}

	/// <summary>
	///	OK / Cancel 確認ダイアログを表示する
	/// </summary>
	/// <param name="mess">表示するメッセージ</param>
	/// <param name="funcOk">「OK」ボタンが押された時の処理</param>
	/// <param name="funcCancel">「Cancel」ボタンが押された時の処理</param>
	static public void Confirm(string mess, Action funcOk = null, Action funcCancel = null) {
		MyDialog dialog = Instance;
		
		dialog.DialogPanel = dialog.Instantiate("DialogPanel");

		dialog.AddObj("DialogText").GetComponent<Text>().text = mess;

		dialog.ActionOK = funcOk;
		dialog.ActionCancel = funcCancel;
		GameObject buttonPanel = dialog.AddObj("OkCancelPanel");
		buttonPanel.transform.Find("OkButton").GetComponent<Button>().onClick.AddListener(dialog.ClickOkButton);
		buttonPanel.transform.Find("CancelButton").GetComponent<Button>().onClick.AddListener(dialog.ClickCancelButton);
	}

	/// <summary>
	///	OKボタンが押された時の処理
	/// </summary>
	private void ClickOkButton() {
		Close();		// ダイアログを消す
		if (ActionOK != null) {	// コールバック先が登録されていれば
			ActionOK();				// 実行
		}
	}

	/// <summary>
	///	Cancelボタンが押された時の処理
	/// </summary>
	private void ClickCancelButton() {
		Close();		// ダイアログを消す
		if (ActionCancel != null) {
			ActionCancel();
		}
	}

	/// <summary>
	/// 非MonoBehaviour 用の、プレハブ Instantiate(実体化)
	/// </summary>
	/// <param name="prefabName">Instantiate したいプレハブ名</param>
	/// <returns>生成された GameObject</param>
	private GameObject Instantiate(string prefabName) {
		if (!LoadedPF.ContainsKey(prefabName)) {	// まだプレハブが Load されていなければ
			LoadedPF.Add(prefabName, (GameObject)Resources.Load("Dialog/" + prefabName));	// Load
		}
		GameObject obj = UnityEngine.Object.Instantiate(LoadedPF[prefabName]);
		obj.transform.SetParent(CanvasTr, false);
		obj.transform.localScale = Vector3.one;
		obj.GetComponent<RectTransform>().anchoredPosition3D = Vector3.zero;
		// anchoredPosition3D をセットしないと、Pos.Z が不定になる。
		return obj;
	}

	/// <summary>
	/// DialogPanel に GameObject 追加
	/// </summary>
	/// <param name="add">追加する部品名</param>
	/// <returns>追加された GameObject</param>
	private GameObject AddObj(string add) {
		GameObject obj = Instantiate(add);
		obj.transform.SetParent(DialogPanel.transform, false);
		return obj;
	}

	/// <summary>
	///	ダイアログを閉じる
	/// </summary>
	private void Close() {
		foreach (Transform child in DialogPanel.transform) {	// 追加した GameObject を削除
			UnityEngine.Object.Destroy(child.gameObject);
		}
		UnityEngine.Object.Destroy(DialogPanel);	// ダイアログパネルを消す
	}
}
```

## まとめ

「気軽に呼び出せる」ことと拡張が容易なことにこだわったのでスクリプトは長くなりましたが、使い勝手はかなり良いと思います。自分が過去に作ったプロジェクトも、このダイアログを使ってリファクタリングしたくなりました。

「Raycast Target」は、このダイアログの例では、DialogPanel とボタン以外は全て OFF にするのが良いと思います。デフォルトでは ON になっていますが、微妙に実行時間に負荷をかけていると思われます。

ここに掲載したサンプルは Panel の背景とかをデフォルト設定のまま使っているので、自分でカスタマイズして使ってください。
変更・再配布可・商用利用可。これ全体をアセットストアとかで有料販売するのだけはナシねぐらいの感じで使ってください。

最後に宣伝ですが、この記事が少しでも役に立ったという方は、[人魚Days](https://mermaiddays.com)アプリをダウンロードだけでもして頂けると励みになります。