首先在Canvas下创建一个空物体,将其命名为「SlideShow」,并设置好其大小。它将作为轮播图的父容器。
在「SlideShow」身上挂载一个脚本,命名为「SlideShow3D」。声明一个Vector2成员用来设定每张图片的大小,一个Sprite数组用来存储需要展示的图片
public class SlideShow3D : MonoBehaviour
{
// 图片大小
public Vector2 ItemSize;
// 图片集
public Sprite[] ItemSprites;
}
新创建一个脚本命名为「SlideShowItem」,作为子物体身上挂载的脚本。
public class SlideShowItem : MonoBehaviour
{
}
由于子物体都具有相同的特征,因此单独写一个创建子物体模板的方法
private GameObject CreateTemplate()
{
GameObject item = new GameObject("Template");
item.AddComponent<Image>();
item.AddComponent<RectTransform>().sizeDelta = ItemSize;
item.AddComponent<SlideShowItem>();
return item;
}
接下来通过给生成的模板添加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);
}
这里将设置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;
}
}
现在运行游戏,可以看到图片成功生成了出来,并且都堆在了(0,0)位置。
首先分析一下图片的移动轨迹。从俯视角来看,图片是在一个椭圆形的轨迹上移动
但摄像机是位于轮播图的正前方,也就是说在摄像机的角度来看,图片是在一条数轴上往复移动。图片的远近通过缩放来表示。
因此这里可以定义一个方法,通过图片在椭圆轨道上的位置计算出映射到数轴上的位置
///
/// 获取图片在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);
}
}
接下来计算图片的放大系数。这个比较简单,只需要定义出最大放大系数(图片离相机最近时)和最小放大系数(图片离相机最远时),就可以根据图片在椭圆轨道上的位置,计算出当前的缩放系数。
///
/// 获取放大系数
///
/// 图片在椭圆上的位置[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);
}
}
有了这两个方法,我们就可以计算出每个图片所在的位置及其缩放。这里可以把这两个信息封装成一个结构体,便于传参
// 最大缩放系数
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);
}
}
在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]);
}
}
「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;
}
运行游戏,可以看到生成出来的图片已经有了位移和缩放变化,只不过还存在一些层级问题
下面来解决前面出现的层级问题。一种解决方案是给每个子物体添加Canvas组件,并单独设置它们的「Sort Order」属性。但这种方案会增加额外的draw call,造成性能问题,因此不推荐使用。另一种方案是动态改变子物体在Hierarchy面板上的顺序,将靠近摄像机的图片置于下方。这里采用第二种方案。
首先给「ItemPos」添加一个Order
字段,用来表示该图片对应的层级。因为后面需要单独对_posData
集合中的Order
字段进行修改,所以要把「ItemPos」改为class类型。
public class ItemPos
{
public float X;
public float Scale;
public int Order;
}
修改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;
}
}
然后在「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);
}
下面来实现鼠标拖拽旋转效果。既然涉及到拖拽,那么实现「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;
}
直接将_moveAction
属性暴露出去并不安全,因此可以定义一个设置监听事件的方法
///
/// 添加拖动监听事件
///
///
public void AddMoveListener(Action<float> onMove)
{
_moveAction = onMove;
}
下面来分析一下旋转效果的实现原理。其实很简单,就是重新设置每一个图片的PosData。由于我们的「ItemPos」数据存储在了一个数组中,因此相当于把图片由原本的_posData[i]
替换成_posData[i+1]
或_posData[i-1]
。所以我们需要记录一下刚开始每个图片的下标。在「SlideShowItem」类中新增一个ItemIndex
属性
// 下标
public int ItemIndex;
然后在「SlideShow3D」中的SetItemData()
方法中进行赋值
private void SetItemData()
{
for (int i = 0; i < _items.Count; i++)
{
_items[i].SetPosData(_posData[i]);
_items[i].ItemIndex = i;
}
}
接下来在「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]);
}
}
这里的ChangeIndex()
方法定义在「SlideShowItem」类中,用来更新ItemIndex
属性
///
/// 变更下标
///
/// 下标变化量
/// item总数
public void ChangeIndex(int symbol,int total)
{
int id = ItemIndex;
id += symbol;
if (id < 0)
{
id += total;
}
ItemIndex = id % total;
}
运行游戏,可以看到现在旋转功能基本实现,只是还缺少转动时的动画
给旋转效果添加动画可以直接使用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);
}
最后的效果如下