Unityでリアクティブプログラミングを使ってピンチイン、ピンチアウトを実装

# はじめに

最近、リアクティブプログラミングを始めました。

これまでは、UIイベントを処理するコードは、繰り返し見直さないと処理の流れを追えない、ということが多くありました。UIイベントのコールバックに応じて状態を管理していたせいで、UIイベントごとにどのような状態に変わるのか、把握が必要だったためです。

リアクティブプログラミングを導入した結果、ストリームから受け取ったUIイベントを加工する処理を繋げることで、UIイベントを処理できるようになりました。その結果、コードを上から下へ読むだけで処理の流れを追いやすくなったと感じています。

今回、Unity上で[UniRx](http://u3d.as/content/neuecc/uni-rx-reactive-extensions-for-unity/7tT)を使用して、ピンチイン、ピンチアウトで画面を拡大・縮小する処理をリアクティブプログラミングで書いてみました。本記事では、リアクティブプログラミングの例のひとつとして、その処理を公開します。

# サンプルアプリケーション

今回作成したサンプルアプリケーションの動作例は、以下の動画の通りです。ピンチイン、ピンチアウト操作に合わせて、矩形が拡大・縮小表示されます。また、拡大・縮小の中心点を球で示します。

![edited.gif](https://qiita-image-store.s3.amazonaws.com/0/30639/b595031a-cf68-1e10-73b0-e77fca8511b0.gif)

[UnityプロジェクトはGitHubにアップしてあります](https://github.com/mikanbako/example-unity-pinch-gesture-with-rx)。プロジェクトはUnity 5.0.0f4 Personalで作成しています。また、当方では、Android 4.3, 4.4での動作を確認しています。

# サンプルコード

これから説明するサンプルコードは、下記の3クラスです。拡大・縮小の情報を表すクラス、その情報を求めるクラス、結果を描画するクラスで構成しています。

<dl>
<dt>Scalingクラス</dt>
  <dd>拡大・縮小の中心点および拡縮率を表します。</dd>
<dt>PinchGestureクラス</dt>
  <dd>ピンチイン、ピンチアウトを検知して、拡大・縮小の中心点および拡縮率を求めます。登録されたイベントリスナに、その情報を送ります。</dd>
<dt>MainCameraクラス</dt>
  <dd>PinchGestureから受け取ったScalingを元に、Cameraや、中心点を表す球を移動させます。</dd>
</dl>

これから各クラスごとに、下記の内容を説明していきます。

1. クラスのコード全体
2. ストリーム、オブザーバを定義したメソッド
3. ストリーム内の各オペレータやオブザーバの処理

## Scalingクラス

拡大・縮小の中心点および拡縮率を表します。

```csharp:Scaling.cs
using UnityEngine;

public sealed class Scaling
{
    public Vector2 Center { get; private set; }

    public float Scale { get; private set; }

    public Scaling(Vector2 center, float scale)
    {
        Center = center;
        Scale = scale;
    }
}
```

Centerプロパティで拡大・縮小の中心点（スクリーン座標）を表します。Scaleプロパティで拡縮率を表します。

## PinchGestureクラス

ピンチイン、ピンチアウトを検知して、拡大・縮小の中心点および拡縮率を求めます。

```csharp:PinchGesture.cs
using UnityEngine;
using UnityEngine.Events;
using UniRx;
using System;
using System.Linq;

public sealed class PinchGesture : MonoBehaviour
{
    [SerializeField]
    private ScaleEvent _scaleEvent;

    private CompositeDisposable _subscribers;

    private static float GetMaxLength(Touch[] touches)
    {
        float maxLength = 0;

        for (var i = 0; i < touches.Length - 1; i++)
        {
            for (var j = i + 1; j < touches.Length; j++)
            {
                maxLength = Mathf.Max(
                    maxLength,
                    Vector2.Distance(
                        touches[i].position,
                        touches[j].position));
            }
        }

        return maxLength;
    }

    private static Vector2 GetCenter(Touch[] touches)
    {
        return touches
            .Select(touch => touch.position)
            .Aggregate(
                Vector2.zero,
                (previous, current) => previous + current) /
            touches.Length;
    }

    private void OnEnable()
    {
        _subscribers = new CompositeDisposable();

        IConnectableObservable<Touch[]> touchStream =
            Observable.EveryUpdate()
                .Select(_ => Input.touches)
                .Publish();

        IObservable<Vector2> centerStream = touchStream
            .Buffer(2, 1)
            .Where(touches => touches[0].Length <= 1 &&
                2 <= touches[1].Length)
            .Select(touches => GetCenter(touches[1]));
       
        IObservable<float> scaleStream = touchStream
            .Buffer(2, 1)
            .Where(touches =>
                touches.All(touch => 2 <= touch.Length))
            .Select(touches =>
                GetMaxLength(touches[1]) /
                    GetMaxLength(touches[0]))
            .Scan(1f, (scale, rate) => scale * rate)
            .Skip(1);

        IDisposable scalingSubscriber = centerStream
            .CombineLatest(
                scaleStream,
                (center, scale) => new Scaling(center, scale))
            .Subscribe(_scaleEvent.Invoke);

        _subscribers.Add(touchStream.Connect());
        _subscribers.Add(scalingSubscriber);
    }

    private void OnDisable()
    {
        _subscribers.Dispose();
    }

    [Serializable]
    private sealed class ScaleEvent : UnityEvent<Scaling>
    {
    }
}
```

下記のOnEnableメソッド内で、ピンチイン、ピンチアウトの検知と、中心点・拡縮率の計算を行います。

```csharp
    private void OnEnable()
    {
        _subscribers = new CompositeDisposable();

        IConnectableObservable<Touch[]> touchStream =
            Observable.EveryUpdate()
                .Select(_ => Input.touches)
                .Publish();

        IObservable<Vector2> centerStream = touchStream
            .Buffer(2, 1)
            .Where(touches => touches[0].Length <= 1 &&
                2 <= touches[1].Length)
            .Select(touches => GetCenter(touches[1]));
       
        IObservable<float> scaleStream = touchStream
            .Buffer(2, 1)
            .Where(touches =>
                touches.All(touch => 2 <= touch.Length))
            .Select(touches =>
                GetMaxLength(touches[1]) /
                    GetMaxLength(touches[0]))
            .Scan(1f, (scale, rate) => scale * rate)
            .Skip(1);

        IDisposable scalingSubscriber = centerStream
            .CombineLatest(
                scaleStream,
                (center, scale) => new Scaling(center, scale))
            .Subscribe(_scaleEvent.Invoke);

        _subscribers.Add(touchStream.Connect());
        _subscribers.Add(scalingSubscriber);
    }
```

定義したストリーム、オブザーバについて説明します。

### touchStream

Updateメソッドが呼ばれるタイミングで、タッチ入力の情報を流すストリームです。

```csharp
        IConnectableObservable<Touch[]> touchStream =
            Observable.EveryUpdate()
                .Select(_ => Input.touches)
                .Publish();
```

後続の複数の箇所で、このストリームからデータを受け取ることから、Input.touchesを1回だけ呼ぶようにConnectableObserverにしています。

### centerStream

拡大・縮小の中心点を流すストリームです。

```csharp
        IObservable<Vector2> centerStream = touchStream
            .Buffer(2, 1)
            .Where(touches => touches[0].Length <= 1 &&
                2 <= touches[1].Length)
            .Select(touches => GetCenter(touches[1]));
```

<dl>
<dt>Buffer(2, 1)</dt>
<dd>touchStreamから今回とその直前のタッチ情報をひとつにまとめます。各UpdateごとにA, B, C, Dの順でタッチ情報を取得できる場合、(A, B), (B, C), (C, D)という形にまとまります。</dd>
<dt>Where(touches => touches[0].Length &lt;= 1 && 2 &lt;= touches[1].Length)</dt>
<dd>2点以上のタッチが始まったタイミングを抽出します。</dd>
<dt>Select(touches => GetCenter(touches[1]))</dt>
<dd>今回のタッチ情報から中心点を計算します。</dd>
</dl>

### scaleStream

拡縮率を流すストリームです。

```csharp
        IObservable<float> scaleStream = touchStream
            .Buffer(2, 1)
            .Where(touches =>
                touches.All(touch => 2 <= touch.Length))
            .Select(touches =>
                GetMaxLength(touches[1]) /
                    GetMaxLength(touches[0]))
            .Scan(1f, (scale, rate) => scale * rate)
            .Skip(1);
```

<dl>
<dt>Buffer(2, 1)</dt>
<dd>touchStreamから今回とその直前のタッチ情報をひとつにまとめます。各UpdateごとにA, B, C, Dの順でタッチ情報を取得できる場合、(A, B), (B, C), (C, D)という形にまとまります。</dd>
<dt>Where(touches => touches.All(touch => 2 &lt;= touch.Length))</dt>
<dd>今回とその直前のタッチ情報のうち、両方とも2点以上のタッチを行っている場合のみを抽出します。</dd>
<dt>Select(touches => GetMaxLength(touches[1]) / GetMaxLength(touches[0]))</dt>
<dd>直前のタッチと比べてどれだけ指同士の幅が縮まったり広がったりしたか、その割合を求めます。GetMaxLengthメソッドは、タッチ情報から、指同士の最大距離を求めます。
<dt>Scan(1f, (scale, rate) => scale * rate)</dt>
<dd>拡縮率を求めます。</dd>
<dt>Skip(1)</dt>
<dd>Scanの初期値を無視します。UniRxの実装では、Scanに指定した初期値がストリームに流れます。今回は、ストリームに値を流す場合はUpdateのタイミングで1つのみ流したいため、初期値を無視するようにしました。</dd>
</dl>

### scalingSubscriber

登録されたイベントリスナに拡大・縮小の情報を発行するオブザーバです。拡大・縮小の情報は、Scalingオブジェクトにまとめます。

```csharp
        IDisposable scalingSubscriber = centerStream
            .CombineLatest(
                scaleStream,
                (center, scale) => new Scaling(center, scale))
            .Subscribe(_scaleEvent.Invoke);
```

<dl>
<dt>centerStream.CombineLatest(scaleStream, (center, scale) => new Scaling(center, scale))</dt>
<dd>2点以上タッチされ始めたタイミングで計算された中心点と、現行の拡縮率とをScalingオブジェクトにまとめます。</dd>
<dt>Subscribe(_scaleEvent.Invoke)</dt>
<dd>登録されたイベントリスナに、拡大・縮小の中心点と拡縮率を送ります。</dd>
</dl>

## MainCameraクラス

PinchGestureから受け取ったScalingを元に、Cameraや、中心点を表す球を移動させます。

```csharp:MainCamera.cs
using UnityEngine;
using UniRx;
using System;

[RequireComponent(typeof(Camera))]
public sealed class MainCamera : MonoBehaviour
{
    [SerializeField]
    private GameObject _centerObject;

    private Subject<Scaling> _scalingStream;

    private CompositeDisposable _subscribers;

    public void OnScale(Scaling scaling)
    {
        _scalingStream.OnNext(scaling);
    }

    // Use this for initialization
    private void Start()
    {
        Camera camera = GetComponent<Camera>();

        _scalingStream = new Subject<Scaling>();
        _subscribers = new CompositeDisposable();

        float firstOrthographicSize = camera.orthographicSize;

        IObservable<float> scaleVariationRateStream =
            _scalingStream
                .Select(scaling => scaling.Scale)
                .StartWith(1f)
                .Buffer(2, 1)
                .Select(scaleHistory =>
                    scaleHistory[1] / scaleHistory[0]);

        IDisposable scalingSubscriber = _scalingStream
            .Zip(
                scaleVariationRateStream,
                (scaling, scaleVariationRate) =>
                    new
                    {
                        Center = scaling.Center,
                        Scale = scaling.Scale,
                        ScaleVariationRate = scaleVariationRate
                    })
            .Subscribe(current =>
            {
                Vector2 centerPosition = camera
                        .ScreenToWorldPoint(current.Center);

                _centerObject.transform.position = centerPosition;

                camera.orthographicSize =
                    firstOrthographicSize / current.Scale;

                Vector2 centerTranslate =
                   (centerPosition -
                            (Vector2)transform.position) *
                        (current.ScaleVariationRate - 1);

                transform.position += (Vector3)centerTranslate;
            });

        _subscribers.Add(_scalingStream);
        _subscribers.Add(scalingSubscriber);
    }

    private void OnDestroy()
    {
        _subscribers.Dispose();
    }
}
```

下記のStartメソッド内で、Cameraや、中心点を表す球を移動させます。

```csharp
    private void Start()
    {
        Camera camera = GetComponent<Camera>();

        _scalingStream = new Subject<Scaling>();
        _subscribers = new CompositeDisposable();

        float firstOrthographicSize = camera.orthographicSize;

        IObservable<float> scaleVariationRateStream =
            _scalingStream
                .Select(scaling => scaling.Scale)
                .StartWith(1f)
                .Buffer(2, 1)
                .Select(scaleHistory =>
                    scaleHistory[1] / scaleHistory[0]);

        IDisposable scalingSubscriber = _scalingStream
            .Zip(
                scaleVariationRateStream,
                (scaling, scaleVariationRate) =>
                    new
                    {
                        Center = scaling.Center,
                        Scale = scaling.Scale,
                        ScaleVariationRate = scaleVariationRate
                    })
            .Subscribe(current =>
            {
                Vector2 centerPosition = camera
                        .ScreenToWorldPoint(current.Center);

                _centerObject.transform.position = centerPosition;

                camera.orthographicSize =
                    firstOrthographicSize / current.Scale;

                Vector2 centerTranslate =
                   (centerPosition -
                            (Vector2)transform.position) *
                        (current.ScaleVariationRate - 1);

                transform.position += (Vector3)centerTranslate;
            });
```

定義したストリーム、オブザーバについて説明します。

### scaleVariationRateStream

拡大・縮小の際に、前回から変化した拡縮率の割合を流すストリームです。画面の中心以外の場所を基点に拡大・縮小を行う場合、カメラのズームイン、ズームアウトだけでなく平行移動もさせる必要が出てきます。その際に、このストリームに流れる値を使用します。

```csharp

        IObservable<float> scaleVariationRateStream =
            _scalingStream
                .Select(scaling => scaling.Scale)
                .StartWith(1f)
                .Buffer(2, 1)
                .Select(scaleHistory =>
                    scaleHistory[1] / scaleHistory[0]);
```

<dl>
<dt>Select(scaling => scaling.Scale)</dt>
<dd>PinchGestureから受け取ったScalingから、拡縮率を取り出します。</dd>
<dt>StartWith(1f)</dt>
<dd>初期値として拡縮率1を流します。</dd>
<dt>Buffer(2, 1)</dt>
<dd>今回と前回の拡縮率をまとめます。<dd>
<dt>Select(scaleHistory => scaleHistory[1] / scaleHistory[0])</dt>
<dd>前回と比較した今回の拡縮率の変化率を求めます。</dd>
</dl>

### scalingSubscriber

拡大・縮小の情報から、中心点を表す球とCameraを移動させるオブザーバです。

```csharp
        IDisposable scalingSubscriber = _scalingStream
            .Zip(
                scaleVariationRateStream,
                (scaling, scaleVariationRate) =>
                    new
                    {
                        Center = scaling.Center,
                        Scale = scaling.Scale,
                        ScaleVariationRate = scaleVariationRate
                    })
            .Subscribe(current =>
            {
                Vector2 centerPosition = camera
                        .ScreenToWorldPoint(current.Center);

                _centerObject.transform.position = centerPosition;

                camera.orthographicSize =
                    firstOrthographicSize / current.Scale;

                Vector2 centerTranslate =
                   (centerPosition -
                            (Vector2)transform.position) *
                        (current.ScaleVariationRate - 1);

                transform.position += (Vector3)centerTranslate;
            });
```

<dl>
<dt>Zip(scaleVariationRateStream, (scaling, scaleVariationRate) => new { Center = scaling.Center, Scale = scaling.Scale, ScaleVariationRate = scaleVariationRate })</dt>
<dd>PinchGestureから受け取ったScalingと、scaleVariationRateStreamで計算した、拡縮率の変化率をまとめます。</dd>
<dt>Subscribe</dt>
<dd>Scalingに含まれる中心点に球を移動させ、拡縮率に応じてCameraのビューポイントのサイズを変更します。また、中心点を中心に拡大・縮小されて見えるように、拡縮率の変化率を元にCameraを平行移動させます。</dd>
</dl>

# 参考文献

<dl>
<dt><a href="http://ninjinkun.hatenablog.com/entry/introrxja">【翻訳】あなたが求めていたリアクティブプログラミング入門</a></dt>
<dd>リアクティブプログラミングの入門に役立ちました。</dd>
<dt><a href="http://www.slideshare.net/torisoup/unity-unirx">未来のプログラミング技術をUnityで -UniRx-</a></dt>
<dd>UniRxを使用したコード例が豊富に記載されています。</dd>
<dt><a href="http://reactivex.io/">ReactiveX</a></dt>
<dd>Rxのオペレータを探す際に参考にしました。</dd>
<dt><a href="http://narudesign.com/devlog/unity-pinch-in-out/">Unity3D:任意の点を中心に拡大縮小(ピンチイン/アウト)</dt>
<dd>ピンチイン、ピンチアウトの実装の際に参考にしました。</dd>
</dt>