前言
在《明日方舟》中,抽卡界面卡池左右滚动时,实现了3种基于滚动容器的额外效果。
-
1、其卡池内展示的角色和信息,会呈现分层滚动的效果,这种视觉效果称为视差滚动明日方舟抽卡界面
- 2、按页面整页滚动,而非停在2页的中间。
- 3、当松开手指时,界面会根据当前滚动的位置自动对齐到整页。
- 4、当在界面快速向左右轻扫,页面会快速滚动到左边分页或者右边分页。
个人非常喜欢这种效果,因此特别好奇如何能复现这种滚动效果。在unity中尝试了几天,终于琢磨出了复现方法。现归纳和整理了思路和实现代码,分享给各位。
Unity工程准备
创建UGUI的测试用界面预制体,并在界面中设置了一个水平的滚动容器,滚动容器中根据需要设置3个~4个tab分页,每个分页尺寸与viewport视口大小一致。

content需要添加水平布局器和内容布局器,content的X宽度由content size fitter来控制(如果是会动态增减tab页就必须要这个控件),如果是演示用项目,tab数量固定,也可以不用content size fitter,而是手动设置content宽度 = tab页宽度 *tab页数量。

tab分页下再创建4个空的gameobject,命名为layer1~layer4,可以增减分层数量。分层设置锚点为居中对齐{0.5,0.5},然后设置固定大小,不要设为拉伸以匹配父级tab页的大小。因为视差滚动要控制这几个layer页的X值

各layer中可以随意放置图片、按钮、文本等UI元素,位置可以根据UI示意图需要调整。
需要实现的功能清单
- 按tab整页滚动。
- 当滚动结束时判断当前滚动位置,自动对齐到tab页。
- 轻扫快速滚动。
- 各卡池内分层图片在滚动的时候,以不同的速度移动,呈现视差滚动效果。
原理解释
ScrollRect的inspector窗口中提供的参数中,并没有可以直接按整页滚动的选项。
要实现整页滚动,需要引入一个unity提供的API = horizontalNormalizedPosition(水平归一化位置)
horizontalNormalizedPosition是用一个从0到1的浮点数来指代content当前的水平滚动位置,而我们需要想办法将tab页索引为水平归一化位置值。
大致图形化是下图这样

基于总tab页数量,将其按照等分公式计算为归一化值。我们的代码核心就是基于此数值,通过设置归一化位置,来达到让页面按整页滚动。

实现效果gif
这里先放实现后的gif效果

一、视差滚动
这部分代码本来想求助于DeepSeek,但是在给出需求后,AI并未正确理解需求,生成的代码完全无用。后来自己思考了很久,终于琢磨出原理和代码并实现了效果。
这部分的视差滚动效果是:当页面从屏幕外滚动进入时,该tab页的每一层Layer会以不同的速度进行位移,当该tab滚动完成后,各Layer恰好完美居于界面中正确的位置。但当页面滚动离开屏幕时,会重新看到每一层Layer以不同的速度进行位移。
实现思路:每个Tab页有自己的归一化位置,而滚动容器有个初始位置,只要计算每个tab页相对于初始位置的偏移值,基于该偏移值来对每个Layer添加额外的OffSetX值。这样当进行滚动时,只要ScrollRect.horizontalNormalizedPosition等于页面位置索引值,各Layer层位置归零,但值不同时,就依据偏移值操作各Layer的位置。再辅助DOTween动画插件或者Vector2.Lerp进行插值计算,就可以实现视差滚动效果。
如下图所示,设ScrollRect初始化后展示的是tab1页面,此时ScrollRect.horizontalNormalizedPosition值就是0,而tab2和tab3的位置索引值为0.5和1,就与ScrollRect.horizontalNormalizedPosition产生了偏移。

