概要
今回は個人開発の中で UnityEditor.IMGUI.Controls.TreeView を利用してEditor上でシート管理をしやすくした話をします。
UI Toolkitがそれなりに広まってきた今、なぜIMGUIを?と思うかもしれません。
理由としては、数年前からの継ぎ足しで作っている個人開発のコードのため単純にIMGUIのほうが拡張しやすかったというのが大きいです。
昔からUnityで開発している人はこちらのほうが馴染み深いと思います。
そろそろちゃんとUI Toolkitに移行しなければ…
早速本題に入ります。
コード
コードの全体は上記のリポジトリにまとめています。
PackageManagerウィンドウから Install Package from git URL… を選択し、下記のURLを入力することで使えるようになります。
https://github.com/jukey17/EditorSheetView.git?path=Assets/EditorSheetView
動作確認環境は Unity6.3 LTS(6000.3.1f1) です。
謝辞
今回の実装に当たって、TreeViewの基本構造の理解に下記の記事を参考にさせていただきました。
本当にありがとうございます。
TreeView自体の基本構造や使い方などは上記の記事を参考にしてください。
また、上記以外にもいくつか日本語で解説してくださっている記事があります。
それらも合わせて参照していただくとより理解が進むと思います。
※Unity 6から上記の記事で紹介されている TreeView がobsolateになっています。本記事では後継となる TreeView<TIdentifier> を利用しています。
シート(Sheet)とは
タイトルやコード上では シート や Sheet と表現しています。
これは、冒頭のスクリーンショットにもある通り、Googleスプレッドシートのシートのように編集できる内容をイメージしています。
行と列で表現できるデータ構造を編集するUIなので、テーブルやグリッドでも問題ありません。
自分のイメージしやすい単語に置き換えて見ていただければと思います。
本題
実装内容をすべて解説しようとするとかなり長くなってしまうので、この記事では実装時のポイントをいくつかに絞って解説を行います。
リポジトリ内の実装からコードを抜粋しながら説明するので、全体のコードといっしょに見るとより分かりやすいと思います。
シート管理と割り切って TreeView を使う
今回はあくまでシートとして行と列による表現にしか使わないため、 本来 TreeView で扱うことのできる階層構造は使っておりません。
protected override TreeViewItem<int> BuildRoot()
{
var root = new TreeViewItem<int>(0, -1, "root");
var items = _rowList.Select(data => new RowItem(data))
.Cast<TreeViewItem<int>>()
.ToList();
SetupParentsAndChildrenFromDepths(root, items);
return root;
}
本来であればここで階層構造に構築することができますが、今回は階層を作らずに子要素として行のリストをそのまま親となるルートに対して追加しているだけにしています。
ISheetViewColumn と ISheetViewColumnFactory
SheetTreeView クラスは 行となる TRowData の型情報を元にその行を構成するための各列 (TreeViewColumn) をコンストラクタで事前に生成します。
これにより、行を描画する RowGUI メソッド内では、事前に生成された TreeViewColumn へ描画を移譲するだけで済むようになっています。
public SheetTreeView(TreeViewState<int> treeViewState, MultiColumnHeaderState multiColumnHeaderState,
ISheetViewColumnFactory columnFactory)
: base(treeViewState, new MultiColumnHeader(multiColumnHeaderState))
{
_columns = columnFactory.CreateTreeViewColumns<TRowData>();
Reload();
}
// 中略...
protected override void RowGUI(RowGUIArgs args)
{
if (args.item is RowItem item)
{
for (var i = 0; i < args.GetNumVisibleColumns(); i++)
{
var rect = args.GetCellRect(i);
var columnIndex = args.GetColumn(i);
var column = _columns[columnIndex];
column.Draw(rect, item.Data);
}
}
else
{
base.RowGUI(args);
}
}
コンストラクタの引数となっている列を生成する ISheetViewColumnFactory と、そこから生成される実際の列を表現する ISheetViewColumn はそれぞれインターフェースとなっています。
これにより、ユーザーが外から自由にカスタマイズしやすい設計になっています。
DefaultSheetViewColumnFactory
とはいっても、一から自分で作るのは大変なため、デフォルト実装が用意されています。
public ISheetViewColumn[] CreateTreeViewColumns<TRowData>()
{
var type = typeof(TRowData);
var fields = GetFields(type)
.Select(field => new SheetViewFieldColumn(field, _columnDrawerFactory.Create(field.FieldType)))
.Cast<ISheetViewColumn>()
.ToArray();
if (fields.Length > 0)
{
var result = new ISheetViewColumn[fields.Length + 1];
Array.Copy(fields, result, fields.Length);
result[fields.Length] = new SheetViewRemoveButtonColumn(_removeHandler);
return result;
}
var properties = GetProperties(type)
.Select(property => new SheetViewPropertyColumn(property, _columnDrawerFactory.Create(property.PropertyType)))
.Cast<ISheetViewColumn>()
.ToArray();
if (properties.Length > 0)
{
var result = new ISheetViewColumn[properties.Length + 1];
Array.Copy(properties, result, properties.Length);
result[properties.Length] = new SheetViewRemoveButtonColumn(_removeHandler);
return result;
}
var columns = new List<ISheetViewColumn>();
foreach (var member in GetMembers(type))
{
if (member is FieldInfo field)
{
columns.Add(new SheetViewFieldColumn(field, _columnDrawerFactory.Create(field.FieldType)));
}
if (member is PropertyInfo property)
{
columns.Add(new SheetViewPropertyColumn(property, _columnDrawerFactory.Create(property.PropertyType)));
}
}
columns.Add(new SheetViewRemoveButtonColumn(_removeHandler));
return columns.ToArray();
}
// 中略…
private static FieldInfo[] GetFields(Type type)
{
var attr = type.GetCustomAttribute<SheetViewUseFieldsAttribute>();
return attr != null ? type.GetFields(attr.BindingFlags) : Array.Empty<FieldInfo>();
}
private static PropertyInfo[] GetProperties(Type type)
{
var attr = type.GetCustomAttribute<SheetViewUsePropertiesAttribute>();
return attr != null ? type.GetProperties(attr.BindingFlags) : Array.Empty<PropertyInfo>();
}
private static IEnumerable<MemberInfo> GetMembers(Type type)
{
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
return type.GetMembers(flags)
.Where(member => member.GetCustomAttribute<SheetViewUseMemberAttribute>() != null);
}
少し長いですが、すべての列の情報 ISheetViewColumn[] を生成する処理です。
行となる TRowData の型情報から対象となるフィールド、プロパティを取得してそれを列の要素としてみなして列情報として返却しています。
TRowData はジェネリックとなっているので、メソッド呼び出し時に任意の型を設定して複数の型で使い回すことができます。
また、そのTRowData の中から何を列の情報の対象とするかどうかは専用の属性を見ています。
下記のような形でクラス単位、またはメンバー単位で列の対象を自由に設定できます。
// フィールドがすべて列の対象となる。
[SheetViewUseFields]
public ExampleFieldData
{
private int id;
private string label;
private Vector3 position;
public int Id => id; // 対象にならない
}
// プロパティがすべて列の対象となる。
[SheetViewUseProperties]
public ExamplePropertyData
{
private int Id { get; set; }
private string Label { get; set; }
private Vector3 Position { get; set; }
private string memo; // 対象にならない
}
// SheetViewUseMemberAttributeをつけたフィールド・プロパティが列の対象となる。
[Serializable]
public ExampleMemberAttributeData
{
[SerializeField, SheetViewUseMember] private int id;
[SerializeField, SheetViewUseMember] private string label;
[SerializeField, SheetViewUseMember] private Vector3 position;
public int Id => id; // 対象にならない
}
フィールドもしくはプロパティの情報を元に列の情報を生成するため、 ISheetViewColumn の具象クラスは SheetViewFieldColumn と SheetViewPropertyColumn に分かれます。
※ SheetViewRemoveButtonColumn も用意されていますが、こちらは後述します。
public sealed class SheetViewFieldColumn : ISheetViewColumn
{
private readonly FieldInfo _fieldInfo;
private readonly IColumnDrawer _drawer;
public SheetViewFieldColumn(FieldInfo fieldInfo, IColumnDrawer drawer)
{
_fieldInfo = fieldInfo;
_drawer = drawer;
}
public void Draw(Rect rect, object rowData)
{
var next = _drawer.Draw(rect, _fieldInfo.GetValue(rowData));
if (next != null)
{
_fieldInfo.SetValue(rowData, next);
}
}
}
中身は至ってシンプルで、フィールドまたはプロパティ情報を元に描画クラスへ描画処理を移譲しているだけです。
この描画クラス IColumnDrawer もインターフェース化されており、列の型情報を元に自由に描画クラスを定義できるようになっています。
次回は列の描画を担当する IColumnDrawer について解説したいと思います。
終わりに
試しに作ってみよう、記事にしてみようと思ったらドンドンと膨れてしまって1つの記事で解説しきれない物量になってしまいました。
まだ理解が足りていない、納得ができていない実装が残っているのでアップデートしつつまた記事を書いて解説ができたらいいなと思っています。
誰でも使い回せるようにとパッケージ化しましたが、そのまま使わなくとも、一部分を抜粋して自分好みにカスタマイズしたり、TreeViewの使い方の例として扱ってもらったり、誰かの何らかの助けになれれば幸いです。
続きを書きました
