Unity 轮转图, 惯性, 自动回正, 点击选择

简单的实现 2D 以及 3D 的轮转图, 类似于 Web 中无限循环的轮播图那样.

文中所有代码均已同步至 github.com/SlimeNull/UnityTests

  • 3D 轮转图: Assets/Scripts/Scenes/CarouselTestScene/Carousel.cs
  • 2D 轮转图: Assets/Scripts/Scenes/CarouselTestScene/UICarousel.cs

主要逻辑

根据我们想要的效果可知, 主要原理就是, 当鼠标拖拽时, 更新对象的位置. 我们可以通过在脚本中保存一个 “当前旋转量” 的变量, 当鼠标拖拽时, 更改它. 而每次拖拽, 都根据旋转量, 计算每一个对象的位置. 接下来以 3D 轮转图为例, 讲述编写思路.


基础旋转

设计对象层级关系:

- 父对象 (脚本挂载到这里)|- 子对象|- 子对象|- 子对象|- ...|- 子对象

轮转图的大概逻辑框架:

public class Carousel : MonoBehavior, IDragHandler
{// 当前旋转偏移量float _radianOffset;void Start(){// 在开始时, 更新物体状态UpdateObjectStatus();}private void UpdateObjectStatus(){// 更新子物体状态, 计算旋转, 设置位置等}void IDragHandler.OnDrag(PointerEventData eventData){var mouseOffset = 获取鼠标拖拽偏移量的逻辑;var radianChange = 将鼠标偏移量转换为旋转量的逻辑;_radianOffset += radianChange;UpdateObjectStatus();}
}

编写从旋转角度, 更新物体状态的逻辑. 这里, 我们将一整个圆平均分为成员数量份, 并让它们平均分布. 半径的话, 我们允许用户在检视器中自定义, 所以在这里向外暴露字段即可.

需要注意的是, 在数学习惯三角函数中, 角的起始为 x 正方向, 也就是 “右方”, 为了使第一个子物体, 也就是旋转量为 0 的子物体在最前方, 我们在实际计算角度时, 需要整体减去半个 PI, 也就是 90 度.

public class Carousel : MonoBehavior, IDragHandler
{float _radianOffset;/// <summary>/// 大小/// </summary>[field: SerializeField]public float Size { get; set; } = 5;void Start(){UpdateObjectStatus();}// 更新物体状态private void UpdateObjectStatus(){var radius = Size / 2;                        // 半径var childCount = transform.childCount;        // 成员数量var radianGap = Mathf.PI * 2 / childCount;    // 旋转弧度间隔for (int i = 0; i < childCount; i++){// 获取当前角度, 以及当前游戏对象var radian = radianGap * i + _radianOffset - Mathf.PI / 2;var child = transform.GetChild(i);// 计算余弦正弦值var cos = Mathf.Cos(radian);var sin = Mathf.Sin(radian);// 计算坐标var x = cos * radius;var z = sin * radius;// 设置位置child.localPosition = new Vector3(x, 0, z);}}void IDragHandler.OnDrag(PointerEventData eventData){// 拖拽逻辑}
}

接下来实现获取鼠标拖拽偏移量, 由于 3D 空间中的对象, 距离相机远近不确定, 所以最终的鼠标偏移量我们以鼠标位置转换为世界坐标的结果为准. 在开始拖拽的时候, 记录下当前距离相机的位置与鼠标的世界坐标, 并在接下来的每一次拖拽触发时都重新计算鼠标的世界坐标, 并得出鼠标在世界中的偏移量.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler
{float _radianOffset;float _cameraDistance;              // 相机距离Vector3 _lastMouseWorldPosition;    // 上次的鼠标世界坐标/// <summary>/// 大小/// </summary>[field: SerializeField]public float Size { get; set; } = 5;void Start(){UpdateObjectStatus();}// 更新物体状态private void UpdateObjectStatus(){// 位置更新逻辑}void IDragHandler.OnDrag(PointerEventData eventData){var radius = Size / 2;// 求当前鼠标坐标, 计算鼠标的偏移量var mousePosition = Input.mousePosition;mousePosition.z = _cameraDistance;var newMouseWorldPosition = Camera.main.ScreenToWorldPoint(mousePosition);var mouseWorldOffset = newMouseWorldPosition - _lastMouseWorldPosition;// 保存新的鼠标位置_lastMouseWorldPosition = newMouseWorldPosition;// 将鼠标偏移量转换为旋转量var radianChange = mouseWorldOffset.x / radius;// 增加旋转_radianOffset += radianChange;// 更新物体状态UpdateObjectStatus();}void IBeginDragHandler.OnBeginDrag(PointerEventData eventData){// 通过自己的屏幕坐标, 取 z 值得到相机距离// 当然, 这里你也可以使用向量投影来直接获取当前对象与相机的距离var selfScreenPosition = Camera.main.WorldToScreenPoint(transform.position);_cameraDistance = selfScreenPosition.z;// 求鼠标的世界坐标var mousePosition = Input.mousePosition;mousePosition.z = _cameraDistance;_lastMouseWorldPosition = Camera.main.ScreenToWorldPoint(mousePosition);}
}

