RectTransformをコピペするEditor拡張作ってみた

# はじめに

Unity2021から、下図のように CopyComponent や PasteComponentValue が階層化され、実行までの手間が増えている状況。
開発作業時に RectTransform のコピーを頻繁に実行するため、楽にコピペ出来るようにEditor拡張を作ってみた。
<img width="480" alt="Unity2021 Editorスクショ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2847630/0570e60b-5654-e879-1df4-c325fc2d04c2.png">

# 詳細
### 作業環境
- Unity 2021.3.6f1
- Unity 2020.3.20f1

### 操作手順
1. コピー元（From）とコピー先（To）の RectTransform がアタッチされている GameObject を２つ選択
2. キーボードの Ctrl + 右クリック にて、動作項目が表示されたウインドウを表示
※選択数が２つという条件がウインドウ表示の条件となっている
3. 選択した GameObject の From と To を確認して、実行したい動作項目をクリックすることで RectTransform のコピペ実行
実行完了後に Debug ログを出力
4. 元に戻したい場合は、キーボードの Ctrl + Zキー で Undo

### 動作イメージ
Image(1) の RectTransform を Image(2) の RectTransform へ コピペする動作イメージ
<img width="1024" alt="動作イメージ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2847630/74643f97-4cf7-566d-c802-1efc3662186c.gif">

### コード
<details><summary>コードを表示</summary><div>

