• UGUI学习笔记(九)自制3D轮播图


    一、效果展示

    二、实现过程

    2.1 准备工作

    首先在Canvas下创建一个空物体,将其命名为「SlideShow」,并设置好其大小。它将作为轮播图的父容器。

    在「SlideShow」身上挂载一个脚本,命名为「SlideShow3D」。声明一个Vector2成员用来设定每张图片的大小,一个Sprite数组用来存储需要展示的图片

    public class SlideShow3D : MonoBehaviour
    {
        // 图片大小
        public Vector2 ItemSize;
        // 图片集
        public Sprite[] ItemSprites;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    新创建一个脚本命名为「SlideShowItem」,作为子物体身上挂载的脚本。

    public class SlideShowItem : MonoBehaviour
    {
    
    }
    
    • 1
    • 2
    • 3
    • 4

    2.2 动态创建子物体

    由于子物体都具有相同的特征,因此单独写一个创建子物体模板的方法

    private GameObject CreateTemplate()
    {
    	GameObject item = new GameObject("Template");
    	item.AddComponent<Image>();
    	item.AddComponent<RectTransform>().sizeDelta = ItemSize;
    	item.AddComponent<SlideShowItem>();
    	return item;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    接下来通过给生成的模板添加sprite、设置parent并实例化,来真正生成子物体。

    // 子物体集合
    private List<SlideShowItem> _items;
    void Start()
    {
    	_items = new List<SlideShowItem>();
    	CreateItems();
    }
    /// 
    /// 创建子物体
    /// 
    private void CreateItems()
    {
    	var template = CreateTemplate();
    	foreach (var sprite in ItemSprites)
    	{
    		var slideShowItem = Instantiate(template).GetComponent<SlideShowItem>();
    		slideShowItem.SetParent(transform);
    		slideShowItem.SetSprite(sprite);
    		_items.Add(slideShowItem);
    	}
    	Destroy(template);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    这里将设置parent和sprite的方法放在了子物体的脚本中,以提高内聚性。

    public class SlideShowItem : MonoBehaviour
    {
        private Image _img;
    
        private Image Img
        {
            get
            {
                if (_img == null)
                    _img = GetComponent<Image>();
                return _img;
            }
        }
        public void SetParent(Transform parentTransform)
        {
            transform.SetParent(parentTransform);
        }
    
        public void SetSprite(Sprite sprite)
        {
            Img.sprite = sprite;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    现在运行游戏,可以看到图片成功生成了出来,并且都堆在了(0,0)位置。

    2.3 使用一维数据模仿椭圆轨迹

    首先分析一下图片的移动轨迹。从俯视角来看,图片是在一个椭圆形的轨迹上移动

    但摄像机是位于轮播图的正前方,也就是说在摄像机的角度来看,图片是在一条数轴上往复移动。图片的远近通过缩放来表示。

    因此这里可以定义一个方法,通过图片在椭圆轨道上的位置计算出映射到数轴上的位置

    ///   
    /// 获取图片在x轴上的位置  
    ///   
    /// 图片在椭圆上的位置[0,1]  
    /// 椭圆的周长  
    /// 
    private float GetX(float ratio,float lenght)
    {
    	if (ratio > 1 || ratio < 0)
    	{
    		Debug.LogError("ratio必须在[0,1]的范围内");
    		return 0;
    	}
    	if (ratio >= 0 && ratio < 0.25f)
    	{
    		return lenght * ratio;
    	}
    	else if (ratio >= 0.25f && ratio < 0.75f)
    	{
    		return lenght * (0.5f - ratio);
    	}
    	else
    	{
    		return lenght * (ratio - 1);
    	}
    }
    
    • 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

    接下来计算图片的放大系数。这个比较简单,只需要定义出最大放大系数(图片离相机最近时)和最小放大系数(图片离相机最远时),就可以根据图片在椭圆轨道上的位置,计算出当前的缩放系数。

    /// 
    /// 获取放大系数
    /// 
    /// 图片在椭圆上的位置[0,1]
    /// 最大放大系数
    /// 最小放大系数
    /// 
    private float GetScaleTimes(float ratio, float max, float min)
    {
    	if (ratio > 1 || ratio < 0)
    	{
    		Debug.LogError("ratio必须在[0,1]的范围内");
    		return 0;
    	}
    	float offset = (max - min) / 0.5f;
    	if (ratio < 0.5f)
    	{
    		return max - offset * ratio;
    	}
    	else
    	{
    		return max - offset * (1 - ratio);
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    有了这两个方法,我们就可以计算出每个图片所在的位置及其缩放。这里可以把这两个信息封装成一个结构体,便于传参

    // 最大缩放系数  
    public float MaxScale;  
    // 最小放大系数  
    public float MinScale;  
    // 图片间间距  
    public float Offset;  
    // 子物体位置数据集合  
    private List<ItemPos> _posData;
    
    public struct ItemPos  
    {  
        public float X;  
        public float Scale;  
    }
    /// 
    /// 计算子物体的位置数据
    /// 
    private void CalculateItemData()
    {
    	// 椭圆轨道周长
    	float length = (ItemSize.x + Offset) * _items.Count;
    	// 比例系数
    	float radioOffset = 1 / (float) _items.Count;
    	float radio = 0;
    	for (int i = 0; i < _items.Count; i++)
    	{
    		ItemPos data = new();
    		data.X = GetX(radio, length);
    		data.Scale = GetScaleTimes(radio, MaxScale, MinScale);
    		radio += radioOffset;
    		_posData.Add(data);
    	}
    	
    }
    
    • 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

    Start()方法中调用上面的方法,计算出子物体的位置信息后,将信息存储在一个集合中。然后再定义一个方法对集合中的数据进行遍历,将位置信息传递给子物体类。让位置、缩放的设置工作交给子物体类。

    void Start()
    {
    	_items = new List<SlideShowItem>();
    	_posData = new List<ItemPos>();
    	CreateItems();
    	CalculateItemData();
    	SetItemData();
    }
    /// 
    /// 设置子物体的位置信息
    /// 
    private void SetItemData()
    {
    	for (int i = 0; i < _items.Count; i++)
    	{
    		_items[i].SetPosData(_posData[i]);
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    「SlideShowItem」类中新增的代码如下

    private RectTransform _rect;
    
    private RectTransform Rect
    {
    	get
    	{
    		if (_rect == null)
    			_rect = GetComponent<RectTransform>();
    		return _rect;
    	}
    }
    public void SetPosData(ItemPos itemPos)
    {
    	Rect.anchoredPosition = Vector2.right*itemPos.X;
    	Rect.localScale = Vector3.one*itemPos.Scale;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    运行游戏,可以看到生成出来的图片已经有了位移和缩放变化,只不过还存在一些层级问题

    2.4 计算层级

    下面来解决前面出现的层级问题。一种解决方案是给每个子物体添加Canvas组件,并单独设置它们的「Sort Order」属性。但这种方案会增加额外的draw call,造成性能问题,因此不推荐使用。另一种方案是动态改变子物体在Hierarchy面板上的顺序,将靠近摄像机的图片置于下方。这里采用第二种方案。

    首先给「ItemPos」添加一个Order字段,用来表示该图片对应的层级。因为后面需要单独对_posData集合中的Order字段进行修改,所以要把「ItemPos」改为class类型。

    public class ItemPos  
    {  
        public float X;  
        public float Scale;  
        public int Order;  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    修改CalculateItemData()方法,通过Linq给_posData集合按Scale从小到大进行排序,生成一个新的集合。根据新的集合的顺序,给Order属性赋值

    private void CalculateItemData()
    {
    	// 椭圆轨道周长
    	float length = (ItemSize.x + Offset) * _items.Count;
    	// 比例系数
    	float radioOffset = 1 / (float) _items.Count;
    	float radio = 0;
    	for (int i = 0; i < _items.Count; i++)
    	{
    		ItemPos data = new();
    		data.X = GetX(radio, length);
    		data.Scale = GetScaleTimes(radio, MaxScale, MinScale);
    		radio += radioOffset;
    		_posData.Add(data);
    	}
    
    	var newPosData = _posData.OrderBy(u => u.Scale).ToList();
    	for (int i = 0; i < newPosData.Count; i++)
    	{
    		newPosData[i].Order = i;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    然后在「SlideShowItem」类中的SetPosData()方法中,将Order值设置为当前物体在Hierarchy面板上的层级即可。利用transform.SetSiblingIndex()方法可以很方便地实现这一点。

    public void SetPosData(ItemPos itemPos)
    {
    	Rect.anchoredPosition = Vector2.right*itemPos.X;
    	Rect.localScale = Vector3.one*itemPos.Scale;
    	transform.SetSiblingIndex(itemPos.Order);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    运行游戏,可以发现层级已显示正常

    2.5 实现旋转效果

    下面来实现鼠标拖拽旋转效果。既然涉及到拖拽,那么实现「IDragHandler」和「IEndDragHandler」两个接口就是第一选择。我们让「SlideShowItem」类实现这两个接口,并重写OnDrag()OnEndDrag()这两个方法。OnDrag()方法传入的参数中,有一个delta属性,它记录了鼠标拖拽的位移,我们将它记录在成员变量中。然后定义一个委托,这个委托可以从外部传入,然后在OnEndDrag()中调用。

    // 鼠标拖动增量  
    private float _moveDelta;  
    // 拖动结束时的回调  
    private Action<float> _moveAction;
    
    public void OnDrag(PointerEventData eventData)
    {
    	_moveDelta = eventData.delta.x;
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
    	_moveAction(_moveDelta);
    	_moveDelta = 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    直接将_moveAction属性暴露出去并不安全,因此可以定义一个设置监听事件的方法

    /// 
    /// 添加拖动监听事件
    /// 
    /// 
    public void AddMoveListener(Action<float> onMove)
    {
    	_moveAction = onMove;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    下面来分析一下旋转效果的实现原理。其实很简单,就是重新设置每一个图片的PosData。由于我们的「ItemPos」数据存储在了一个数组中,因此相当于把图片由原本的_posData[i]替换成_posData[i+1]_posData[i-1]。所以我们需要记录一下刚开始每个图片的下标。在「SlideShowItem」类中新增一个ItemIndex属性

    // 下标  
    public int ItemIndex;
    
    • 1
    • 2

    然后在「SlideShow3D」中的SetItemData()方法中进行赋值

    private void SetItemData()
    {
    	for (int i = 0; i < _items.Count; i++)
    	{
    		_items[i].SetPosData(_posData[i]);
    		_items[i].ItemIndex = i;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    接下来在「SlideShow3D」类中添加回调方法。根据传入的鼠标位移值的正负,判断向右移动还是向左移动。

    private void CreateItems()
    {
    	var template = CreateTemplate();
    	foreach (var sprite in ItemSprites)
    	{
    		var slideShowItem = Instantiate(template).GetComponent<SlideShowItem>();
    		slideShowItem.SetParent(transform);
    		slideShowItem.SetSprite(sprite);
    		// 添加事件监听
    		slideShowItem.AddMoveListener(MoveItem);
    		_items.Add(slideShowItem);
    	}
    	Destroy(template);
    }
    
    private void MoveItem(float moveDelta)
    {
    	int symbol = moveDelta > 0 ? 1 : -1;
    	for (int i = 0; i < _items.Count; i++)
    	{
    		_items[i].ChangeIndex(symbol,_items.Count);
    		_items[i].SetPosData(_posData[_items[i].ItemIndex]);
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    这里的ChangeIndex()方法定义在「SlideShowItem」类中,用来更新ItemIndex属性

    /// 
    /// 变更下标
    /// 
    /// 下标变化量
    /// item总数
    public void ChangeIndex(int symbol,int total)
    {
    	int id = ItemIndex;
    	id += symbol;
    	if (id < 0)
    	{
    		id += total;
    	}
    	ItemIndex = id % total;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    运行游戏,可以看到现在旋转功能基本实现,只是还缺少转动时的动画

    2.6 添加旋转动画

    给旋转效果添加动画可以直接使用DOTween插件,十分简单高效。这里之所以使用协程是为了防止动画播放过程中出现层级显示问题。

    public void SetPosData(ItemPos itemPos)
    {
    	Rect.DOAnchorPos(Vector2.right * itemPos.X, _aniTime);
    	Rect.DOScale(Vector3.one*itemPos.Scale, _aniTime);
    	StartCoroutine(WaitAnime(itemPos));
    }
    
    private IEnumerator WaitAnime(ItemPos itemPos)
    {
    	yield return new WaitForSeconds(_aniTime * 0.5f);
    	transform.SetSiblingIndex(itemPos.Order);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    最后的效果如下


    源码下载

  • 相关阅读:
    平台卡卷API文档分享
    百余门店闭门谢客,韩妆如何败给了国潮?
    沁恒CH32V103C8T6(二): Linux RISC-V编译和烧录环境配置
    【附源码】计算机毕业设计SSM实验室资产管理系统
    基于白鲸优化的BP神经网络(分类应用) - 附代码
    Spring(一)Spring配置、构造注入、bean作用域、bean自动装配
    LeetCode SQL专项练习 (8) 计算函数
    【JY】YJK前处理参数详解及常见问题分析:刚度系数(三)
    window安装ELK
    USB device ‘FTDI Dual RS232-HS‘ with UUID
  • 原文地址:https://blog.csdn.net/LWR_Shadow/article/details/126798433