在上面的代码中, 我们的旋转量是直接将鼠标偏移量除以半径得来的. 因为圆的周长等于弧乘半径, 反之, 如果想要根据圆上长度求角大小, 直接拿这个长度除以半径即可. 而我们这里的思路, 正是将鼠标的偏移量, 视作圆上一点的旋转距离.

当然, 你也可以直接通过别的算法来将鼠标偏移量转换为旋转量, 这取决于你的需求. 甚至你也可以直接暴力的将鼠标的坐标偏移乘或除以一个固定的数来作为旋转量使用.

最后, 检查一下你的代码, 它们大概是这样的:

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler
{float _radianOffset;float _cameraDistance;Vector3 _lastMouseWorldPosition;public float Size { get; set; } = 5;void Start() { ... }private void UpdateObjectStatus() { ... }void IDragHandler.OnDrag(PointerEventData eventData) { .. }void IBeginDragHandler.OnBeginDrag(PointerEventData eventData) { ... }
}

在编写完基础代码之后, 我们的效果大概如下:

注意, 为了让 3D 场景中的物体能够接收指针拖拽的事件, 你的场景中需要有一个 “EventSystem” 组件, 并且主相机需要挂载 “GraphicsRaycaster” 组件.


自动回正

接下来我们还需要继续编写自动回正的功能, 使最近的物体旋转到前方. 大概思路就是, 保存当前最靠近前方的物体索引, 并计算其在最前方的旋转偏移量, 最后使用 DOTween 执行缓动.

因为我们使用正弦值乘半径作为物体的 z 坐标, 所以对于获取当前最前方的物体, 我们只需要看哪个物体旋转量正弦值最小即可.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler
{// 其他字段和属性/// <summary>/// 已选择的索引/// </summary>public SelectedIndex { get; private set; }// 更新物体状态private void UpdateObjectStatus(){var radius = Size / 2;var childCount = transform.childCount;var radianGap = Mathf.PI * 2 / childCount;var minSin = 2f;var selectedIndex = -1;for (int i = 0; i < childCount; i++){var radian = radianGap * i + _radianOffset - Mathf.PI / 2;var child = transform.GetChild(i);var cos = Mathf.Cos(radian);var sin = Mathf.Sin(radian);var x = cos * radius;var z = sin * radius;child.localPosition = new Vector3(x, 0, z);// 如果当前正弦值小于已保存的最小正弦值// 则更新最小正弦值以及已选中索引if (sin <= minSin){minSin = sin;selectedIndex = i;}}// 更新属性SelectedIndex = selectedIndex;}// 其他逻辑
}

要获取某个子对象对应的旋转角度, 直接拿旋转间隔, 乘其索引, 再转为负数即可. 因为从第一个元素旋转到第二个元素时, 旋转角度是减少的.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler
{   // 其他字段和属性/// <summary>/// 从索引获取旋转量/// </summary>/// <param name="index"></param>/// <returns></returns>private float GetRadianFromItemIndex(int index){var childCount = transform.childCount;var radianGap = Mathf.PI * 2 / childCount;return -radianGap * index;}// 其他逻辑
}

那么, 选中某个元素, 将其移动到最前方的逻辑, 大概就是这样了, 使用 DOTween 做过渡就可以:

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler
{float _radianOffset;// 其他字段/// <summary>/// 过渡时间/// </summary>[field: SerializeField]public float TransitionTime { get; set; } = 0.2f;// 其他字段和属性// 更新物体状态private void UpdateObjectStatus() { ... }// 从索引获取旋转角度private float GetRadianFromItemIndex(int index) { ... }public void Select(int index){var originRadian = _radianOffset;var targetRadian = GetRadianFromItemIndex(index);DOTween.To(radian =>{// 设置旋转量并更新物体状态_radianOffset = radian;UpdateObjectStatus();}, originRadian, targetRadian, TransitionTime);}// 其他逻辑
}

不过我们接下来还需要考虑两件事.

  1. 如果我当前旋转量是一圈多, 过渡会出问题.
    举个例子, 当旋转量是两圈半时, 也就是 5 个 PI, 当我调用 Select(0) 的时候, 它会直接从 5 个 PI 过渡到 0, 在视觉上就是, 这个轮转图转了两圈半, 回到原点.
  2. 如果当前旋转到目标旋转量大于半个圆, 视觉上不好看.
    举个例子, 我需要从 180 度旋转到 370 度, 如果直接这么转, 视觉上看起来是它绕了远路, 因为从 180 转到 10 度经过的 170 当然是比 转到 370 经过的 190 度要短的.

