前言

ScrollBarScrollRect 通常配合使用以实现滚动视图。ScrollBar 用于控制 ScrollRect 的滚动位置,而 ScrollRect 用于显示滚动内容。本文将介绍这两个组件的实现。

ScrollRect.gif

ScrollBar

image.png

ScrollBar 的作用实际上与 Slider 十分类似,代码的实现也大同小异。但就使用上的区别而言,Slider 主要用于数值的调整,而 ScrollBar 则大部分情况下用于滚动条。

ScrollBar 的属性

image.png

ScrollBar 的属性与 Slider 的属性基本一致,都有一个 OnValueChanged 事件,用于监听值的变化。不同之处在于 Slider 除了 HandleRect 之外还有一个 FillRect 用于填充的显示,而 ScrollBar 只有 HandleRect

ScrollBar 的交互

ScrollBar 也实现了 OnXXXDrag 系列事件以及 OnPointerXXX 系列事件,用于处理拖拽以及点击的交互。

拖拽类事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public virtual void OnBeginDrag(PointerEventData eventData)
{
isPointerDownAndNotDragging = false;

if (!MayDrag(eventData))
return;

if (m_ContainerRect == null)
return;

m_Offset = Vector2.zero;
if (RectTransformUtility.RectangleContainsScreenPoint(m_HandleRect, eventData.position, eventData.enterEventCamera))
{
Vector2 localMousePos;
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(m_HandleRect, eventData.position, eventData.pressEventCamera, out localMousePos))
m_Offset = localMousePos - m_HandleRect.rect.center;
}
}

public virtual void OnDrag(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;

if (m_ContainerRect != null)
UpdateDrag(eventData);
}

这里同样会计算 m_Offset 用于记录偏移量,然后在 OnDrag() 事件中调用 UpdateDrag() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void UpdateDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;

if (m_ContainerRect == null)
return;

Vector2 localCursor;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(m_ContainerRect, eventData.position, eventData.pressEventCamera, out localCursor))
return;

Vector2 handleCenterRelativeToContainerCorner = localCursor - m_Offset - m_ContainerRect.rect.position;
Vector2 handleCorner = handleCenterRelativeToContainerCorner - (m_HandleRect.rect.size - m_HandleRect.sizeDelta) * 0.5f;

float parentSize = axis == 0 ? m_ContainerRect.rect.width : m_ContainerRect.rect.height;
float remainingSize = parentSize * (1 - size);
if (remainingSize <= 0)
return;

DoUpdateDrag(handleCorner, remainingSize);
}

private void DoUpdateDrag(Vector2 handleCorner, float remainingSize)
{
switch (m_Direction)
{
case Direction.LeftToRight:
Set(Mathf.Clamp01(handleCorner.x / remainingSize));
break;
case Direction.RightToLeft:
Set(Mathf.Clamp01(1f - (handleCorner.x / remainingSize)));
break;
case Direction.BottomToTop:
Set(Mathf.Clamp01(handleCorner.y / remainingSize));
break;
case Direction.TopToBottom:
Set(Mathf.Clamp01(1f - (handleCorner.y / remainingSize)));
break;
}
}

UpdateDrag() 中会根据拖拽位置计算出填充值,并且调用 DoUpdateDrag() 方法分四个方向进行处理。仔细一想这两个函数的命名也有点梗在里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
void Set(float input, bool sendCallback = true)
{
float currentValue = m_Value;

// bugfix (case 802330) clamp01 input in callee before calling this function, this allows inertia from dragging content to go past extremities without being clamped
m_Value = input;

// If the stepped value doesn't match the last one, it's time to update
if (currentValue == value)
return;

UpdateVisuals();
if (sendCallback)
{
UISystemProfilerApi.AddMarker("Scrollbar.value", this);
m_OnValueChanged.Invoke(value);
}
}

private void UpdateVisuals()
{
#if UNITY_EDITOR
if (!Application.isPlaying)
UpdateCachedReferences();
#endif
m_Tracker.Clear();

if (m_ContainerRect != null)
{
m_Tracker.Add(this, m_HandleRect, DrivenTransformProperties.Anchors);
Vector2 anchorMin = Vector2.zero;
Vector2 anchorMax = Vector2.one;

float movement = Mathf.Clamp01(value) * (1 - size);
if (reverseValue)
{
anchorMin[(int)axis] = 1 - movement - size;
anchorMax[(int)axis] = 1 - movement;
}
else
{
anchorMin[(int)axis] = movement;
anchorMax[(int)axis] = movement + size;
}

m_HandleRect.anchorMin = anchorMin;
m_HandleRect.anchorMax = anchorMax;
}
}