关键核心是基于ScrollRect的OnValueChanged,监听当归一化值发生变动时,对tab页下各Layer层进行X值操作。并用Vector2.Lerp进行平滑插值操作移动
//视差滚动参数
private List<RectTransform> tabs = new List<RectTransform>();//tab页
private List<List<RectTransform>> tabLayers = new List<List<RectTransform>>();//tab页下的各分层
[Header("Parallax OffsetX")]
[SerializeField][Range(10f, 5000f)] private float[] layerOffset = new float[4] { 0, 500f, 1800f, 3600f };//各分层的offsetX值
//监听视差滚动
Start()
{
scrollRect.onValueChanged.AddListener(OnScrollValueChanged);
InitializeTabs();
}
#region 分页内部视差滚动
// 视差滚动
// 初始化添加所有标签页和图层数据
void InitializeTabs()
{
foreach (Transform tab in scrollRect.content)
{
tabs.Add(tab.GetComponent<RectTransform>());
List<RectTransform> layers = new List<RectTransform>();
List<Vector2> positions = new List<Vector2>();
foreach (Transform layer in tab)
{
layers.Add(layer.GetComponent<RectTransform>());
positions.Add(layer.GetComponent<RectTransform>().anchoredPosition);
}
tabLayers.Add(layers);
}
}
// 滚动值变化监听并进行偏移设置
void OnScrollValueChanged(Vector2 normalizedPos)
{
for (int i = 0; i < tabLayers.Count; i++)
{
List<RectTransform> layers = tabLayers[i];
for (int j = 0; j < layers.Count; j++)
{
float offsetX = (float)(Math.Round(tabInitPositions[i], 2) - Math.Round(scrollRect.horizontalNormalizedPosition, 2));
float newPosition = offsetX * layerOffset[j];
if (offsetX == 0)
{
Vector2 currentPosition = layers[j].anchoredPosition;
layers[j].anchoredPosition = Vector2.Lerp(currentPosition, new Vector2(0, 0), Time.deltaTime * snapSpeed);
}
else
{
Vector2 currentPosition = layers[j].anchoredPosition;
layers[j].anchoredPosition = Vector2.Lerp(currentPosition, new Vector2(newPosition, layers[j].anchoredPosition.y), Time.deltaTime * snapSpeed);
}
}
}
}
#endregion
Unity工程中实现后,可以看到在Scene中,滚动容器中各tab页的layer层,都进行了偏移值操作