所以, 我们需要做两点修改:

  1. 使当前旋转量不能大于一圈.
  2. 在进行旋转前, 将目标旋转值改为最短的目标值.

其中第一点非常简单, 只需要在每一次更新物体状态前, 对当前旋转量 _radianOffsetPI * 2 进行求余即可.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler
{   // 字段和属性// 更新物体状态private void UpdateObjectStatus(){// 变量初始化// 在执行操作前, 保证 _radianOffset 不大于一圈_radianOffset %= Mathf.PI * 2;for (int i = 0; i < childCount; i++){// 更新物体位置以及其他的具体逻辑}// 更新属性SelectedIndex = selectedIndex;}// 其他逻辑
}

第二点, 我们只需要判断旋转差值, 如果大于半圆, 那么加上或者减去一整个圆即可.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler
{   // 字段和属性// 矫正旋转目标private void CorrectRotationTarget(float origin, ref float target){const float DoublePI = Mathf.PI * 2;// 旋转差值float diff = (target - origin) % DoublePI;if (diff > Mathf.PI)diff -= DoublePI;else if (diff < -Mathf.PI)diff += DoublePI;target = origin + diff;}// 其他逻辑
}

最后, 我们在松开鼠标, 也就是结束拖拽的时候, 调用 Select 方法, 选择当前最前方物体即可.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler
{   // 其他字段和属性/// <summary>/// 启用自动回正/// </summary>[field: SerializeField]public bool EnableAutoCorrection { get; set; } = true;// 选择public void Select(int index) { ... }void IEndDragHandler.OnEndDrag(PointerEventData eventData){if (EnableAutoCorrection){Select(SelectedIndex);}}// 其他逻辑
}

最后, 检查你的代码, 它们大概是这样的:

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler
{float _radianOffset;float _cameraDistance;Vector3 _lastMouseWorldPosition;public float Size { get; set; } = 5;public bool EnableAutoCorrection { get; set; } = true;public float TransitionTime { get; set; } = 0.2f;public int SelectedIndex { get; private set; }void Start() { ... }private void UpdateObjectStatus() { ... }private void CorrectRotationTarget(float origin, ref float target) { ... }private float GetRadianFromItemIndex(int index) { ... }public void Select(int index) { ... }void IDragHandler.OnDrag(PointerEventData eventData) { .. }void IBeginDragHandler.OnBeginDrag(PointerEventData eventData) { ... }void IEndDragHandler.OnEndDrag(PointerEventData eventData) { ... }
}

在做完上面一切工作之后, 我们最终会有一个带自动回正的轮转图. 效果如下:


运动惯性

惯性的实现也是很简单的, 我们只需要保存拖拽时的速度, 在松开时使其仍然继续运动, 并不断减小速度就可以.

我们的大概思路如下:

  1. 使用一个字段保存速度, 一个字段保存当前是否正在被用户拖拽. 向外公开 “旋转阻力” 以允许在检视器中调整阻力.
  2. 在 Update 中, 如果启用了惯性, 没有被用户拖拽, 并且速度不为 0, 则通过速度对旋转量进行增加, 并更新物体状态. 如果在一次更新中, 速度最终衰减到了 0, 那么则执行自动回正的逻辑.
  3. 在 Drag 中, 根据当前拖拽的旋转增量以及时间差, 计算并保存旋转速度.
  4. 在 EndDrag 中, 原本直接调用自动回正的逻辑, 仅在不启用惯性的时候直接执行. 因为如果启动了惯性, 在 Update 中会执行自动回正, 不需要在结束拖拽时执行.

最终, 代码的框架大致如下:

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler
{// 其他字段与属性float _radianVelocity;   // 旋转速度bool _dragging;          // 是否正在被拖拽/// <summary>/// 旋转阻力/// </summary>[field: SerializeField]public float RadianDrag { get; set; } = 10;/// <summary>/// 启用惯性/// </summary>[field: SerializeField]public bool EnableInertia { get; set; } = true;void Update(){if (EnableInertia && !_dragging && _radianVelocity == 0){_radianOffset += _radianVelocity;UpdateObjectStatus();// 使速度衰减的逻辑 (稍后进行详细的编写)// 自动回正if (EnableAutoCorrection && _radianVelocity == 0){Select(SelectedIndex);}}}void IDragHandler.OnDrag(PointerEventData eventData){var radius = Size / 2;var mousePosition = Input.mousePosition;mousePosition.z = _cameraDistance;var newMouseWorldPosition = Camera.main.ScreenToWorldPoint(mousePosition);var mouseWorldOffset = newMouseWorldPosition - _lastMouseWorldPosition;_lastMouseWorldPosition = newMouseWorldPosition;var radianChange = mouseWorldOffset.x / radius;// 直接以 radianChange 除以 Time.deltaTime 得到速度_radianVelocity = radianChange / Time.deltaTime;_radianOffset += radianChange;UpdateObjectStatus();}void IBeginDragHandler.OnBeginDrag(PointerEventData eventData){// 其他逻辑_dragging = true;    // 设置拖拽状态}void IEndDragHandler.OnEndDrag(PointerEventData eventData){_dragging = false;   // 设置拖拽状态if (EnableAutoCorrection && !EnableInertia){// 自动回正Select(SelectedIndex);}}// 其他逻辑
}

