[Unity][Timeline] TimelineWindowの縦横スクロールを操作できるようにする [解説]

## 初めに

先日、Twitterにて表題の内容を実装してみたというツイートを動画付きで投稿したら、思った以上に反響があったため慌ててgithubにコードを公開するということがありました

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">前々から作ろう詐欺で放置してたUnityのTimeline Windowの横スクロールと縦スクロールをマウスホイールで動かせるようにした拡張ウィンドウでけた<br>律儀に掘り起こす処理をクラス分けしてしまったので明日にでもgithubに公開してみよう<a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://t.co/oVZx299xdt">pic.twitter.com/oVZx299xdt</a></p>&mdash; jukey17(ゆき (@_jukey17) <a href="https://twitter.com/_jukey17/status/1056953407171260416?ref_src=twsrc%5Etfw">2018年10月29日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

コードをポイッと公開しただけだとちょっと勿体無いなと思ったので、どのように実装されているかというのをここで解説したいと思います

とにかくコードを見たいという方は[githubのrepositoryへどうぞ！](https://github.com/jukey17/unity-timeline-utility)

## 注意事項

今回の内容は**非公開APIをC#のReflectionと言う機能を使って呼び出す**ことによって実現されています

本来であれば公開されていないものなので、今後のUnityのバージョンアップ内容によっては突然仕様が変わって使えなくなることが起こりえます
そうなった際のメンテナンスは保証しかねるので利用する場合はご了承ください

また、この記事で解説する内容は、全て執筆時点の最新バージョンである**`Unity 2018.3.0b7`**で動作確認を行っています
これ以前・以後のバージョンでは仕様によっては使えないものがあるかもしれませんのでこちらも予めご了承ください

## スクロール・マウスホイール動作の標準仕様

実装の解説の前に、まずは標準の仕様についておさらいしましょう

私が把握できているTimelineWindow上におけるスクロールやマウスホイール関連の動作の仕様を列挙してみます
※これ以外にもありましたら教えてください

### 横軸(時間・フレーム)のスケールを変える

マウスホイールを回すときの標準の挙動です
特定の場所にフォーカスして細かく編集したり、全体を俯瞰してみたりするときに便利です
~~いや、スクロールしてほしい~~

### 縦軸のスケール(トラックの高さ)を変える

ctrl(command)キーを押しながらマウスホイールを回すときの標準の挙動です
正直使いたいと思ったことがありません
~~だから、スクロールしてほしい~~

ということで**マウスホイールではスクロールさせることはできません**

一応クリップの表示エリアに縦横のスクロールバーが用意されているのですが、そこまでマウスカーソルを持っていって掴んで動かすというのは地味に面倒です
サクッとスクロールさせる方法はないのか？

### alt(option)キーを押しながらクリップの表示エリアを掴んでドラッグする

スクロールさせる方法、ありました！
alt(option)キーを押しながらクリップの表示エリアを左クリックで掴み、マウスを動かす(ドラッグする)と任意の方向にクリップの表示エリアをスクロールさせることができます

やった！

~~でもalt(option)キーを押しながら操作するのはそれはそれでまだ面倒…~~

**ここはやはりマウスホイールだけでスクロールさせたい！！！**

ということで、ようやく本題に入ります

## どうやってスクロールさせるか

スクロールさせたいとはいっても、Timelineのエディタ周りのAPIは[`TimelineEditor`クラス](https://docs.unity3d.com/2018.3/Documentation/ScriptReference/Timeline.TimelineEditor.html)しか公開されておらず(Unity 2018.3.0b7現在)、編集ウィンドウに関しては外から一切触ることが出来ない状態になっています

そこで登場するのが**C#のReflection**です

Reflectionに関する細かい解説は本題から逸れていくので割愛しますが、簡単にまとめると、**Reflectionを使えば公開されていないAPIも掘り起こして実行することができる**のです

ただし、非公開APIを掘り起こせると言っても、どのパラメータを変更すればスクロールを制御することができるかを知っていないと掘り起こすにも掘り起こせません

## 標準機能の動作からアタリを付けて探す

そこで目をつけるのが、先程紹介したalt(option)キーを押しながらマウスを動かすことでスクロールさせる機能です

この処理を行っているところを見つけることができれば、そこで操作しているパラメータと同じものを操作することでスクロールさせることができるはずです

これでザックリとしたアタリをつけることができたので、Editor上で使用するTimelineの機能がまとまっている`UnityEditor.Timeline.dll`をdecompileしてそれっぽいことをしているクラスを探し出しましょう
※dllのdecompileについても話が逸れていくので割愛します

### `UnityEditor.Timeline.TimelinePanManipulator`

alt(option)キーとマウスドラッグのイベントを取得しているだろうということにアタリを付けて探していくと`TimelinePanManipulator`というクラスを発見することが出来ます

実際にマウスドラッグ中の処理をしていそうなところを覗いてみるとスクロールさせているような実装を見つけることができます
ビンゴ！

```csharp:TimelinePanManipulator.cs
using UnityEngine;

namespace UnityEditor.Timeline
{
  internal class TimelinePanManipulator : Manipulator
  {
  // 省略 

    protected override bool MouseDrag(Event evt, WindowState state)
    {
      if (!this.m_Active)
        return false;
      Rect treeviewBounds = TimelineWindow.instance.treeviewBounds;
      treeviewBounds.xMax = TimelineWindow.instance.position.xMax;
      treeviewBounds.yMax = TimelineWindow.instance.position.yMax;
      if (!((Object) state.GetWindow() != (Object) null) || state.GetWindow().treeView == null)
        return false;
      // ここ辺りでスクロール処理しているっぽい！
      Vector2 scrollPosition = state.GetWindow().treeView.scrollPosition;
      scrollPosition.y -= evt.delta.y;
      state.GetWindow().treeView.scrollPosition = scrollPosition;
      state.OffsetTimeArea((int) evt.delta.x);
      return true;
    }
  }
}
```

### `UnityEditor.Timeline.WindowState` と `UnityEditor.Timeline.TimelineTreeViewGUI` 

`TimelinePanManipulator`クラスの実装をみる限り、実際にスクロールさせるためには`WindowState`クラスと`TimelineTreeViewGUI`クラスを使っているようです

どうしてスクロール処理するだけで2つのクラスに別れているのか？と思ったのですが、横スクロールの際はクリップ表示エリアのみを、縦スクロールの際はクリップ表示エリアだけでなくトラック表示エリアも動かせるようにするために処理が別れているようでした

なにはともあれ、**操作すべきパラメータが分かってしまえばゴールまでもうあと少しです**

ここから更にこの2つのクラスを追いかけていくと、この２つのクラスは`UnityEditor.Timeline.TimelineWindow`クラスがインスタンスを持っていて、この`TimelineWindow`はシングルトンパターンになっている事が分かります

そしてこの`TimelineWindow`クラスはTimelineを編集するためのウィンドウ本体であり、ウィンドウが開かれているときだけシングルトンパターンのインスタンスができるということも判明します

ここまで分かってしまえば、あとはReflectionの機能を使って掘り起こすだけです

## TimelineWindowの縦横スクロール処理の全容

```csharp
// 任意の方向へのスクロール
public static void Scroll(int x, int y)
{
    var assembly = Assembly.Load("UnityEditor.Timeline");

    // TimelineWindowのインスタンスを取得
    var timelineWindowType = assembly.GetType("UnityEditor.Timeline.TimelineWindow");
    var timelineWindowInstanceProp = timelineWindowType.GetProperty("instance", BindingFlags.Public | BindingFlags.Static);
    var timelineWindowInstance = timelineWindowInstanceProp.GetValue(null);
    if (timelineWindowInstance == null)
    {
        Debug.Log("not open TimelineWindow.");
        return;
    }

    // WindowStateのインスタンスを取得
    var windowStateType = assembly.GetType("UnityEditor.Timeline.WindowState");
    var windowStateInstanceProp = timelineWindowType.GetProperty("state", BindingFlags.Public | BindingFlags.Instance);
    var windowStateInstance = windowStateInstanceProp.GetValue(timelineWindowInstance);

    // 横軸スクロール
    var offsetTimeAreaMethod = windowStateType.GetMethod("OffsetTimeArea", BindingFlags.Public | BindingFlags.Instance);
    offsetTimeAreaMethod.Invoke(windowStateInstance, new object[] {x});

    // TimelineTreeViewGUIのインスタンスを取得
    var treeViewType = assembly.GetType("UnityEditor.Timeline.TimelineTreeViewGUI");
    var treeViewInstanceProp = timelineWindowType.GetProperty("treeView", BindingFlags.Public | BindingFlags.Instance);
    var treeViewInstance = treeViewInstanceProp.GetValue(timelineWindowInstance);

    // 縦軸スクロール
    var scrollPositionProp = treeViewType.GetProperty("scrollPosition", BindingFlags.Public | BindingFlags.Instance);
    var scrollPosition = (Vector2) scrollPositionProp.GetValue(treeViewInstance);
    scrollPosition.y += y;
    scrollPositionProp.SetValue(treeViewInstance, scrollPosition);

    // 再描画を依頼する
    TimelineEditor.Refresh(RefreshReason.WindowNeedsRedraw);
}
```
githubで公開しているコードはクラス分けをしてしまっていて一覧性があまりないため、上記のサンプルでは1メソッドに全て収まる形で記述してみました

あとは`EditorWindow`上などでマウスホイールのイベントを取ってそのdelta値を流し込んであげればマウスホイールでスクロールができるようになります

実際の用途に合わせてこのコードを改造して使ってもらえれば幸いです

## 最後に

いかかだったでしょうか
今回のような対応をすることで少し大掛かりな内容にはなってしまいますが、上手い具合に標準機能の動作からアタリをつけることで非公開APIを掘り起こしてコードから自由に呼べることができるというのがC#の面白いところです

この方法を覚えておくと拡張の幅が拡がって楽しくなると思いますので、皆さんも使ってみましょう！
~~公開API以上に仕様がコロコロ変わるのでメンテナンスには注意！~~