ゲーム化!tomo_manaのブログ

ゲーム化!tomo-manaのブログ

Unityでゲームを作る方法について紹介しています

Unity学習#26-6 ScrollRect (Unity 2019.4.4f1)

#26 の実装にあたって、少しLayoutについて調べた内容を備忘のためにまとめます。今回は、ScrollRect で ILayoutGroup、ILayoutElement がどのように活用されているのかを調べました。

概要

(1) ScrollRect は ILayoutGroup だけで成り立っている(SetLayoutHorizontal/Vertical() をイベントトリガとして活用)
(2) Image と Mask はセットで動作する
(3) ScrollRect は Content も操作する(ILayoutGroupの更新時とScrollbar操作時)


ScrollViewとLayout

最初に、ScrollView での ILayoutGroup、ILayoutElement の実装について。

一般的なレイアウトの構成

HorizontalまたはVerticalLayoutGroupでは、ILayoutGroupとILayoutElementは以下のような関係でした。

f:id:tomo_mana:20210210231956p:plain
Layoutテンプレート

ScrollViewの構成

ScrollViewでは、以下のようになっています。

f:id:tomo_mana:20210503003618p:plain
ScrollView における Layout

ScrollView の ILayoutGroup は ScrollRect です。ScrollRectは子ゲームオブジェクトのILayoutElementの情報を参照しません。

(1) ScrollViewでは、ImageがILayoutElementを持っているものの、Imageが無くてもスクロール機能が使用できます。そのため、ScrollRect は ILayoutGroup だけを使ったレイアウトであると分かります。(なお、ScrollRect は ILayoutElement も実装していますが、ILayoutElement としては何もしていない)

(2) また、ScrollViewは基本的にViewportにImageとMaskをセットで持っています。ImageはILayoutElementですが、ScrollRect は Image の preferred width/height を参照しません

(3) ScrollRectは孫階層であるContentへの参照を持っています。ScrollRectはILayoutGroupのイベントであるSetLayoutHorizontal()/Vertical() と、Scrollbar 用のイベントである OnDrag()/OnBeginDrag()/OnScroll() で Content の Anchor情報を参照します(表示位置を変更するため)


ScrollRect:Viewport は ILayoutElement としてでなく RectTransform として直接参照されている。Content も直接参照されている。

f:id:tomo_mana:20210503225250p:plain
ScrollView - ScrollRect

ScrollView, Viewport, Content:ScrollView が RectTransform を操作していることがInspectorで確認できるのはViewportだけ。Content も実際は操作されている。

f:id:tomo_mana:20210503225307p:plain
RectTransform - ScrollView, Viewport, Content

(1) ScrollView と ILayoutGroup

ScrollRect は ILayoutElement と ILayoutGroup を実装しているので、CalcLayoutInputHorizontal()/Vertical() と SetLayoutHorizontal()/Vertical() を受け取ります。以前の記事で、Vertical/HorizontalLayoutGroup では、ILayoutGroup は自分のmin/preferred/flexible width/height 情報を使わないことが分かっています。

ScrollRectは、この2つのメッセージを受けた時にどうしているか。

CalcLayoutInputHorizontal/Vertical

何もしません。

public virtual void CalculateLayoutInputHorizontal() {}
public virtual void CalculateLayoutInputVertical() {}

SetLayoutHorizontal/Vertical

Viewport:anchorMin/Max, sizeDelta, anchordPosition を更新します。
Contentのサイズを使ってViewportの大きさを変更する(Scrollbarの表示/非表示)