速度的衰减, 我们这里通过将速度拆解成两个量来做处理, “方向” 和 “大小”. 然后对大小做减法, 最后再重新拼接.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler
{// 字段与属性void Update(){if (EnableInertia && !_dragging && _radianVelocity != 0){_radianOffset += _radianVelocity * Time.deltaTime;UpdateObjectStatus();// 拆解var radianVelocitySign = Mathf.Sign(_radianVelocity);var radianVelocitySize = Mathf.Abs(_radianVelocity);// 减小速度radianVelocitySize -= RadianDrag * Time.deltaTime;if (radianVelocitySize < 0)   // 速度大小不能为负值radianVelocitySize = 0;// 重新拼接_radianVelocity = radianVelocitySign * radianVelocitySize;// 自动回正if (EnableAutoCorrection && _radianVelocity == 0){Select(SelectedIndex);}}}// 其他逻辑
}

于是, 我们成功为轮转图添加了惯性功能. 最后检查代码, 它们大概是这样的:

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler
{float _radianOffset;float _radianVelocity;float _cameraDistance;Vector3 _lastMouseWorldPosition;public float Size { get; set; } = 5;public float RadianDrag { get; set; } = 10;public bool EnableInertia { get; set; } = true;public bool EnableAutoCorrection { get; set; } = true;public float TransitionTime { get; set; } = 0.2f;public int SelectedIndex { get; private set; }void Start() { ... }void Update() { ... }private void UpdateObjectStatus() { ... }private void CorrectRotationTarget(float origin, ref float target) { ... }private float GetRadianFromItemIndex(int index) { ... }public void Select(int index) { ... }void IDragHandler.OnDrag(PointerEventData eventData) { .. }void IBeginDragHandler.OnBeginDrag(PointerEventData eventData) { ... }void IEndDragHandler.OnEndDrag(PointerEventData eventData) { ... }
}

于是, 你就可以得到在文章最开始展示的, 完整轮转图的效果了:


点击选择

最后, 我们希望为轮转图添加鼠标单击选择的功能, 实现起来很方便, 因为我们已经编写好选择某元素的方法 Select 了. 那么, 只需要实现 IPointerClickHandler, 并判断是否点击了某个子元素, 然后调用 Select 方法即可.

public class Carousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerClickHandler
{// 字段与属性/// <summary>/// 允许点击选择/// </summary>[field: SerializeField]public bool AllowClickSelection { get; set; } = false;void IPointerClickHandler.OnPointerClick(PointerEventData eventData){if (!AllowClickSelection)return;var childCount = transform.childCount;for (int i = 0; i < childCount; i++){var renderer = transform.GetChild(i);if (eventData.pointerPressRaycast.gameObject == renderer.gameObject){Select(i);break;}}}// 其他逻辑
}

添加完上述代码之后, 我们就得到了可以单击选择的轮转图了.



物体生成

对于 UI 上的轮转图, 我们以展示 Sprite 为例, 为了方便, 我们不需要像原本那样实现在父物体下放置好物体, 而是根据用户指定的 Sprite, 自动生成游戏对象, 自动挂载所需组件, 设置属性.


创建物体

我们打算让脚本向外暴露出一个 Sprite[] 供设置要展示的图片, 那么在接下来的逻辑中, 就需要实现:

  1. 初始化时, 创建所需的游戏对象, 挂载 Image 组件以供显示图片
  2. 当外部对图片数组进行赋值时, 重新更新状态, 使显示是正确的
  3. 由于 Canvas 在 Screen Space - Overlay 模式下不存在近大远小, 所以我们需要提供根据前后顺序, 自动进行缩放的功能

那么, 我们需要做的添加和变更的有这些:

public class UICarousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerClickHandler
{// 其他字段和属性[SerializeField]Sprite[] _images;[SerializeField]private bool _scaleImages = true;[SerializeField]private float _minScale = 0.3f;List<UnityEngine.UI.Image> _imageComponents;// 懒加载的, 用于获取 RectTransform 的属性RectTransform _selfRectTransform;RectTransform SelfRectTransform => _selfRectTransform ??= GetComponent<RectTransform>();/// <summary>/// 要展示的图片/// </summary>public Sprite[] Images{get => _images; set{_images = value;UpdateImagesStatus();}}/// <summary>/// 图片尺寸 (用于生成 Image 物体设置大小)/// </summary>[field: SerializeField]public Vector2 ImageSize { get; set; } = new Vector2(100, 100);/// <summary>///是否根据图像的前后关系调整图像大小  <br/>/// 如果是 Overlay, 不存在近大远小, 则需要开启这个, 但是如果是 WorldSpace 的 Canvas 并且设置了缩放使其在场景内, 则不需要启用这个/// </summary>public bool ScaleImages{get => _scaleImages;set{_scaleImages = value;UpdateImagesStatus();}}/// <summary>/// 最小缩放比例 (最后方的图像的缩放系数会是这个值)/// </summary>public float MinScale{get => _minScale; set{_minScale = value;UpdateImagesStatus();}}void Awake(){// 初始化if (_images is null)_images = Array.Empty<Sprite>();UpdateRenderers();}/// <summary>/// 根据图像创建 Image 对象, 并自动设置属性/// </summary>/// <param name="image"></param>/// <returns></returns>private UnityEngine.UI.Image CreateRendererFor(Sprite image) { ... }/// <summary>/// 更新 Sprite 的渲染器/// </summary>private void UpdateRenderers() { ... }/// <summary>/// 旋转主逻辑/// 根据 "旋转偏移量" 设置所有图像的位置, 大小, 以及前后关系/// </summary>private void UpdateObjectStatus() { ... }// 其他逻辑
}

根据 Sprite 创建 Image 的逻辑:

public class UICarousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerClickHandler
{// 字段和属性/// <summary>/// 根据图像创建 Image 对象, 并自动设置属性/// </summary>/// <param name="image"></param>/// <returns></returns>private UnityEngine.UI.Image CreateRendererFor(Sprite image) {GameObject gameObject = new("Image");gameObject.transform.SetParent(transform);var rectTransform = gameObject.AddComponent<RectTransform>();var renderer = gameObject.AddComponent<UnityEngine.UI.Image>();// 设置rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, ImageSize.x);rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, ImageSize.y);renderer.sprite = image;return renderer;}// 其他逻辑
}

更新 Sprite 渲染器的逻辑(删除或创建 Image):

public class UICarousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerClickHandler
{// 字段和属性/// <summary>/// 更新 Sprite 的渲染器/// </summary>private void UpdateRenderers(){if (_imageComponents is null)_imageComponents = new List<UnityEngine.UI.Image>(GetComponentsInChildren<UnityEngine.UI.Image>());if (_imageComponents.Count < _images.Length){for (int i = 0; i < _imageComponents.Count; i++)_imageComponents[i].sprite = _images[i];while (_imageComponents.Count < _images.Length)_imageComponents.Add(CreateRendererFor(_images[_imageComponents.Count]));}else{for (int i = 0; i < _images.Length; i++)_imageComponents[i].sprite = _images[i];while (_imageComponents.Count > _images.Length){int lastIndex = _imageComponents.Count - 1;// destroy the lastDestroy(_imageComponents[lastIndex].gameObject);// remove the last_imageComponents.RemoveAt(lastIndex);}}}// 其他逻辑
}

UI 特有更新

更新物体状态的主逻辑(移动图片, 缩放图片, 调整图片层级关系):

public class UICarousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerClickHandler
{// 字段和属性/// <summary>/// 旋转主逻辑/// 根据 "旋转偏移量" 设置所有图像的位置, 大小, 以及前后关系/// </summary>private void UpdateObjectStatus(){UpdateRenderers();var scaleGap = 1 - MinScale;var radianGap = Mathf.PI * 2 / _images.Length;var selfSizeDelta = SelfRectTransform.sizeDelta;var radius = selfSizeDelta.x / 2;var imageCount = _images.Length;var halfPi = Mathf.PI / 2;var minSin = 1f;var selectedImageIndex = -1;_radianOffset %= (Mathf.PI * 2);for (int i = 0; i < imageCount; i++){var scaleShrink = scaleGap * i / (imageCount - 1);var renderer = _imageComponents[i];float cos = Mathf.Cos(radianGap * i + _radianOffset - halfPi);float sin = Mathf.Sin(radianGap * i + _radianOffset - halfPi);var x = cos * radius;var z = sin * radius;if (sin <= minSin){selectedImageIndex = i;minSin = sin;}renderer.transform.localPosition = new Vector3(x, 0, z);if (ScaleImages){var scale = Mathf.Lerp(MinScale, 1, ((-sin) + 1) / 2);renderer.transform.localScale = new Vector3(scale, scale, scale);}else{renderer.transform.localScale = new Vector3(1, 1, 1);}}// 根据大小, 调整顺序, 因为小的在后面, 所以直接根据大小, 调用调整顺序方法foreach (var com in _imageComponents.OrderBy(com => com.rectTransform.localScale.x))com.transform.SetAsLastSibling();// 记录当前选择索引以及图像SelectedIndex = selectedImageIndex;}// 其他逻辑
}