Set() 函数中也是更新了 m_Value 的值,并且调用 UpdateVisuals() 方法更新显示。更新显示的方法依然是我们熟悉的调整 HandleRectanchorMinanchorMax

点击类事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public override void OnPointerDown(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;

base.OnPointerDown(eventData);
isPointerDownAndNotDragging = true;
m_PointerDownRepeat = StartCoroutine(ClickRepeat(eventData));
}

protected IEnumerator ClickRepeat(PointerEventData eventData)
{
while (isPointerDownAndNotDragging)
{
if (!RectTransformUtility.RectangleContainsScreenPoint(m_HandleRect, eventData.position, eventData.enterEventCamera))
{
Vector2 localMousePos;
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(m_HandleRect, eventData.position, eventData.pressEventCamera, out localMousePos))
{
var axisCoordinate = axis == 0 ? localMousePos.x : localMousePos.y;
float change = axisCoordinate < 0 ? size : -size;
value += reverseValue ? change : -change;
}
}
yield return new WaitForEndOfFrame();
}
StopCoroutine(m_PointerDownRepeat);
}

public override void OnPointerUp(PointerEventData eventData)
{
base.OnPointerUp(eventData);
isPointerDownAndNotDragging = false;
}

点击事件的处理与 Slider 类似,当点击区域在 HandleRect 之外时,会根据点击位置直接调整 value 的值。

ScrollRect

ScrollRect 是一个比较复杂的组件,经常被用于背包界面,排行榜界面等需要滚动的场景。

image.png

image.png

当我们在场景中新建一个 ScrollRect 时,可以看到该组件实际上由一系列的子组件组成。

首先,总体的 gameObject 名称为 ScrollView,该物体挂载了一个 Image 组件用于显示背景,以及一个 ScrollRect 组件用于控制滚动。ScrollView 下有三个子物体,其中 Viewport 用于显示滚动内容,Scrollbar Horizontal 以及 Scrollbar Vertical 都是 ScrollBar 组件,用于控制滚动位置。

Viewport 即为滚动内容的显示区域了,它自身挂载了一个 Mask 组件,用于裁剪显示区域,从而达到在一个固定的区域中显示的效果。Viewport 下有一个 Content 物体,一般而言,我们会将滚动的内容放在这个物体下,并且为 Content 挂载一些布局组件,如 VerticalLayoutGroup 或者 GridLayoutGroup 等。

image.png

ScrollRect 本身也有一系列可以配置的属性。

image.png

其中,Content 就是对上述的 Content 物体的 RectTransform 的引用,ScrollRect 会通过改变 Content 的 transform 属性来完成滚动的效果HorizontalVertical 分别表示是否可以水平滚动和垂直滚动;而其他的属性均为滚动行为的配置,如是否有惯性,是否有弹性等。

ScrollRect 的生命周期函数

OnEnable

1
2
3
4
5
6
7
8
9
10
11
12
protected override void OnEnable()
{
base.OnEnable();

if (m_HorizontalScrollbar)
m_HorizontalScrollbar.onValueChanged.AddListener(SetHorizontalNormalizedPosition);
if (m_VerticalScrollbar)
m_VerticalScrollbar.onValueChanged.AddListener(SetVerticalNormalizedPosition);

CanvasUpdateRegistry.RegisterCanvasElementForLayoutRebuild(this);
SetDirty();
}

OnEnable() 函数中,会分别为 HorizontalScrollbarVerticalScrollbaronValueChanged 事件添加回调函数,用于将滚动条的值传递给自身。同时也会更新自身的布局。即 ScrollRectScrollBar 的交互是通过向 ScrollBaronValueChanged 事件中添加回调函数来实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void SetHorizontalNormalizedPosition(float value) { SetNormalizedPosition(value, 0); }
private void SetVerticalNormalizedPosition(float value) { SetNormalizedPosition(value, 1); }