public virtual void SetLayoutHorizontal()
{
    if (m_HSliderExpand || m_VSliderExpand)
    {
        LayoutRebuilder.ForceRebuildLayoutImmediate(content);
        m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);
    }
    if (m_VSliderExpand && vScrollingNeeded)
    {
        viewRect.sizeDelta = new Vector2(-(m_VSliderWidth + m_VerticalScrollbarSpacing), viewRect.sizeDelta.y);
       
        LayoutRebuilder.ForceRebuildLayoutImmediate(content);
        m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);
    }
    if (m_HSliderExpand && hScrollingNeeded)
    {
        viewRect.sizeDelta = new Vector2(viewRect.sizeDelta.x, -(m_HSliderHeight + m_HorizontalScrollbarSpacing));
        m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);
    }
    if (m_VSliderExpand && vScrollingNeeded && viewRect.sizeDelta.x == 0 && viewRect.sizeDelta.y < 0)
    {
        viewRect.sizeDelta = new Vector2(-(m_VSliderWidth + m_VerticalScrollbarSpacing), viewRect.sizeDelta.y);
    }
}
public virtual void SetLayoutVertical()
{
    UpdateScrollbarLayout();
    m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);
}

代入された m_ViewBounds は NormalizedPosition(スクロール位置)を決める時に使われます。

ここから、ScrollView は ILayoutGroup しか使っていないことと、ILayoutGroup の SetLayoutHorizontal()/Vertical() を Viewport の大きさを変更するために使っていることが分かります。

(2) Mask と Image (Viewport)

Mask処理は、今回知りたかったLayoutGroup/Elementと少し異なるため、ここでは触れませんが、MaskはImageとセットで動作します(Mask、Imageのどちらかが欠けても、クリッピング処理は正常に働かない)。

クリッピング処理はどうやって実現しているか気になるので、またいつか調べようと思いますが、今のところmaskとimageはセットで初めて動作して、どちらかが無いとマスクが実現できないことだけ分かりました。

(3) Content の更新

Scrollview のもう一つの謎は Content(RectTransform) への直接参照でした。Content は、レイアウト変更(ScrollViewの大きさが変更された時)と、スクロールバーが操作されたときの両方でアンカー情報が更新されます。調べてから振り返ってみると、確かにそういった使われ方をされるだろう、と思いました(調べるまでもなかった?)

レイアウト変更時

Content が ILayoutGroup を持っていた場合のために、LayoutRebuilder に Content を渡します。

public virtual void SetLayoutHorizontal()
{
    LayoutRebuilder.ForceRebuildLayoutImmediate(content);
    if (m_VSliderExpand && vScrollingNeeded)
    {
        LayoutRebuilder.ForceRebuildLayoutImmediate(content);
    }
}

スクロールバーが操作された時

ざっくりですが、OnDrag()/OnBeginDrag() と OnScroll() それぞれで、Content の Anchor を参照します。

public virtual void OnScroll(PointerEventData data)
{
    Vector2 position = m_Content.anchoredPosition;
    if (m_MovementType == MovementType.Clamped)
        position += CalculateOffset(position - m_Content.anchoredPosition);
}
public virtual void OnBeginDrag(PointerEventData eventData)
{
    m_ContentStartPosition = m_Content.anchoredPosition;
}
public virtual void OnDrag(PointerEventData eventData)
{
    Vector2 offset = CalculateOffset(position - m_Content.anchoredPosition);
}

参照された Anchor を使って、SetContentAnchordPosition()、SetNormalizedPosition() で Content の Anchor と 座標を更新しています。

protected virtual void SetContentAnchoredPosition(Vector2 position)
{
    if (!m_Horizontal)
        position.x = m_Content.anchoredPosition.x;
    if (!m_Vertical)
        position.y = m_Content.anchoredPosition.y;

    if (position != m_Content.anchoredPosition)
    {
        m_Content.anchoredPosition = position;
    }
}
protected virtual void SetNormalizedPosition(float value, int axis)
{
    float hiddenLength = m_ContentBounds.size[axis] - m_ViewBounds.size[axis];
    float newLocalPosition = m_Content.localPosition[axis] + contentBoundsMinPosition - m_ContentBounds.min[axis];
    Vector3 localPosition = m_Content.localPosition;
    if (Mathf.Abs(localPosition[axis] - newLocalPosition) > 0.01f)
    {
        m_Content.localPosition = localPosition;
    }
}

(以上)