其中, 缩放图片中用到了 Mathf.Lerp 函数, 在 “比例” 参数中, 传入了 (-sin + 1) / 2 这样的值. 这是因为, 正弦在我们的算法中本来就关系到物体的前后关系, 所以直接对正弦值做处理, 就能得到最终值域为 0 到 1, 可用于 Lerp 的参数了.

  1. sin: 从远到近, 值从 1 到 -1
  2. -sin: 从远到近, 值从 -1 到 1
  3. -sin + 1: 从远到近, 值从 0 到 2
  4. (-sin + 1) / 2: 从远到近, 值从 0 到 1
  5. Mathf.Lerp(MinScale, 1, (-sin + 1) / 2): 从远到近, 值从 MinScale 到 1

而之所以需要调整顺序, 也是因为在 UI 中, 遮挡关系不由 Z 轴决定, 而是由 UI 层级以及它们在面板中的先后关系决定的.

另外, 我们也应该删去对鼠标的坐标转换之类的东西, 因为是 UI 层上的拖拽, 所以在 Drag 中只需要使用事件数据的鼠标移动量就可以了:

public class UICarousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerClickHandler
{// 字段和属性// 删去 _cameraDistance 和 _lastMouseWorldPosition/// <summary>/// 拖拽时, 计算实际角度偏移量, 并更新图像状态/// </summary>/// <param name="eventData"></param>void IDragHandler.OnDrag(PointerEventData eventData){var selfSizeDelta = SelfRectTransform.sizeDelta;float normalizedOffset = eventData.delta.x / (selfSizeDelta.x / 2);float radianChange = Mathf.Asin(normalizedOffset % 1);_radianOffset += radianChange;_radianVelocity = radianChange / Time.deltaTime;UpdateObjectStatus();}void IBeginDragHandler.OnBeginDrag(PointerEventData eventData){// 删去了坐标转换逻辑_dragging = true;}// 其他逻辑
}

最后, 检查代码, 它们应该是大概这样的:

[RequireComponent(typeof(RectTransform))]
public class UICarousel : MonoBehavior, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerClickHandler
{RectTransform _selfRectTransform;float _radianOffset;float _radianVelocity;bool _dragging;[SerializeField]Sprite[] _images;[SerializeField]private bool _scaleImages = true;[SerializeField]private float _minScale = 0.3f;List<UnityEngine.UI.Image> _imageComponents;RectTransform SelfRectTransform => _selfRectTransform ??= GetComponent<RectTransform>();public Sprite[] Images { ... }public Vector2 ImageSize { get; set; } = new Vector2(100, 100);public float RadianDrag { get; set; } = 10;public bool ScaleImages { ... }public float MinScale { ... }public bool AllowClickSelection { get; set; } = false;public bool EnableInertia { get; set; } = true;public bool EnableAutoCorrection { get; set; } = true;public float TransitionTime { get; set; } = 0.2f;public int SelectedIndex { get; private set; }void Awake() { ... }void Start() { ... }void Update() { ... }private UnityEngine.UI.Image CreateRendererFor(Sprite image) { ... }private void UpdateRenderers() { ... }private void UpdateObjectStatus() { ... }private void CorrectRotationTarget(float origin, ref float target) { ... }private float GetRadianOffsetFromIndex(int index) { ... }public void Select(int index) { .... }void IDragHandler.OnDrag(PointerEventData eventData) { .. }void IBeginDragHandler.OnBeginDrag(PointerEventData eventData) { ... }void IEndDragHandler.OnEndDrag(PointerEventData eventData) { ... }void IPointerClickHandler.OnPointerClick(PointerEventData eventData) { ... }
}

于是, 最终我们就能得到一个可点选, 带惯性, 带自动回正的 2D 轮转图了.



其他

