深入探讨并实现Unity图像轮播
最近接了一个展厅的小项目,需求中有一个图像轮播的小功能,本来以为这是一碟小菜,心想瞬间有了N中解决方案,然而,现实是pia pia打脸,幸好最终完美实现了效果,才保住了作为公司中几个妹子心目中“大神”的老脸。
先看看最终效果:
注意哦,它两边是有一个渐隐效果的。
说一说制作历程和思路吧:
思路一
一看到我们美监妹子的效果图,第一个想到的是自带的ScrollView(ScrollRect组件),然而确认了需求后,说是这个轮播是要无限循环的,就是说滚动是无穷无尽的,朝左边拖动,最左边的要能自动跑到最右边,朝右边拖动,最右边的要能自动跑到最左边,如果用ScrollRect做,势必要有比较复杂的位置计算,果断PASS掉了。
然后有了第二个思路:
还是用UGUI,所有的图放到一个具有Mask组件的Image下,然后所有的图用anchoredPosition求得与中心点(0,0)的距离,然后用这个距离做一个反比系数,用这个系数去控制图像的尺寸,即:如果距离如果为0,则图像的Size最大,否则,就越小。
k
=
1
−
(
a
n
c
h
o
r
e
d
P
o
s
i
t
i
o
n
−
(
0
,
0
)
)
.
m
a
g
n
i
t
u
d
e
轮
播
组
件
宽
度
/
2
k = 1 - {{(anchoredPosition-(0,0)).magnitude}\over{轮播组件宽度/2}}
k=1−轮播组件宽度/2(anchoredPosition−(0,0)).magnitude
public void OnDrag(PointerEventData eventData)
{
Vector2 delta = eventData.delta;
delta.y = 0; // 排除纵向的拖动
foreach( CarouselImage image in AllImages )
{
image.anchoredPosition += delta;
float k = 1 - Mathf.Abs(image.anchoredPostion.x) / HalfWidth;
image.sizeDelta *= k;
}
}
嗯,看上去不错,然而,这样做有一点没有考虑,那就是中间那个图应该是位于最上层,两边的图应该处于底层,这就需要动态的对他们排序了。很显然,可以用上面的k值进行排序,K越大SiblingIndex就应该越大,这样可以保证中间的图位于顶层。然而,试了几种修改SiblingIndex的方法,都觉得不够优雅,而且设置SiblingIndex的时机总是不对。哎,抓耳挠腮之际,突然想到,干嘛非要局限于一个平面呢。咱们unity可是3D的。
第三个思路
用SpriteRenderer(后来改为了World模式下的Canvas,原因是轮播并不是直接播放原图,而是进行了特定的排版,比如加上边框,图像上添加说明文字等等),围绕一个点去旋转,这些图像设置为广告板,一切问题迎刃而解。
public void LoadImages( CarouselImage [] images )
{
float deltaAngle = 2f * Mathf.PI / images.Length;
for( int i = 0; i < images.Length; ++i)
{
images[i].localPosition = new Vector3(
-Mathf.Sin(i * deltaAngle) * Radius,
0,
Mathf.Cos(i * deltaAngle ) * Radius);
}
}
嗯。这样的话,所有的图像都平均分布在这个圆的周围了,想要轮播,只需要绕y轴转动这个顶层的物体就行了。
嗯,看上去不错了,但是。。因为这个是圆,所有的图片围绕这个圆平均分布,但有一个问题是,当图片数量不同时,图片的稀疏程度就会不同,但我们美监要求的是,除非图片数量少于N,否则无论多少图片,可见部分的图始终是N张。那就意味着,这个圆上分布的图并不是均匀分布的。而且圆的半径也很难调整。额。。这个思路又放弃了。
终极思路
怎么办,怎么才能优雅的实现这个呢?受上一个思路的启发,实际上,圆本身是不需要的,本质上我只需要一个圆弧,然后在这段弧上,分布N张图片(图片总量大于N),看不见的图应当可以disable掉。关键是这个弧了。然后,我就想到了贝塞尔曲线。
// Class CarsoulManager
//贝塞尔曲线顶点
[SerializeField]
private Vector3 [] BezierPos = new Vector3 []
{
new Vector3[ -160, 0, 80 ],
new Vector3[ 0, 0, 0 ],
new Vector3[ 160, 0, 80 ]
};
// 支持四阶、三阶、二阶贝塞尔曲线
public Vector3 GetBezierPosition( float lerp )
{
int count = BezierPos.Length;
if (count >= 4)
{
Vector3 p1 = Vector3.Lerp(BezierPos[0], BezierPos[1], lerp);
Vector3 p2 = Vector3.Lerp(BezierPos[1], BezierPos[2], lerp);
Vector3 p3 = Vector3.Lerp(BezierPos[2], BezierPos[3], lerp);
Vector3 p4 = Vector3.Lerp(p1, p2, lerp);
Vector3 p5 = Vector3.Lerp(p2, p3, lerp);
return Vector3.Lerp(p4, p5, lerp);
}
else if (count == 3)
{
Vector3 p1 = Vector3.Lerp(BezierPos[0], BezierPos[1], lerp);
Vector3 p2 = Vector3.Lerp(BezierPos[1], BezierPos[2], lerp);
return Vector3.Lerp(p1, p2, lerp);
}
else if (count == 2)
{
return Vector3.Lerp(BezierPos[0], BezierPos[1], lerp);
}
else if (count == 1)
return BezierPos[0];
else
return Vector3.zero;
}
有了这个贝塞尔曲线,那么加载好的图像就可以平均分配到这条曲线上了。这很容易,使用Lerp就可以了,整个曲线从最左边到最右边,看作是从0到1,只要给每张图分配不同的Lerp值就好了。
// Class CarouselImage:
private float m_lerpVal = 0;
public float LerpValue
{
get { return m_lerpVal; }
set
{
m_lerpVal = value;
if ((m_lerpVal < -Carousel.Spacing ) || ( m_lerpVal > 1+Carousel.Spacing))
{
if (gameObject.activeSelf)
gameObject.SetActive(false);
}
else
{
if (!gameObject.activeSelf)
gameObject.SetActive(true);
transform.localPosition = Carousel.GetBezierPosition(m_lerpVal);
}
}
}
为了让第0张图在一开始时就位于中心位置,所以加载是按照如下方式进行的:
// Class CarouselManager:
public void LoadImages(CarouselImage [] items)
{
// 计算每张图的间隔
float spacing = 1f / N;
int len = items.Length;
for( int i = 0; i < len; ++ i )
{
if( i >= m_Pictures.Count )
{
CarouselImage pic = Instantiate<CarouselImage >(PicturePrefab, transform);
pic.WorldCamera = TheCamera;
m_Pictures.Add(pic);
}
m_Pictures[i].Pictures = items[i];
int k = ((i % 2 == 0) ? 1 : -1) * ((i + 1) / 2);
m_Pictures[i].LerpValue = 0.5f + k * spacing;
}
for (int i = m_Pictures.Count - 1; i >= len; --i)
{
DestroyImmediate(m_Pictures[i].gameObject);
m_Pictures.RemoveAt(i);
}
}
稍微解释一下,因为轮播组件是要频繁更换图片的,所以可能会多次调用LoadImages方法,于是,为了节约一丢丢的性能,这里就不删除上次加载的图,而是重新赋予他们新的图像,只有上次加载的图像数量不足时,才创建新的实例,当然,如果上次加载的图像比这次还多,那最后剩余的图像还是要删除的。
那么如何滑动交互呢?很简单:
// Class CarouselTouch
public void OnTouchDrag(float delta)
{
float min = float.MaxValue;
float max = float.MinValue;
float spacing = 1f / N;
// 首先对所有图像进行新的位置计算,并顺便找到最左边和最右边的图像
foreach( CarouselImage pic in m_Pictures )
{
pic.LerpValue += delta;
if( pic.LerpValue > max )
{
max = pic.LerpValue;
}
if( pic.LerpValue < min )
{
min = pic.LerpValue;
}
}
// 如果是向左滑动的话,把最左边的图像挪到右边去,反之亦然
foreach( CarouselImage pic in m_Pictures)
{
if( delta > 0 )
{
if( pic.LerpValue > ( 1f + spacing ))
{
pic.LerpValue = min - spacing;
min = pic.LerpValue;
}
}
else
{
if( pic.LerpValue < - spacing )
{
pic.LerpValue = max + spacing;
max = pic.LerpValue;
}
}
}
}
很完美了。只剩下最后一个问题:美监要求两边要渐隐。其实这个实现起来也不是很难,两种方法:
1、写Shader,当图像的lerp值超过一个阈值时,比如小于0.1或者大于0.9时,就开始把两边透明化。
2、不想写代码的话,搞个摄像机,对准整个CarouselManager组件,并设置为只渲染Carousel相关的图像layer,渲染到一张RenderTexture中,对这个RenderTexture就可以做两边透明话的处理了,网上搜一下AlphaMask,一堆类似的插件,本质上也是个Mask,放到RawImage中即可。
最后,再看一下整体效果图:
当然了,如果不想要中间大,两边小的效果,可以吧贝塞尔曲线的Z值全设置为0,这样曲线就退化为一条直线了,效果如下:
甚至是这样子的效果哦:
最后,当然这个组件还可以继续再完善,继续再研发其他的效果,比如自动缓缓播放等等,这些都不难,这里就不再继续探讨了。