【Unity笔记】复现明日方舟抽卡界面UI效果

前言

在《明日方舟》中,抽卡界面卡池左右滚动时,实现了3种基于滚动容器的额外效果。

  • 1、其卡池内展示的角色和信息,会呈现分层滚动的效果,这种视觉效果称为视差滚动
    明日方舟抽卡界面
  • 2、按页面整页滚动,而非停在2页的中间。
  • 3、当松开手指时,界面会根据当前滚动的位置自动对齐到整页。
  • 4、当在界面快速向左右轻扫,页面会快速滚动到左边分页或者右边分页。
    个人非常喜欢这种效果,因此特别好奇如何能复现这种滚动效果。在unity中尝试了几天,终于琢磨出了复现方法。现归纳和整理了思路和实现代码,分享给各位。

Unity工程准备

创建UGUI的测试用界面预制体,并在界面中设置了一个水平的滚动容器,滚动容器中根据需要设置3个~4个tab分页,每个分页尺寸与viewport视口大小一致。


ScrollRect设置

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


content设置

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


layer设置

各layer中可以随意放置图片、按钮、文本等UI元素,位置可以根据UI示意图需要调整。

需要实现的功能清单

  • 按tab整页滚动。
  • 当滚动结束时判断当前滚动位置,自动对齐到tab页。
  • 轻扫快速滚动。
  • 各卡池内分层图片在滚动的时候,以不同的速度移动,呈现视差滚动效果。

原理解释

ScrollRect的inspector窗口中提供的参数中,并没有可以直接按整页滚动的选项。
要实现整页滚动,需要引入一个unity提供的API = horizontalNormalizedPosition(水平归一化位置)
horizontalNormalizedPosition是用一个从0到1的浮点数来指代content当前的水平滚动位置,而我们需要想办法将tab页索引为水平归一化位置值。
大致图形化是下图这样


tab页索引为归一化对应值

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

(特别备注:此处我的解释不一定正确,但实际就是代码能正常执行并且效果基本达到,所以姑且就是这样吧)
图片.png

实现效果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层,都进行了偏移值操作


图片.png

优化方向
这个实现逻辑不一定是最好的,应该还有更优雅的解法。另外如果考虑到性能,滚动容器中如果只生成中间、左边、右边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) { /* 同前 */ }
}
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容