从效果上来看, 我们编写的轮转图已经完美了, 但仔细看的话, 还是有一些逻辑瑕疵的.

  1. 即使我拖拽轮转图并停止, 旋转速度的值也有可能不为 0
    这就导致当开启惯性的时候, 无论如何, 在松开鼠标的时候, 它都会 “动” 一下.
  2. 当鼠标拖拽到屏幕边缘的时候, 鼠标在移动, 但是屏幕上鼠标的位置不改变
    这会导致旋转速度被设为 0, 进而导致, 虽然松开了鼠标, 但是因为没有触发惯性, 因而没有执行自动回正的逻辑. 因为启用惯性时, 只有速度从一个非 0 值变成 0 值, 才会执行自动回正.

这上面的问题, 我懒得改了, 改起来的思路的话, 也很简单, 如下:

  1. 将速度计算的逻辑扔 Update 里面, 并计算相对上一帧的旋转量, 即可解决问题 1
  2. 在结束拖拽时, 同时判断速度是否为 0, 如果为 0 就意味着惯性逻辑不执行, 那么这时, 直接执行自动回正逻辑即可.

最后, 关于本文章中脚本的源代码, 直接在 github.com/SlimeNull/UnityTests 中下载即可. 文章中因为涉及到逐步骤以及思路的讲解, 所以代码并不完整. 不过你跟着做的话, 也是能写出来完整的代码的.

使用方式的话, 3D 轮转图是直接挂父对象上, 然后手动创建子对象. 在运行时会自动设置子对象位置. 2D 轮转图的话, 不需要手动创建子对象, 直接在 “检视器” 中设置要展示的图片, 在启动时便会自动创建对象.


文章原始链接: https://slimenull.com/p/20240226141713

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/270683.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Spring高级】第2讲:容器实现类

目录 BeanFactory实现BeanDefinition后置处理器单例bean创建后置处理器顺序总结 ApplicationContext实现ClassPathXmlApplicationContextFileSystemXmlApplicationContextAnnotationConfigApplicationContextAnnotationConfigServletWebServerApplicationContext BeanFactory实…

springboot3.x 以上,官方不建议使用spring.factories

springboot2.7.x 以上,官方不建议使用spring.factories 最近公司项目升级.需要将springcloud/springboot版本升级到2.7.x以上,再升级的过程中遇到了太多的问题.总结在了如下文章中: springboot艰难版本升级之路!! springboot 2.3.x版本升级到2.7.x版本 这篇文章就重点是梳理一…

如何让多个视频同时转GIF 2024全新款 高清无损转换

大家是否经常会遇到这样的问题&#xff0c;看到一些有趣的短视频片段&#xff0c;但却不知道如何将它们转换成GIF动图&#xff1f;今天&#xff0c;小编就给大家分享一个简单教程&#xff0c;教你如何批量将喜欢的短视频转换成GIF动图&#xff0c;让我们一起来学习吧&#xff0…

linux安装ngnix

一、将nginx-1.20.1.tar.gz上传至linux服务器目录下 二、将nginx安装包解压到/usr/local目录下 tar -zxvf /home/local/nginx-1.20.1.tar.gz -C /usr/local/三、预先安装依赖 yum -y install pcre-devel yum -y install openssl openssl-devel yum -y install gcc gcc-c auto…

【自然语言处理】【大模型】BitNet:用1-bit Transformer训练LLM

BitNet&#xff1a;用1-bit Transformer训练LLM 《BitNet: Scaling 1-bit Transformers for Large Language Models》 论文地址&#xff1a;https://arxiv.org/pdf/2310.11453.pdf 相关博客 【自然语言处理】【大模型】BitNet&#xff1a;用1-bit Transformer训练LLM 【自然语言…

Vue3.2 + vue/cli-service 打包 chunk-vendors.js 文件过大导致页面加载缓慢解决方案

chunk-vendors.js 是/node_modules 目录下的所有模块打包成的包&#xff0c; 但是这包太大导致页面加载很慢&#xff08;我的都要3-4秒了&#xff09;&#xff0c; 这个时候就会出现白屏的情况 解决方案 1、compression-webpack-plugin 插件解决方案 1&#xff09;、安装 npm …

解决vue项目本地开发完成后部署到服务器后报404的问题

一、如何部署 前后端分离开发模式下&#xff0c;前后端是独立布署的&#xff0c;前端只需要将最后的构建物上传至目标服务器的web容器指定的静态目录下即可 我们知道vue项目在构建后&#xff0c;是生成一系列的静态文件 常规布署我们只需要将这个目录上传至目标服务器即可 /…

redis进阶(一)

文章目录 前言一、Redis中的对象的结构体如下&#xff1a;二、压缩链表三、跳跃表 前言 Redis是一种key/value型数据库&#xff0c;其中&#xff0c;每个key和value都是使用对象表示的。 一、Redis中的对象的结构体如下&#xff1a; /** Redis 对象*/ typedef struct redisO…

element多选框select下拉框数据回显的问题value.push is not a function

