UnityでSerializeFieldされたFieldへオブジェクトを楽にBindingできるようにしました
はじめに
RootViewというprefabが存在し、RootViewスクリプトがAddされているとします。
今まではInspector上のSerializeFieldされたFieldへ手でドラッグ&ドロップさせていたのですが入れるところを間違ったり面倒だったりで、Custom Attributeを使って対応したら楽できるんじゃないかと同僚に提案されたので実装しました。
構想
Custom Attributeで名前(パス)を指定して、同じ名前のオブジェクトを子供のオブジェクトから検索して一致したらFieldへ割り当てるようにします。
前提
Viewという基底クラスを用意し、prefabにAddされるスクリプトは全てViewを親に持つようにします。
public class View : MonoBehaviour
{
}
Custom Attribute
FieldのAttributeでオブジェクト名(パス)を指定できるようにするので、Custom Attributeを作成します。
[AttributeUsage(AttributeTargets.Field)]
public sealed class AutoAssignByNameAttribute : Attribute
{
public AutoAssignByNameAttribute(string name)
{
Name = name;
}
public string Name { get; }
}
Root View
Viewを継承したクラスで、AutoAssignByNameでオブジェクト名を指定します。
public class RootView : View
{
[SerializeField, AutoAssignByName("ButtonA")]
Button _buttonA;
[SerializeField, AutoAssignByName("ButtonA/Label")]
TextMeshProUGUI _buttonALabel;
[SerializeField, AutoAssignByName("ButtonB")]
Button _buttonB;
[SerializeField, AutoAssignByName("ButtonB/Label")]
TextMeshProUGUI _buttonBLabel;
[SerializeField, AutoAssignByName("ImageA")]
Image _image;
}
FieldへBindingするメソッド
public static class EditorUtil
{
public static void AutoAssignForView(GameObject gameObject)
{
var autoAssignMap = new Dictionary<string, FieldInfo>();
// ①
var view = gameObject.GetComponent<View>();
if (view == null) return;
// ②
foreach (var field in view.GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) {
var autoAssignByName = field.GetCustomAttribute<AutoAssignByNameAttribute>();
if (autoAssignByName == null) continue;
autoAssignMap[autoAssignByName.Name] = field;
}
// ③
var childrenTransform = gameObject.GetComponentsInChildren<Transform>();
foreach (var (autoAssignName, field) in autoAssignMap) {
var autoAssignNames = autoAssignName.Split("/");
foreach (var childTransform in childrenTransform)
{
if (autoAssignNames[0] != childTransform.gameObject.name) continue;
var foundTransform = childTransform.parent.Find(autoAssignName);
field.SetValue(view, foundTransform.GetComponent(field.FieldType));
break;
}
}
}
}
①
GetComponent<View>
でViewクラス(正確にはViewクラスを継承したクラス)を取得します。
②
ViewクラスからFieldを取得して、AutoAssignByNameAttributeがついているか判定します。
ついていれば、AutoAssignByNameで指定された名前(パス)とFieldをDictionaryにセットします。
③
DictionaryのKey(AutoAssignByNameで指定された名前(パス))はスラッシュで分割して先頭のものと一致する子供のオブジェクトを探します。
AutoAssignByName("ButtonA") => ButtonA を探す。
AutoAssignByName("ButtonA/Label") => ButtonA を探す。
一致したら、親オブジェクトからTransform.Findでオブジェクトを検索して、SetValueでBindingします。
Transform.Findはスラッシュでのパス指定が可能なので、このようにしています。
パス指定できたことで、上記のRootViewの例のようなボタンのラベルをButtonALabel、ButtonBLabelみたいな一意な名前にしなくても良いので、うまくできたかなと思います。
AutoAssignByNameで指定する名前の制約としてRootのオブジェクト名を指定できないコードになっているのですが、運用してみて特に不都合はないので問題なさそうです。
ViewのInspectorにボタンを出す
CustomEditorでViewのScriptにボタンを出して、先ほどのAutoAssignForViewメソッドを実行するようにします。
CustomEditor Attributeの第二引数にtrueを設定すると子供のクラスでも有効になるので、先ほどのRootViewでもボタンが出るようになります。
[CustomEditor(typeof(View), true)]
public sealed class ViewEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
if (GUILayout.Button("Auto Assign")) {
var view = target as View;
EditorUtil.AutoAssignForView(view.gameObject);
EditorUtility.SetDirty(view.gameObject);
}
}
}
さいごに
ボタン押すだけでBindingできるようになったので楽になりました。
ソースコードでこれはどこのオブジェクトかをパスからわかるようになったのも見通しが良くなったかなと思います。
現在、以前書いた記事にあるように「UnityFigmaBridge」を使ってFigmaからUnityの画面を作成しているのですが、この仕組みのおかげで画面のprefabが再作成されてBindingがクリアされてもボタンを押せば元に戻るので楽です。
実際はすべての画面のprefabに対して一括で
EditorUtil.AutoAssignForView(view.gameObject);
を実行したりする、UnityFigmaBridgeのダウンロード後処理をするコードを書いています。
またUnityFigmaBridge関連の対応がいろいろまとまったら記事にしたいと思います。
Discussion