NGUI源码分析(一)之 UIScrollView

文章说明

本篇文章基于NGUI (3.12.0)版本源码下的代码分析,如果代码和大家自己的不同,可能是版本不同。如果文章中分析有误希望大神看见指点迷津,大家好,我就是一个勤勤恳恳爱偷懒的码农,希望和大家一起学习研究。

UIScrollView

在阅读源码前,希望你已经熟练的掌握了基本的 UIScrollView 的用法,如果你还不知道的话建议你先看下这篇文章 UIScrollView 使用

因为 UIScrollView 涉及到的东西蛮多的,感觉一篇文章分享不完,所以我想分成几篇分别来讲一下,今天给大家分享下 UIScrollView 中最重要的滚动部分(其中不包含滑轮,滚动条),看完感觉可以自己动手做一个小 demo 玩一玩是可以的。

1.简述原理

UIScrollView 实现滚动的原理其实是修改 UIPanel 的 localposition 和 Clip 中的 Offset,真正实现移动的其实是 Clip 的 Offset,但是为了 UIScrollView 的相对位置不变,所以需要同时修改 localposition ,如图,注意观察 UIPanel 的坐标和 Offset 变化。

在这里插入图片描述
所以我们只需要关心这三个问题即可:

  1. UIScrollView 是什么时候处理滚动的?
  2. UIScrollView 滚动的时候是如何处理 localposition 和 Offset 的位置的?
  3. UIScrollView 如何解决拖动超出显示区域后,它还能弹回来?

只要解决了这三个问题,我们也可以写一个简单的 UIScrollView ,所以带着这些问题我们再去看源码会感觉清晰很多。

2.组件结构属性

先来看看 UIScrollView 外部设置的组件都包含哪些内容,如图所示:

在这里插入图片描述

  1. Content Origin :滚动内容的轴心点;
  2. Movement :滑动方向,比如水平滑动,垂直滑动;
  3. Drag Effect :拖拽的效果;
  4. Scroll Wheel Factor :和鼠标滚轮有关,使用鼠标进行滚动时,会影响滚动的大小;
  5. Monmentum Amount :滑动时产生的动量;
  6. Restrict Within Panel :表示是否约束item都尽可能显示在 panel 内部
  7. Constrain On Drag :滚动视图是否会在每次拖动操作上执行边界内的约束逻辑;
  8. Cancel Drag If Fits :表示如果item全部都在显示范围内,是否可以取消拖拽效果;
  9. Smooth Drag Start :表示是否是平滑的拖拽开始;
  10. IOS Drag Emulation : 是否使用iOS拖动模拟;
  11. 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 内。

在这里插入图片描述

我将 RestrictWithinBoundsCalculateConstrainOffset 中的主要思想部分放在下面这张图中,结合这张图再去看源码感觉会比较清晰。

在这里插入图片描述

源码:

	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 中 UIDragScrollViewBox 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,欢迎一起讨论学习呀。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值