文章目录 问题描述 问题描述 今天在使用Element UI el-select组件遇到了一个问题&#xff0c;如下图&#xff1a; 下拉框里的值选中了&#xff0c;但是文本框里没有值 这是 el-select组件代码,我这里是用了一个多选框&#xff0c;options的值是在后端查询的&#xff0c;form.we…

提取b站字幕(视频字幕、AI字幕)

提取b站字幕&#xff08;视频字幕、AI字幕&#xff09; 1. 打开视频 2. 按 F12 进行开发者界面 视频自己的紫米输入的是 json&#xff0c;如果是AI字幕则需要输入 ai_subtitle 3. 进入这个网址&#xff1a;https://www.dreamlyn.cn/bsrt

关于springboot一个接口请求后,主动取消后,后端是否还在跑

1、最近在思考一个问题&#xff0c;如果一个springboot的请求的接口比较耗时&#xff0c;中途中断该请求后&#xff0c;则后端服务是否会终止该线程的处理&#xff0c;于是写了一个demo RequestMapping(value "/test", method RequestMethod.GET)public BasicResul…

云消息队列 Confluent 版正式上线!

作者&#xff1a;阿里云消息队列 前言 在 2023 年杭州云栖大会上&#xff0c;Confluent 成为阿里云技术合作伙伴&#xff0c;在此基础上&#xff0c;双方展开了深度合作&#xff0c;并在今天&#xff08;3月1日&#xff09;正式上线“云消息队列 Confluent 版”。 通过将 Co…

keycloak18.0.0==本地源码启动

github下载源码&#xff0c; 版本18.0.0 java和maven的版本如下 E:\keycloak-18.0.0>java -version java version "21.0.1" 2023-10-17 LTS Java(TM) SE Runtime Environment (build 21.0.112-LTS-29) Java HotSpot(TM) 64-Bit Server VM (build 21.0.112-LTS-…

电脑e盘文件没删除但不见了怎么办?了解原因与找回方法

电脑E盘文件没删除但不见了怎么办&#xff1f;在数字化时代&#xff0c;电脑已成为我们生活和工作中不可或缺的工具。其中&#xff0c;各个硬盘分区更是承载着我们宝贵的数据。但有时&#xff0c;你可能会发现&#xff0c;存放在E盘中的某些文件明明没有删除&#xff0c;却突然…

前端面试拼图-原理源码

摘要&#xff1a;最近&#xff0c;看了下慕课2周刷完n道面试题&#xff0c;记录下... 1. JS内存泄漏如何检测&#xff1f;场景有哪些? 1.1 垃圾回收 GC 垃圾回收是一种自动管理内存的机制&#xff0c;它负责在运行时跟踪内存的分配和使用情况&#xff0c;并在不再需要的对象…

2024年【道路运输企业安全生产管理人员】复审考试及道路运输企业安全生产管理人员模拟考试题

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 2024年道路运输企业安全生产管理人员复审考试为正在备考道路运输企业安全生产管理人员操作证的学员准备的理论考试专题&#xff0c;每个月更新的道路运输企业安全生产管理人员模拟考试题祝您顺利通过道路运输企业安全…

Flink:Temporal Table 的两种实现方式 Temporal Table DDL 和 Temporal Table Function

博主历时三年精心创作的《大数据平台架构与原型实现&#xff1a;数据中台建设实战》一书现已由知名IT图书品牌电子工业出版社博文视点出版发行&#xff0c;点击《重磅推荐&#xff1a;建大数据平台太难了&#xff01;给我发个工程原型吧&#xff01;》了解图书详情&#xff0c;…

多线程相关面试题(2024大厂高频面试题系列)

1、聊一下并行和并发有什么区别&#xff1f; 并发是同一时间应对多件事情的能力&#xff0c;多个线程轮流使用一个或多个CPU 并行是同一时间动手做多件事情的能力&#xff0c;4核CPU同时执行4个线程 2、说一下线程和进程的区别&#xff1f; 进程是正在运行程序的实例&#xff…

【MySQL】not in遇上null的坑

今天遇到一个问题&#xff1a; 1、当 in 内的字段包含 null 的时候&#xff0c;正常过滤&#xff1b; 2、当 not in 内的字段包含 null 的时候&#xff0c;不能正常过滤&#xff0c;即使满足条件&#xff0c;最终结果也为 空。 测试如下&#xff1a; select * from emp e;当…

网络爬虫部分应掌握的重要知识点

目录 一、预备知识1、Web基本工作原理2、网络爬虫的Robots协议 二、爬取网页1、请求服务器并获取网页2、查看服务器端响应的状态码3、输出网页内容 三、使用BeautifulSoup定位网页元素1、首先需要导入BeautifulSoup库2、使用find/find_all函数查找所需的标签元素 四、获取元素的…