uGUI 選択オブジェクトが描画範囲に収まるようにScrollRectで自動スクロールする

# やりたいこと

uGUIのScrollRectのスクロールをEventSystemで選択しているオブジェクトに自動追従させてスクロールするようにします。

なぜこのような処理が必要かと言うと、何もせずにただスクロールビューを作っただけでは次のような挙動になってしまいます。
![111.gif](https://qiita-image-store.s3.amazonaws.com/0/47146/d87e9539-4b7a-36d1-39e4-a5395d216f51.gif)
（選択オブジェクトが描画範囲外に出てもスクロールが追従してくれない）

これを次のように、選択しているオブジェクトに合わせて自動スクロールしてくれるようにします。
![auto1.gif](https://qiita-image-store.s3.amazonaws.com/0/47146/929f2d2b-c5bf-34e8-e165-3b404a2125c7.gif)


# 前提条件

次の様な構成でUIが構築されているとします。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/47146/1156e21e-7ba8-4502-232c-7afa38082626.png)

* [ScrollView] - [Viewport] - [Content] - [Node] の親子関係で配置
* Contentオブジェクト以下にNode（リストの1要素）が配置される
* Node配置には `Vertical Layout Group` を利用
* NodeはPrefab化されており、次の`Node.cs`スクリプトがアタッチされている


```csharp:Node.cs
using UnityEngine;

namespace ScrollUI
{
    class Node : MonoBehaviour
    {
        /// <summary>
        /// NodeのIndex番号
        /// </summary>
        public int NodeNumber { get { return transform.GetSiblingIndex(); } }
    }
}

```

`Node.cs` はリスト表示における要素の順列のIndex番号を返すようにしています。Indexの計算方法は今回はTransformの要素順序と一致するためこの方法で取得していますが、実装によっては別のものに差し替える必要があるかと思います。

# 自動スクロールするスクリプト

次のスクリプトを適当なGameObjectに貼り付けて設定することで自動スクロールしてくれるようになります。
(UniRxをしれっと使ってますが、必須ではないので剥がしてもらっても大丈夫です）

```csharp:FollowSelectedNode
using UniRx;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace ScrollUI
{
    public class FollowSelectedNode : MonoBehaviour
    {
        /// <summary>
        /// ScrollRectコンポーネント
        /// </summary>
        [SerializeField]
        private ScrollRect _scrollRect;

        /// <summary>
        /// スクロールエリアのRectTransform
        /// </summary>
        [SerializeField]
        private RectTransform _viewportRectransform;

        /// <summary>
        /// Nodeを格納するTransform
        /// </summary>
        [SerializeField]
        private Transform _contentTransform;

        /// <summary>
        /// NodeのRectTransform
        /// </summary>
        [SerializeField]
        private RectTransform _nodePrefab;

        /// <summary>
        /// VerticalLayoutGroup(Spacing取得用)
        /// </summary>
        [SerializeField]
        private VerticalLayoutGroup _verticalLayoutGroup;

        void Start()
        {
            //選択中のオブジェクトが変化したら選択中のNodeのIndexを取得してスクロールさせる
            EventSystem.current
                .ObserveEveryValueChanged(x => x.currentSelectedGameObject)
                .Select(x => x != null ? x.GetComponent<Node>() : null)
                .Where(x => x != null)
                .Subscribe(x => Scroll(x.NodeNumber))
                .AddTo(this);
        }

        /// <summary>
        /// 自動スクロール
        /// </summary>
        void Scroll(int nodeIndex)
        {
            //要素間の間隔
            var spacing = _verticalLayoutGroup.spacing;
            //現在のスクロール範囲の数値を計算しやすい様に上下反転
            var p = 1.0f - _scrollRect.verticalNormalizedPosition;
            //現在の要素数
            var nodeCount = _contentTransform.childCount;
            //描画範囲のサイズ
            var viewportSize = _viewportRectransform.sizeDelta.y;
            //描画範囲のサイズの半分
            var harlViewport = viewportSize * 0.5f;

            //１要素のサイズ
            var nodeSize = _nodePrefab.sizeDelta.y + spacing;

            //現在の描画範囲の中心座標
            var centerPosition = (nodeSize * nodeCount - viewportSize) * p + harlViewport;
            //現在の描画範囲の上端座標
            var topPosition = centerPosition - harlViewport;
            //現在の現在描画の下端座標
            var bottomPosition = centerPosition + harlViewport;

            // 現在選択中の要素の中心座標
            var nodeCenterPosition = nodeSize * nodeIndex + nodeSize / 2.0f;

            //選択した要素が上側にはみ出ている
            if (topPosition > nodeCenterPosition)
            {
                //選択要素が描画範囲に収まるようにスクロール
                var newP = (nodeSize * nodeIndex) / (nodeSize * nodeCount - viewportSize);
                _scrollRect.verticalNormalizedPosition = 1.0f - newP; //反転していたので戻す
                return;
            }

            //選択した要素が下側にはみ出ている
            if (nodeCenterPosition > bottomPosition)
            {
                //選択要素が描画範囲に収まるようにスクロール
                var newP = (nodeSize * (nodeIndex + 1) + spacing - viewportSize) / (nodeSize * nodeCount - viewportSize);
                _scrollRect.verticalNormalizedPosition = 1.0f - newP; //反転していたので戻す
            }
        }
    }
}
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/47146/8e61f234-8b47-7aae-8c23-dc2a05172979.png)

## 動作

![auto1.gif](https://qiita-image-store.s3.amazonaws.com/0/47146/929f2d2b-c5bf-34e8-e165-3b404a2125c7.gif)
要素に合わせて自動スクロール

![auto2.gif](https://qiita-image-store.s3.amazonaws.com/0/47146/d4bcc5c0-44f6-db6c-56e6-f54bbd032512.gif)
Spacingが設定されていても大丈夫


## 解説

ScrollRectのViewport（描画範囲）サイズとNode1つ当たりの描画サイズを用いて、現在選択しているNodeがスクロールビューの描画範囲に収まっているかを計算します。描画範囲に収まってるようなら何もせず、はみ出しているようならぴったり収まるスクロール位置を計算してスクロールさせています。

#おまけ： 計算式の導出

## 1. 現在の描画範囲の計算

![1.png](https://qiita-image-store.s3.amazonaws.com/0/47146/96e19564-d5c6-f3aa-9a0e-c230c01c4e52.png)


`ScrollRect.verticalNormalizedPosition` は正規化されスクロールの度合いを表すパラメータである。
この`verticalNormalizedPosition` スクロール度合いを 1.0 ～ 0.0 の範囲で返すのですが、計算しにくいのでこれを反転して0.0～1.0にした「**スクロール量P**」を定義する。

このPを用いると、現在の**描画領域の中心座標C**は**描画領域のサイズR**と**スクロールビュー全長L**を用いて
`C(P) = (L-R)P + R/2` と表せる。


## 2.スクロールビュー全長と要素数の関係

![2.png](https://qiita-image-store.s3.amazonaws.com/0/47146/87e455ee-cb70-abf4-1727-03d5d9eb7b19.png)

リスト内の1つの**要素のサイズn**と、スクロールビューに格納されている**要素数N**を用いることで、スクロールビュー全長Lは
`L = nN` となる。

## 3.現在選択中の要素番号iと描画位置の関係

![3.png](https://qiita-image-store.s3.amazonaws.com/0/47146/3e52fe24-6c3d-8c76-1583-bf01dbb3b386.png)

現在UIで**選択しているオブジェクトの番号i**と要素のサイズnを用いることで、 **選択中の要素の中心座標t** は
`t = ni + n/2` と表せる。

## 4. 1～3より、選択中の要素が範囲内に描画される条件を求める

![4.png](https://qiita-image-store.s3.amazonaws.com/0/47146/a423c2be-b2e5-482e-7365-25139a1602d6.png)

1～3より、選択中の要素iが描画範囲に収まっているかは次の不等式が成り立つかで判定ができる。

`(nN -R)P < ni + n/2 < (nN-R)P + R`

## 5. 不等式を満たす時

描画範囲に収まっているのでスクロール量は変化させなくてよい

##6. 上端からはみ出ている時

![6.png](https://qiita-image-store.s3.amazonaws.com/0/47146/dc0e7886-1f12-f9c3-cc35-57b943e9bc24.png)

上端から要素がはみ出ている時は、4の不等式の左側が成り立たなくなった時である。
この時、選択要素が画面内に収まるようにするためには、要素上端で描画位置を揃えることを考慮した補正を加え、
`(nN-R)P = ni + n/2 + n/2` をPについて解けばよい。

よって、上端からはみ出した時に再設定すべきスクロール量Pは
`P = ni/(nN-R)` となる。

##7. 下端からはみ出ている時

![7.png](https://qiita-image-store.s3.amazonaws.com/0/47146/a0f95c96-e9e8-5d0b-0e4c-d107b975f555.png)

 
下端から要素がはみ出ている時は、4の不等式の右側が成り立たなくなった時である。
この時、選択要素が画面内に収まるようにするためには、要素下端で描画位置を揃えることを考慮した補正を加え、
`(nN-R)P = ni + n/2 - n/2` をPについて解けばよい。

よって、上端からはみ出した時に再設定すべきスクロール量Pは
`P = ((i+1)n-R)/(nN-R)` となる。

## 以上を踏まえて

```csharp
/// <summary>
/// 自動スクロール
/// </summary>
void Scroll(int nodeIndex)
{
    //要素間の間隔
    var spacing = _verticalLayoutGroup.spacing;
    //現在のスクロール範囲の数値を計算しやすい様に上下反転
    var p = 1.0f - _scrollRect.verticalNormalizedPosition;
    //現在の要素数
    var nodeCount = _contentTransform.childCount;
    //描画範囲のサイズ
    var viewportSize = _viewportRectransform.sizeDelta.y;
    //描画範囲のサイズの半分
    var harlViewport = viewportSize * 0.5f;

    //１要素のサイズ
    var nodeSize = _nodePrefab.sizeDelta.y + spacing;

    //現在の描画範囲の中心座標
    var centerPosition = (nodeSize * nodeCount - viewportSize) * p + harlViewport;
    //現在の描画範囲の上端座標
    var topPosition = centerPosition - harlViewport;
    //現在の現在描画の下端座標
    var bottomPosition = centerPosition + harlViewport;

    // 現在選択中の要素の中心座標
    var nodeCenterPosition = nodeSize * nodeIndex + nodeSize / 2.0f;

    //選択した要素が上側にはみ出ている
    if (topPosition > nodeCenterPosition)
    {
        //選択要素が描画範囲に収まるようにスクロール
        var newP = (nodeSize * nodeIndex) / (nodeSize * nodeCount - viewportSize);
        _scrollRect.verticalNormalizedPosition = 1.0f - newP; //反転していたので戻す
        return;
    }

    //選択した要素が下側にはみ出ている
    if (nodeCenterPosition > bottomPosition)
    {
        //選択要素が描画範囲に収まるようにスクロール
        var newP = (nodeSize * (nodeIndex + 1) + spacing - viewportSize) / (nodeSize * nodeCount - viewportSize);
        _scrollRect.verticalNormalizedPosition = 1.0f - newP; //反転していたので戻す
    }
}
```

# 所感

自分はUIの実装をしていたはずなのに気づいたら計算式を解いていた