protected virtual void SetNormalizedPosition(float value, int axis)
{
EnsureLayoutHasRebuilt();
UpdateBounds();
// How much the content is larger than the view.
float hiddenLength = m_ContentBounds.size[axis] - m_ViewBounds.size[axis];
// Where the position of the lower left corner of the content bounds should be, in the space of the view.
float contentBoundsMinPosition = m_ViewBounds.min[axis] - value * hiddenLength;
// The new content localPosition, in the space of the view.
float newLocalPosition = m_Content.localPosition[axis] + contentBoundsMinPosition - m_ContentBounds.min[axis];

Vector3 localPosition = m_Content.localPosition;
if (Mathf.Abs(localPosition[axis] - newLocalPosition) > 0.01f)
{
localPosition[axis] = newLocalPosition;
m_Content.localPosition = localPosition;
m_Velocity[axis] = 0;
UpdateBounds();
}
}

SetNormalizedPosition() 中,会将滚动条的值转换为 Content 的位置,然后调整 ContentlocalPosition 以实现滚动效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public virtual void Rebuild(CanvasUpdate executing)
{
if (executing == CanvasUpdate.Prelayout)
{
UpdateCachedData();
}

if (executing == CanvasUpdate.PostLayout)
{
UpdateBounds();
UpdateScrollbars(Vector2.zero);
UpdatePrevData();

m_HasRebuiltLayout = true;
}
}

Rebuild() 函数中,会在 Prelayout 阶段更新缓存数据,然后在 PostLayout 阶段更新布局及滚动条信息。

OnDisable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected override void OnDisable()
{
CanvasUpdateRegistry.UnRegisterCanvasElementForRebuild(this);

if (m_HorizontalScrollbar)
m_HorizontalScrollbar.onValueChanged.RemoveListener(SetHorizontalNormalizedPosition);
if (m_VerticalScrollbar)
m_VerticalScrollbar.onValueChanged.RemoveListener(SetVerticalNormalizedPosition);

m_Dragging = false;
m_Scrolling = false;
m_HasRebuiltLayout = false;
m_Tracker.Clear();
m_Velocity = Vector2.zero;
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
base.OnDisable();
}

OnDisable() 函数中,会移除 HorizontalScrollbarVerticalScrollbar 的回调函数,并且清空一些状态。

LateUpdate

ScrollRectLateUpdate 中处理了滚动的减速以及位置限制等逻辑。此外,OnValueChanged 事件也会由 LateUpdate 逐帧触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
protected virtual void LateUpdate()
{
if (!m_Content)
return;

EnsureLayoutHasRebuilt();
UpdateBounds();
float deltaTime = Time.unscaledDeltaTime;
Vector2 offset = CalculateOffset(Vector2.zero);
if (!m_Dragging && (offset != Vector2.zero || m_Velocity != Vector2.zero))
{
Vector2 position = m_Content.anchoredPosition;
for (int axis = 0; axis < 2; axis++)
{
// Apply spring physics if movement is elastic and content has an offset from the view.
if (m_MovementType == MovementType.Elastic && offset[axis] != 0)
{
float speed = m_Velocity[axis];
float smoothTime = m_Elasticity;
if (m_Scrolling)
smoothTime *= 3.0f;
position[axis] = Mathf.SmoothDamp(m_Content.anchoredPosition[axis], m_Content.anchoredPosition[axis] + offset[axis], ref speed, smoothTime, Mathf.Infinity, deltaTime);
if (Mathf.Abs(speed) < 1)
speed = 0;
m_Velocity[axis] = speed;
}
// Else move content according to velocity with deceleration applied.
else if (m_Inertia)
{
m_Velocity[axis] *= Mathf.Pow(m_DecelerationRate, deltaTime);
if (Mathf.Abs(m_Velocity[axis]) < 1)
m_Velocity[axis] = 0;
position[axis] += m_Velocity[axis] * deltaTime;
}
// If we have neither elaticity or friction, there shouldn't be any velocity.
else
{
m_Velocity[axis] = 0;
}
}

if (m_MovementType == MovementType.Clamped)
{
offset = CalculateOffset(position - m_Content.anchoredPosition);
position += offset;
}

SetContentAnchoredPosition(position);
}

if (m_Dragging && m_Inertia)
{
Vector3 newVelocity = (m_Content.anchoredPosition - m_PrevPosition) / deltaTime;
m_Velocity = Vector3.Lerp(m_Velocity, newVelocity, deltaTime * 10);
}

if (m_ViewBounds != m_PrevViewBounds || m_ContentBounds != m_PrevContentBounds || m_Content.anchoredPosition != m_PrevPosition)
{
UpdateScrollbars(offset);
UISystemProfilerApi.AddMarker("ScrollRect.value", this);
m_OnValueChanged.Invoke(normalizedPosition);
UpdatePrevData();
}
UpdateScrollbarVisibility();
m_Scrolling = false;
}

