#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は以下のような関係でした。
ScrollViewの構成
ScrollViewでは、以下のようになっています。
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 も直接参照されている。
ScrollView, Viewport, Content:ScrollView が RectTransform を操作していることがInspectorで確認できるのは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; } }
(以上)