优化方向:
这个实现逻辑不一定是最好的,应该还有更优雅的解法。另外如果考虑到性能,滚动容器中如果只生成中间、左边、右边3个分页,更多的就在滚动后动态生成,那么实现逻辑应该还有差别。
二、整页滚动及自动对齐代码实现(由DeepSeek依据需求给出)
因此我们首先要实现的就是基于内容子项数量进行tab页索引计算,代码如下
private List<float> tabInitPositions = new List<float>();//tab页归一化初始位置
// ========== 计算tab页位置索引值逻辑 ==========
private void CalculatePagePositions()
{
tabInitPositions.Clear();
float contentWidth = scrollRect.content.rect.width;
float pageStep = 1f / (scrollRect.content.childCount - 1);
for (int i = 0; i < scrollRect.content.childCount; i++)
{
tabInitPositions.Add(pageStep * i);
}
}
接下来,我们要思考怎么判断滚动结束了呢?就要用到ScrollRect提供的API接口 OnBeginDrag() 和 OnEndDrag() 了,这个方法会在ScrollRect判断滚动开始和停止时执行写入的逻辑或者方法。这里我们要引入一个bool值来判断是正在滚动还是滚动已经停止了,因此代码如下:
private bool isDragging;//是否正在滚动
private void OnBeginDrag()
{
isDragging = false;//滚动开始
}
private void OnEndDrag()
{
isDragging = false;//滚动停止
}
接着,我们就可以在Update方法里,根据是否停止滚动来执行对应的核心业务逻辑了。当停止滚动时,执行SnapToPage方法,使用数学公式Mathf.Lerp来平滑滚动至对应页面。FindClosestPage()方法是用来判断滚动停止时,当前归一化位置值是更靠近哪个分页索引值,基于该值来确定自动滚动到哪个tab页。
[Header("Settings")]
[SerializeField] private float snapSpeed = 15f;//滚动速度值
public override void Update()
{
if (!isDragging)
{
SnapToPage();
}
}
private void SnapToPage()
{
float currentPos = scrollRect.horizontalNormalizedPosition;
int closestPage = FindClosestPage(currentPos);
// 平滑移动
float targetPos = tabInitPositions[closestPage];
scrollRect.horizontalNormalizedPosition = Mathf.Lerp(
currentPos,
targetPos,
Time.deltaTime * snapSpeed
);
currentPage = closestPage;
}
private int FindClosestPage(float currentPos)
{
float minDistance = float.MaxValue;
int closestIndex = 0;
for (int i = 0; i < tabInitPositions.Count; i++)
{
float distance = Mathf.Abs(currentPos - tabInitPositions[i]);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
三、轻扫快速滚动(由DeepSeek依据需求给出)
这部分主要是几个参数值检测,当判断玩家触发滚动时间短于一个设定值,并且距离也短于一个设定值时,触发轻扫快速切换功能。直接上代码吧
public class PageSnapScroll : MonoBehaviour
{
// 原有字段
[Header("Basic Settings")]
[SerializeField] private float snapSpeed = 15f;
// 新增轻扫检测参数
[Header("Swipe Detection")]
[SerializeField] [Range(0.1f, 0.5f)] private float swipeTimeThreshold = 0.2f;
[SerializeField] [Range(1000f, 5000f)] private float swipeVelocityThreshold = 2500f;
[SerializeField] [Range(0.05f, 0.2f)] private float swipeDistanceThreshold = 0.1f;
private ScrollRect scrollRect;
private List<float> pagePositions = new List<float>();
private int currentPage = 0;
private bool isDragging;
// 新增轻扫检测变量
private float dragStartTime;
private float dragStartPosition;
private void Awake()
{
scrollRect = GetComponent<ScrollRect>();
}
private void Start()
{
CalculatePagePositions();
}
private void Update()
{
if (!isDragging)
{
SnapToPage();
}
}
// ========== 事件处理 ==========
public void OnBeginDrag(PointerEventData eventData)
{
isDragging = true;
RecordDragStart();
}
public void OnEndDrag(PointerEventData eventData)
{
isDragging = false;
CheckSwipeGesture();
}
// ========== 轻扫检测核心逻辑 ==========
private void RecordDragStart()
{
dragStartTime = Time.time;
dragStartPosition = scrollRect.horizontalNormalizedPosition;
}
private void CheckSwipeGesture()
{
// 计算滑动参数
float dragDuration = Time.time - dragStartTime;
float dragDelta = Mathf.Abs(scrollRect.horizontalNormalizedPosition - dragStartPosition);
float currentVelocity = Mathf.Abs(scrollRect.velocity.x);
// 轻扫条件判断
bool isQuickSwipe = dragDuration < swipeTimeThreshold;
bool isFastEnough = currentVelocity > swipeVelocityThreshold;
bool isShortDistance = dragDelta < swipeDistanceThreshold;
if (isQuickSwipe && isFastEnough && isShortDistance)
{
// 根据方向切换页面
int direction = scrollRect.velocity.x > 0 ? -1 : 1;
JumpToAdjacentPage(direction);
}
else
{
// 原有分页逻辑
SnapToPage();
}
}
private void JumpToAdjacentPage(int direction)
{
int targetPage = Mathf.Clamp(currentPage + direction, 0, pagePositions.Count - 1);
// 使用协程实现快速滑动
StartCoroutine(QuickSnapCoroutine(targetPage));
}
private System.Collections.IEnumerator QuickSnapCoroutine(int targetPage)
{
float originalSpeed = snapSpeed;
snapSpeed *= 3f; // 临时提高滑动速度
currentPage = targetPage;
yield return StartCoroutine(SmoothSnap(pagePositions[targetPage]));
snapSpeed = originalSpeed; // 恢复原速度
}
private System.Collections.IEnumerator SmoothSnap(float targetPos)
{
float startPos = scrollRect.horizontalNormalizedPosition;
float progress = 0f;
while (progress < 1f)
{
progress += Time.deltaTime * snapSpeed;
scrollRect.horizontalNormalizedPosition = Mathf.Lerp(startPos, targetPos, progress);
yield return null;
}
scrollRect.horizontalNormalizedPosition = targetPos; // 确保最终位置准确
}
// ========== 原有核心方法保持不变 ==========
private void CalculatePagePositions() { /* 同前 */ }
private void SnapToPage() { /* 调整为目标页跳转 */ }
private int FindClosestPage(float currentPos) { /* 同前 */ }
}