ScrollRect 的交互

ScrollRect 主要实现了对拖拽以及滚动事件的处理。注意,这里的拖拽与滚动指的都是对视图的操作,而不是对滚动条。

ScrollRectUse.gif

拖拽事件

1
2
3
4
5
6
7
public virtual void OnInitializePotentialDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;

m_Velocity = Vector2.zero;
}

OnInitializePotentialDrag() 用于处理拖拽开始时的初始化,这里会将速度置为 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public virtual void OnBeginDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;

if (!IsActive())
return;

UpdateBounds();

m_PointerStartLocalCursor = Vector2.zero;
RectTransformUtility.ScreenPointToLocalPointInRectangle(viewRect, eventData.position, eventData.pressEventCamera, out m_PointerStartLocalCursor);
m_ContentStartPosition = m_Content.anchoredPosition;
m_Dragging = true;
}

开始拖拽时,会将 m_PointerStartLocalCursor 置为 0,并且记录 Content 的起始位置 m_ContentStartPosition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public virtual void OnDrag(PointerEventData eventData)
{
if (!m_Dragging)
return;

if (eventData.button != PointerEventData.InputButton.Left)
return;

if (!IsActive())
return;

Vector2 localCursor;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(viewRect, eventData.position, eventData.pressEventCamera, out localCursor))
return;

UpdateBounds();

var pointerDelta = localCursor - m_PointerStartLocalCursor;
Vector2 position = m_ContentStartPosition + pointerDelta;

// Offset to get content into place in the view.
Vector2 offset = CalculateOffset(position - m_Content.anchoredPosition);
position += offset;
if (m_MovementType == MovementType.Elastic)
{
if (offset.x != 0)
position.x = position.x - RubberDelta(offset.x, m_ViewBounds.size.x);
if (offset.y != 0)
position.y = position.y - RubberDelta(offset.y, m_ViewBounds.size.y);
}

SetContentAnchoredPosition(position);
}

拖拽过程中会根据鼠标所在的位置计算出本次拖拽的偏移值 pointerDelta,然后根据 Content 的起始位置以及偏移值计算出新的位置,并且调用 SetContentAnchoredPosition() 方法设置 Content 的位置。这里针对 Elastic 模式做了特殊处理,即当超出边界时会有一个回弹的效果。

1
2
3
4
5
6
7
public virtual void OnEndDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;

m_Dragging = false;
}

拖拽结束时,会将 m_Dragging 置为 false

滚动事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public virtual void OnScroll(PointerEventData data)
{
if (!IsActive())
return;

EnsureLayoutHasRebuilt();
UpdateBounds();

Vector2 delta = data.scrollDelta;
// Down is positive for scroll events, while in UI system up is positive.
delta.y *= -1;
if (vertical && !horizontal)
{
if (Mathf.Abs(delta.x) > Mathf.Abs(delta.y))
delta.y = delta.x;
delta.x = 0;
}
if (horizontal && !vertical)
{
if (Mathf.Abs(delta.y) > Mathf.Abs(delta.x))
delta.x = delta.y;
delta.y = 0;
}

if (data.IsScrolling())
m_Scrolling = true;

Vector2 position = m_Content.anchoredPosition;
position += delta * m_ScrollSensitivity;
if (m_MovementType == MovementType.Clamped)
position += CalculateOffset(position - m_Content.anchoredPosition);

SetContentAnchoredPosition(position);
UpdateBounds();
}

滚动事件的处理与拖拽类似,只是这里的偏移值是由滚轮的滚动值决定的。同样会根据 Content 的位置计算出新的位置,并且调用 SetContentAnchoredPosition() 方法设置 Content 的位置。