UIScrollView
文章说明
本篇文章基于NGUI (3.12.0)版本源码下的代码分析,如果代码和大家自己的不同,可能是版本不同。如果文章中分析有误希望大神看见指点迷津,大家好,我就是一个勤勤恳恳爱偷懒的码农,希望和大家一起学习研究。
UIScrollView
在阅读源码前,希望你已经熟练的掌握了基本的 UIScrollView 的用法,如果你还不知道的话建议你先看下这篇文章 UIScrollView 使用
因为 UIScrollView 涉及到的东西蛮多的,感觉一篇文章分享不完,所以我想分成几篇分别来讲一下,今天给大家分享下 UIScrollView 中最重要的滚动部分(其中不包含滑轮,滚动条),看完感觉可以自己动手做一个小 demo 玩一玩是可以的。
1.简述原理
UIScrollView 实现滚动的原理其实是修改 UIPanel 的 localposition 和 Clip 中的 Offset,真正实现移动的其实是 Clip 的 Offset,但是为了 UIScrollView 的相对位置不变,所以需要同时修改 localposition ,如图,注意观察 UIPanel 的坐标和 Offset 变化。
所以我们只需要关心这三个问题即可:
UIScrollView 是什么时候处理滚动的?
UIScrollView 滚动的时候是如何处理 localposition 和 Offset 的位置的?
UIScrollView 如何解决拖动超出显示区域后,它还能弹回来?
只要解决了这三个问题,我们也可以写一个简单的 UIScrollView ,所以带着这些问题我们再去看源码会感觉清晰很多。
2.组件结构属性
先来看看 UIScrollView 外部设置的组件都包含哪些内容,如图所示:
- Content Origin :滚动内容的轴心点;
- Movement :滑动方向,比如水平滑动,垂直滑动;
- Drag Effect :拖拽的效果;
- Scroll Wheel Factor :和鼠标滚轮有关,使用鼠标进行滚动时,会影响滚动的大小;
- Monmentum Amount :滑动时产生的动量;
- Restrict Within Panel :表示是否约束item都尽可能显示在 panel 内部
- Constrain On Drag :滚动视图是否会在每次拖动操作上执行边界内的约束逻辑;
- Cancel Drag If Fits :表示如果item全部都在显示范围内,是否可以取消拖拽效果;
- Smooth Drag Start :表示是否是平滑的拖拽开始;
- IOS Drag Emulation : 是否使用iOS拖动模拟;
- Scroll Bars :这个是用来设置滚动条的;
对于这些参数的意义,建议自己下去修改尝试,尽量每个都测试一遍看看效果。
3.UIScrollView 源码分析
UIScrollView 涵盖的内容很多,第一篇文章就直接看它最核心的部分就好了,了解了它的原理再看它其他拓展功能就会方便很多,所以这里我直接就把最核心的几个函数拿出来分享一下。
1.Press
当我们按下 item 的时候会首先调用这个函数,并且参数 pressed 为 true,当我们松手的时候,也会调用这个函数,参数 pressed 是 false。
源码:
//拖动scrllview之前,需要先执行 Press 表示我们已经按下了 item ,如果玩家不松手,在屏幕中来回滑动,就应该调用 Drag 接口
public void Press (bool pressed)
{
if (mPressed == pressed || UICamera.currentScheme == UICamera.ControlScheme.Controller) return;
//如果 smoothDragStart 设置为true, 每次当前 press 为true的时候都要重置一下数据
if (smoothDragStart && pressed)
{
mDragStarted = false;
mDragStartOffset = Vector2.zero;
}
if (enabled && NGUITools.GetActive(gameObject))
{
if (!pressed && mDragID == UICamera.currentTouchID) mDragID = -10;
mCalculatedBounds = false;
//设置是否可以移动,如果item已经再边缘就不能在移动了
mShouldMove = shouldMove;
if (!mShouldMove) return;
mPressed = pressed;
if (pressed)
{
//按下item,滚动开始 移除所有压力的动力
// Remove all momentum on press
mMomentum = Vector3.zero;
mScroll = 0f;
// Disable the spring movement
//关闭弹簧运动
DisableSpring();
// Remember the hit position
//记录最后一次触摸屏幕的位置
mLastPos = UICamera.lastWorldPosition;
// Create the plane to drag along 创建拖拽的平面
//创建一个平面,平面法线是 mTrans.rotation * Vector3.back 并且,平面要经过点 mLastPos
//法线是为了设置这个平面的角度,但是这个平面必须要与 UIScrollivew 所在的ui平行,所以使用 mTrans.rotation 去获取法线坐标
//mLastPos 这个是为了保证后续发射的射线检测的位置焦点和 上一次所在的点在一个平面
mPlane = new Plane(mTrans.rotation * Vector3.back, mLastPos);
// Ensure that we're working with whole numbers, keeping everything pixel-perfect
//确保我们设置的是整数
Vector2 co = mPanel.clipOffset;
co.x = Mathf.Round(co.x);
co.y = Mathf.Round(co.y);
mPanel.clipOffset = co;
Vector3 v = mTrans.localPosition;
v.x = Mathf.Round(v.x);
v.y = Mathf.Round(v.y);
mTrans.localPosition = v;
if (!smoothDragStart)
{
mDragStarted = true;
mDragStartOffset = Vector2.zero;
if (onDragStarted != null) onDragStarted();
}
}
else if (centerOnChild)
{
//这个可以先忽略不看
if (mDragStarted && onDragFinished != null) onDragFinished();
centerOnChild.Recenter();
}
else
{
if (mDragStarted && restrictWithinPanel && mPanel.clipping != UIDrawCall.Clipping.None)
//对于正在拖动,并且限制item必须在显示范围内,还是裁剪的情况下,当我们取消 press 需要调用这个
RestrictWithinBounds(dragEffect == DragEffect.None, canMoveHorizontally, canMoveVertically);
if (mDragStarted && onDragFinished != null) onDragFinished();
if (!mShouldMove && onStoppedMoving != null)
onStoppedMoving();
}
}
}
//这个主要是在 disableDragIfFits 被设置为ture 的时候用来计算判断
protected virtual bool shouldMove
{
get
{
if (!disableDragIfFits) return true;
//disableDragIfFits 为true 的时候才会进来检查,内容是否合适,如果视图中包含了全部内容,就不用移动
//否则就可以移动
if (mPanel == null) mPanel = GetComponent<UIPanel>();
Vector4 clip = mPanel.finalClipRegion;
Bounds b = bounds;
float hx = (clip.z == 0f) ? Screen.width : clip.z * 0.5f;
float hy = (clip.w == 0f) ? Screen.height : clip.w * 0.5f;
//是否可以水平移动
if (canMoveHorizontally)
{
if (b.min.x + 0.001f < clip.x - hx) return true;//向右拖动,判断左边框向右移动 0.001f 是否会到达边界
if (b.max.x - 0.001f > clip.x + hx) return true;//向左拖动,判断有边框向左移动 0.001f 是否会达到边界
}
//判断是否可以垂直移动
if (canMoveVertically)
{
if (b.min.y + 0.001f < clip.y - hy) return true;//向上拖动,判断底部边框向上移动是否到达边界
if (b.max.y - 0.001f > clip.y + hy) return true;//向下拖动,判断顶部边框向下移动是否到达边界
}
return false;
}
}
2.Drag
当 Press 参数为 true 执行以后,如果使用鼠标继续在屏幕滑动,那么就会调用这个函数,开始计算滚动信息,这里就是 UIScrollView 滑动的核心函数;
源码:
public void Drag ()
{
if (!mPressed || UICamera.currentScheme == UICamera.ControlScheme.Controller) return;
if (enabled && NGUITools.GetActive(gameObject) && mShouldMove)
{
//设置当前拖动的 view 的渲染照相机id
if (mDragID == -10) mDragID = UICamera.currentTouchID;
UICamera.currentTouch.clickNotification = UICamera.ClickNotification.BasedOnDelta;
// Prevents the drag "jump". Contributed by 'mixd' from the Tasharen forums.
if (smoothDragStart && !mDragStarted)
{
mDragStarted = true;
mDragStartOffset = UICamera.currentTouch.totalDelta;
if (onDragStarted != null) onDragStarted();
}
Ray ray = smoothDragStart ?
//创建一个 从摄像机通过屏幕目标点的射线 TODO UICamera.currentTouch.pos - mDragStartOffset?
UICamera.currentCamera.ScreenPointToRay(UICamera.currentTouch.pos - mDragStartOffset) :
UICamera.currentCamera.ScreenPointToRay(UICamera.currentTouch.pos);
float dist = 0f;
//计算从射线原地开始发射的射线与平面相交的距离为 dist,如果射线没有与平面相交(与平面平行),那么返回false,否则返回true,
if (mPlane.Raycast(ray, out dist))
{
//获取从射线原点开始距离 dist, 点的坐标
Vector3 currentPos = ray.GetPoint(dist);
//当前点 - 上一次的点就可以计算出两次移动的距离
Vector3 offset = currentPos - mLastPos;
mLastPos = currentPos;
if (offset.x != 0f || offset.y != 0f || offset.z != 0f)
{
//世界坐标转换成局部坐标的 transform 方向
offset = mTrans.InverseTransformDirection(offset);
if (movement == Movement.Horizontal)
{
offset.y = 0f;
offset.z = 0f;
}
else if (movement == Movement.Vertical)
{
offset.x = 0f;
offset.z = 0f;
}
else if (movement == Movement.Unrestricted)
{
offset.z = 0f;
}
else
{
offset.Scale((Vector3)customMovement);
}
//将方向从局部空间转化成世界空间
offset = mTrans.TransformDirection(offset);
}
// Adjust the momentum 计算动量
if (dragEffect == DragEffect.None) mMomentum = Vector3.zero;
else mMomentum = Vector3.Lerp(mMomentum, mMomentum + offset * (0.01f * momentumAmount), 0.67f);
// Move the scroll view 移动 scrollview
if (!iOSDragEmulation || dragEffect != DragEffect.MomentumAndSpring)
{
MoveAbsolute(offset);
}
else
{
// iOSDragEmulation = true 并且 dragEffect == DragEffect.MomentumAndSpring 的情况下会进来
Vector3 constraint = mPanel.CalculateConstrainOffset(bounds.min, bounds.max);
if (movement == Movement.Horizontal)
{
constraint.y = 0f;
}
else if (movement == Movement.Vertical)
{
constraint.x = 0f;
}
else if (movement == Movement.Custom)
{
constraint.x *= customMovement.x;
constraint.y *= customMovement.y;
}
//如果存在偏移 但是 constraint 没有进行调整,只是将 offset * 0.5,所以不会对偏移的列表做调整
if (constraint.magnitude > 1f)
{
MoveAbsolute(offset * 0.5f);
mMomentum *= 0.5f;
}
else MoveAbsolute(offset);
}
// We want to constrain the UI to be within bounds
//我们希望将UI限制在限定范围内
if (constrainOnDrag && restrictWithinPanel &&
mPanel.clipping != UIDrawCall.Clipping.None &&
dragEffect != DragEffect.MomentumAndSpring)
{
RestrictWithinBounds(true, canMoveHorizontally, canMoveVertically);
}
}
}
}
测试的时候,你会发现 UIScrollView 不会对你的 item 进行对齐(在不改变 item root 的节点下,这个item root 默认坐标全部归零哈),无论我怎么调整参数都不行,可以理解,毕竟 UIScrollView 不会关心你创建的 item 所在的位置,创建多少个,由哪个组件帮助管理,也确实没有一个时机去自动给你进行对齐,所以我们需要自己写一个去消灭这种隐患。
3.RestrictWithinBounds
这个函数主要是用来限制显示内容的,将滚动视图的内容尽量限制在滚动视图的范围内,如果你将这个函数直接 return (相当于不调用),你会发现,最终滚动的效果就会和 “Restrict Within Panel”
被设置为 false 的时候效果差不多,滚动结束后,item 也不会回到 panel 内。
我将 RestrictWithinBounds
和 CalculateConstrainOffset
中的主要思想部分放在下面这张图中,结合这张图再去看源码感觉会比较清晰。
源码:
public bool RestrictWithinBounds(bool instant, bool horizontal, bool vertical)
{
if (mPanel == null) return false;
Bounds b = bounds;
//计算偏移
Vector3 constraint = mPanel.CalculateConstrainOffset(b.min, b.max);
//这两行代码注释掉,运行起来会有新发现,这里我不讲
if (!horizontal) constraint.x = 0f;
if (!vertical) constraint.y = 0f;
if (constraint.sqrMagnitude > 0.1f)
{
if (!instant && dragEffect == DragEffect.MomentumAndSpring)
{
// Spring back into place
//这里其实对偏移做了一些调整,利用 SpringPanel 组件,可以有一个平滑变化的效果
Vector3 pos = mTrans.localPosition + constraint;
pos.x = Mathf.Round(pos.x);
pos.y = Mathf.Round(pos.y);
SpringPanel.Begin(mPanel.gameObject, pos, 8f);
}
else
{
// Jump back into place
MoveRelative(constraint);
// Clear the momentum in the constrained direction
if (Mathf.Abs(constraint.x) > 0.01f) mMomentum.x = 0;
if (Mathf.Abs(constraint.y) > 0.01f) mMomentum.y = 0;
if (Mathf.Abs(constraint.z) > 0.01f) mMomentum.z = 0;
mScroll = 0f;
}
return true;
}
return false;
}
4.CalculateConstrainOffset
这个函数主要在 UIPanel 中,放到这里来讲,感觉更能表达它的含义,同时还涉及一个外部函数 “ConstrainRect”
,它位于 "NGUIMath"
中,这个是真正计算偏移的核心代码,一同放下面啦。
源码:
public virtual Vector3 CalculateConstrainOffset (Vector2 min, Vector2 max)
{
Vector4 cr = finalClipRegion;
float offsetX = cr.z * 0.5f;
float offsetY = cr.w * 0.5f;
Vector2 minRect = new Vector2(min.x, min.y); //计算显示矩形框中的左下角顶点坐标
Vector2 maxRect = new Vector2(max.x, max.y); //计算显示矩形框中的右上角顶点坐标
Vector2 minArea = new Vector2(cr.x - offsetX, cr.y - offsetY); //计算显示区域的左下角顶点坐标
Vector2 maxArea = new Vector2(cr.x + offsetX, cr.y + offsetY); //计算显示区域的右上角顶点坐标
if (softBorderPadding && clipping == UIDrawCall.Clipping.SoftClip)
{
//softBorderPadding 默认为 true,默认显示内矩形框的左下角的右上角
minArea.x += mClipSoftness.x;
minArea.y += mClipSoftness.y;
maxArea.x -= mClipSoftness.x;
maxArea.y -= mClipSoftness.y;
}
return NGUIMath.ConstrainRect(minRect, maxRect, minArea, maxArea);
}
static public Vector2 ConstrainRect (Vector2 minRect, Vector2 maxRect, Vector2 minArea, Vector2 maxArea)
{
Vector2 offset = Vector2.zero;
float contentX = maxRect.x - minRect.x; //计算 rect 矩形框中的宽度
float contentY = maxRect.y - minRect.y; //计算 rect 矩形框中的高度
float areaX = maxArea.x - minArea.x; //计算 area 矩形框中的宽度
float areaY = maxArea.y - minArea.y; //计算 area 矩形框中的高度
//判断横坐标 x 是否超框,判断 rect 的宽度是否超过显示区域 area 的宽度
if (contentX > areaX)
{
//超框就需要将 minArea 的x坐标重新计算
float diff = contentX - areaX;
minArea.x -= diff; //显示区域,左侧边框向左移动 diff
maxArea.x += diff; //显示区域,右侧边框向右移动 diff
}
//判断纵坐标 y 是否超框,判断 rect 的高度是否超过显示区域 area 的高度
if (contentY > areaY)
{
float diff = contentY - areaY;
minArea.y -= diff; // 显示区域,下边边框向下移动 diff
maxArea.y += diff; // 显示区域,上边边框向上移动 diff
}
//以下计算偏移是发生在 contentX == areaX ,contentY == areaY 但是没有全部将 rect 包住的情况
//最后重新计算偏移坐标
//这里计算的偏移其实是一个相反的值
//因为,我们移动 panel 的 position 和 clip,但是又不能改变 panel 所在的位置
//所以,这里分析的移动是基于 clip 移动的位置,但是真实获取的值要取相反的值
//比如 minRect.x < minArea.x,panel 相对于 item 偏向右边,item 超出 minarea 的显示范围
//所以需要将 panel clip 向左移动到 item 所在的位置,但是 panel 的相对位置不能变化,所以position就要移动相反的距离
if (minRect.x < minArea.x) offset.x += minArea.x - minRect.x;
if (maxRect.x > maxArea.x) offset.x -= maxRect.x - maxArea.x;
if (minRect.y < minArea.y) offset.y += minArea.y - minRect.y;
if (maxRect.y > maxArea.y) offset.y -= maxRect.y - maxArea.y;
return offset;
}
5.MoveAbsolute 和 MoveRelative
这两个函数是真正移动 panel 的函数,非常简单,直接看源码就好了。
源码:
public void MoveAbsolute (Vector3 absolute)
{
Vector3 a = mTrans.InverseTransformPoint(absolute);
Vector3 b = mTrans.InverseTransformPoint(Vector3.zero);
MoveRelative(a - b);
}
/// 移动UIPanel的显示内容
public virtual void MoveRelative (Vector3 relative)
{
mTrans.localPosition += relative;
Vector2 co = mPanel.clipOffset;
co.x -= relative.x;
co.y -= relative.y;
mPanel.clipOffset = co;
// Update the scroll bars
UpdateScrollbars(false);
}
4.UIDragScrollView
在 Scrollview 中 UIDragScrollView
和 Box collider
是挂载在 item 上的两个重要组件,他们主要负责监听事件,执行 item 的相关操作。
UIDragScrollView 的源码非常简单,主要就是注册了几个用于监听 item 的事件函数,当发生 item 的按压,拖拽,滚动等操作的时候,会自动调用这些函数,处理相应的逻辑。
源码:
void OnPress (bool pressed)
{
mPressed = pressed;
// If the scroll view has been set manually, don't try to find it again
if (mAutoFind && mScroll != scrollView)
{
mScroll = scrollView;
mAutoFind = false;
}
if (scrollView && enabled && NGUITools.GetActive(gameObject))
{
scrollView.Press(pressed);
if (!pressed && mAutoFind)
{
scrollView = NGUITools.FindInParents<UIScrollView>(mTrans);
mScroll = scrollView;
}
}
}
/// <summary>
/// Drag the object along the plane.
/// </summary>
void OnDrag (Vector2 delta)
{
if (scrollView && NGUITools.GetActive(this))
scrollView.Drag();
}
从 UIDragScrollView 这部分源码中可以看出来,当我们拖动 ScrollView 中 item 的时候,会调用这两个函数 OnPress 和 OnDrag ,然后他们再去调用当前 item 所在的 UIScrollView 中的 Press 和 Drag 函数执行相应的逻辑。
思考
希望你看完可以有些思索,NGUI 这种设计有没有缺陷?
NGUI 这种设计有没有别的实现方案?
我们自己可不可以也写一个更适合自己项目的 UIScrollView,欢迎一起讨论学习呀。