```C#:CopyRectTransformHierarchyWindowCustomMenu.cs
using System.Collections.Generic;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;

namespace Furyu.Framework.Editor
{
    /// <summary>
    /// HierarchyWindowCustomMenuでのRectTransformのコピー
    /// <para>
    /// RectTransformをコピーしたい対象のGameObjectをHierarchyから選択し
    /// Ctrl+右クリックで実行したい操作のメニューを選択する
    /// FromのGameObjectのRectTransformをToのGameObjectへ適用する
    /// （つまりRectTransformが更新される側はToのGameObjectとなる）
    /// </para>
    /// </summary>
    public class CopyRectTransformHierarchyWindowCustomMenu
    {
        /// <summary>
        /// カスタムメニュー名フォーマット
        /// </summary>
        private const string CustomMenuNameFormat = "Copy RectTransform From [ {0} ] To [ {1} ]";

        /// <summary>
        /// 処理対象のGameObject数
        /// </summary>
        private const int TargetGameObjectsCount = 2;

        /// <summary>
        /// 実行対象のGameObjectリスト
        /// </summary>
        private static List<GameObject> targetGameObjects = new List<GameObject>();

        /// <summary>
        /// カスタムメニューの項目が選択された時のコールバック
        /// </summary>
        /// <param name="userData"></param>
        /// <param name="options"></param>
        /// <param name="selected"></param>
        private static void Callback(object userData, string[] options, int selected)
        {
            var (rectTransform1, rectTransform2) = GetSelectedGameObjectRectTransforms();

            switch (selected)
            {
                case 0:
                    // from Selection.gameObjects[0] to Selection.gameObjects[1]
                    CopyRectTransform(rectTransform1, rectTransform2);
                    break;
                case 1:
                    // from Selection.gameObjects[1] to Selection.gameObjects[0]
                    CopyRectTransform(rectTransform2, rectTransform1);
                    break;
            }

            Debug.Log($"Execute - {options[selected]}.");
        }

        /// <summary>
        /// 指定RectTransformのコピー
        /// </summary>
        /// <param name="copyFrom"></param>
        /// <param name="copyTo"></param>
        private static void CopyRectTransform(RectTransform copyFrom, RectTransform copyTo)
        {
            ReplaceComponentsIfDifferent<RectTransform>(copyFrom.gameObject, copyTo.gameObject);
        }

        /// <summary>
        /// 選択されたGameObjectのRectTransformの名前を取得する
        /// 同名の場合はParentの名前をアンダーバーで結合した名前となる
        /// </summary>
        /// <returns></returns>
        private static (string, string) GetSelectedGameObjectRectTransformNames()
        {
            var (rectTransform1, rectTransform2) = GetSelectedGameObjectRectTransforms();

            var rectTransform1Name = rectTransform1.name;
            var rectTransform2Name = rectTransform2.name;
            var rectTransform1Parent = rectTransform1.parent;
            var rectTransform2Parent = rectTransform2.parent;

            // 同名の場合
            while (rectTransform1Name == rectTransform2Name)
            {
                // rootまで到達してしまったら終了
                if (rectTransform1Parent == null || rectTransform2Parent == null)
                {
                    break;
                }

                rectTransform1Name = $"{rectTransform1Parent.name}_{rectTransform1Name}";
                rectTransform2Name = $"{rectTransform2Parent.name}_{rectTransform2Name}";

                rectTransform1Parent = rectTransform1Parent.parent;
                rectTransform2Parent = rectTransform2Parent.parent;
            }

            return (rectTransform1Name, rectTransform2Name);
        }

        /// <summary>
        /// 選択されたGameObjectのRectTransformを取得する
        /// </summary>
        /// <returns></returns>
        private static (RectTransform, RectTransform) GetSelectedGameObjectRectTransforms()
        {
            var (gameObject1, gameObject2) = GetSelectedGameObjects();
            var rectTransform1 = gameObject1.GetComponent<RectTransform>();
            var rectTransform2 = gameObject2.GetComponent<RectTransform>();
            return (rectTransform1, rectTransform2);
        }

        /// <summary>
        /// 選択されたGameObjectを取得する
        /// </summary>
        /// <returns></returns>
        private static (GameObject, GameObject) GetSelectedGameObjects()
        {
            // 処理実行時にSelection.countから取得できるGameObject数がUnity2021と2020で違うため
            // メニュー表示時(targetGameObjectsが空の時)に取得できたGameObject数と同じ場合のみ対象GameObjectとして登録する
            // ・Unity2021.3.6 :メニュー表示時のGameObject数(今回の場合は2)が常に返ってくる
            // ・Unity2020.3.20:メニュー表示時のGameObject数(今回の場合は2)と処理実行時のGameObject数(今回の場合は1)で違う値が返ってくる
            if (Selection.count >= TargetGameObjectsCount && targetGameObjects.Count == 0)
            {
                targetGameObjects.Add(Selection.gameObjects[0]);
                targetGameObjects.Add(Selection.gameObjects[1]);
            }

            return (targetGameObjects[0], targetGameObjects[1]);
        }

        /// <summary>
        /// HierarchyWindowItemOnGUIのデリゲート処理
        /// </summary>
        /// <param name="instanceID"></param>
        /// <param name="selectionRect"></param>
        private static void OnHierarchyWindowItemOnGUI(int instanceID, Rect selectionRect)
        {
            if (Validate() == false)
            {
                return;
            }

            var current = Event.current;

            // Ctrl + 右クリック
            // ※Shift押下により選択数が変わる場合があるためShift押下無しを条件に追加
            if (current.control && current.shift == false && current.type == EventType.MouseDown && current.button == 1)
            {
                targetGameObjects.Clear();

                // 表示するメニュー名の設定
                var (rectTransform1Name, rectTransform2Name) = GetSelectedGameObjectRectTransformNames();
                var options = new GUIContent[]
                {
                    // from Selection.gameObjects[0] to Selection.gameObjects[1]
                    new GUIContent(string.Format(CustomMenuNameFormat, rectTransform1Name, rectTransform2Name)),

                    // from Selection.gameObjects[1] to Selection.gameObjects[0]
                    new GUIContent(string.Format(CustomMenuNameFormat, rectTransform2Name, rectTransform1Name))
                };

                // 表示位置の設定
                // width,heightはoptionsでサイズ可変になるようなので0指定
                var mousePosition = current.mousePosition;
                var position = new Rect(mousePosition.x, mousePosition.y, 0, 0);

                // カスタムメニューの表示
                EditorUtility.DisplayCustomMenu(position, options, -1, Callback, Selection.activeGameObject);
            }
        }

        /// <summary>
        /// 対象GameObjectの指定コンポーネント型の置換
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="src"></param>
        /// <param name="dst"></param>
        private static void ReplaceComponentsIfDifferent<T>(GameObject src, GameObject dst) where T : Component
        {
            // Undo対象を登録
            var dstTargetComponent = dst.GetComponent<T>();
            Undo.RecordObject(dstTargetComponent, $"ReplaceComponentsIfDifferentIn{typeof(T).Name}");

            ComponentUtility.ReplaceComponentsIfDifferent(src, dst, (component) => component is T);
        }

        /// <summary>
        /// HierarchyWindowItemOnGUIのデリゲート設定
        /// </summary>
        [InitializeOnLoadMethod]
        private static void SetDelegate()
        {
            EditorApplication.hierarchyWindowItemOnGUI += OnHierarchyWindowItemOnGUI;
        }

        /// <summary>
        /// カスタムメニュー表示条件の検証
        /// </summary>
        /// <returns></returns>
        private static bool Validate()
        {
            if (Selection.activeTransform == null)
            {
                return false;
            }

            // 選択しているGameObject数は固定値
            if (Selection.gameObjects.Length != TargetGameObjectsCount)
            {
                return false;
            }

            // RectTransformを持つGameObjectのみ対象
            foreach (var gameObject in Selection.gameObjects)
            {
                var rectTransform = gameObject.GetComponent<RectTransform>();
                if (rectTransform == null)
                {
                    return false;
                }
            }

            return true;
        }
    }
}
```

</div></details>

:::note
Editor拡張適用方法については[特殊なフォルダー名 - Unityマニュアル](https://docs.unity3d.com/ja/current/Manual/SpecialFolders.html)参照
:::

# さいごに
このEditor拡張により RectTransform のコピペの手間とコピペミスが減りました。
今回は同階層の GameObject の RectTransform のコピペまでしか対応していないので、階層が違う場合でのコピペの要件が出てくるようなら改修したい。
ComponentUtility.ReplaceComponentsIfDifferent メソッドを使えば、他の Component のコピペも出来るので、改変して他の用途のEditor拡張にも使えそう。