UGUI动态元素大小的滑动无限列表

效果与使用说明

效果

  1. 可以滑动
  2. 无限列表(严格来说也和常规的不太一样)
  3. 可以通过曲线调整元素大小

在这里插入图片描述

使用说明

  1. 列表元素位于脚本挂载处的直接子级
  2. 最大的元素位于脚本挂载元素的pivot处
  3. 水平列表的对齐依据是所有元素pivot都在一条线上
  4. 默认在最左侧和最右侧元素外有1个元素(本身看不见,但是在移动的时候可能会移动到视野内)
  5. 基于4,如果希望view外还有更多的元素(虽然不知道出于什么目的),可以调大“左/右侧元素数目”并加mask遮罩住。
  6. 通过dragFactor调整拖动的敏感度
  7. 设置InterestedElem的意义是动态传递出某个感兴趣的数据的下标(因而可以实现某些视觉效果,例如某个特效的跟随)
  8. 不同的项目资源管理和加载的方法不太一样,我这里Init是简单地读取直接子级的元素(作演示用),在实际使用时需要更改为自己项目的方式
  9. 如果期望在列表滑到最左侧或者最右侧有一些效果,则需要为Action<bool,bool> OnReachSide添加实现,其中第一个bool代表是否到达左侧,第二个bool代表是否到达右侧。
  10. 如果期望某个元素被选中(即该元素出现在最大的那个位置)有效果,则需要为OnSelectElem添加实现,例如选中元素后展示该元素的详细信息。值得注意的是,在滑动过程中任何一个元素经过最大元素的位置都会触发这个,即使滑动还没有停下。如果期望在滑动结束才传递出对应元素的信息,则需要调用OnSelectElemStable

【关于曲线】

曲线的横坐标,0处是view最左侧元素的大小,1是view最右侧元素的大小,0.5是中间最大元素。

纵坐标代表相对于中间最大元素,也就是说一般情况下0.5处的纵坐标应当为1

原理

选定最大元素右边的元素作为StepLength(别抬杠说为什么左边的不行,反正不一定两侧都有元素,到时候自己改一下

根据输入的长度L,使用L/StepLength得到一个标准化的拖动距离,代表元素被拖动越过几个元素的位置。

然后根据拖动的距离插值即可,可以认为类似于关键帧动画。

考虑到某一帧玩家可能滑动速度极快,快到多个元素都会划过中间最大元素的位置(好吧我不知道他们为什么要这么干但我有理由相信有人会这么干),此时不必要把一个元素插值经过好几个记录点。我们本可以把这个简化为向相邻元素的拖动移动过程,所以先整体刷新到合适的位置,比如说想做移动4.5个标准化的拖动距离,那我就先刷新数据为移动完4个的情况,再向左插值0.5个元素的移动

这种刷新实质上造成了拖动元素后该元素显示内容被重新(甚至反复)刷新,间接存在性能问题(我在后文的缺陷也提到了)

其实一开始不是很想使用Update的,但是纯在OnDrag里实现容易出Bug,尤其是一帧存在多个拖动的情况。

代码

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/*
* 总体逻辑就根据中间元素的位置确认其余元素的位置和缩放
* 比最中间元素稍小的元素称为次级元素,次级元素pivot到中间元素pivot的距离为单位距离StepLength
* 根据拖动距离和StepLength的比值决定元素会朝某个方向移动多少
* 最中间的元素默认位于父元素的pivot处
* 1. 列表元素位于脚本挂载处的直接子级
* 2. 最大的元素位于脚本挂载元素的pivot处
* 3. 曲线的横坐标,0处是view最左侧元素的大小,1是view最右侧元素的大小,0.5是中间最大元素
* 4. 曲线的纵坐标,表示较之于最大元素的缩放比例,一般情况下0.5处值为1
* 5. 如无特殊情况,一般建议maxElemWidth和最大元素的保持一致
*/
public class DynamicElemSizeScroll : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [Tooltip("元素较之于最大元素的衰减")]
    public AnimationCurve curve;
    [Tooltip("左侧展示元素的个数")]
    public int leftElemCount = 3;
    [Tooltip("右侧展示元素的个数")]
    public int rightElemCount = 3;
    [Tooltip("最大元素的宽度")]
    public float maxElemWidth;
    [Tooltip("元素间距")]
    public int elemBias = 20;
    [Tooltip("拖拽的敏感程度")]
    public float dragFactor = 1f;
    [Tooltip("是否启用感兴趣的元素标记,启用后会记录展示感兴趣的元素的位置用来实现某些效果")]
    public bool useInterestedElem = false;

    // 存有要展示的所有元素的信息
    public List<int> elemData;
    // 依据elemData的值来刷新指定元素
    public Action<Transform, int, int> refreshElem;
    public Action<bool, bool> OnReachSide;  // 是否到达了某一侧,第一个bool是左,第二个bool是右
    public Action<int> OnSelectElem;        // 当某个元素被选中(在拖拽过程中,有元素经过最大的位置就会被选中)
    public Action<int> OnSelectElemStable;  // 当某个元素被选中(且无拖拽)才会调用这个
    public Action<Transform> OnInterestedIdxDisplay;    // 当某个感兴趣的数据处于被展示的状态时,调用这个函数用来实现某些效果(例如于其上显示某些东西)
    public int interestedIdx = -1;  // 当存在某个感兴趣的数据被展示时,计划传出其坐标,用以展示某些效果

    private bool needUpdate = false;
    private int curSelectDataIdx = -1;
    private int adjustOffset = 0;
    private float lastDragNormalizedDistance = 0;
    // drag relating
    bool isDraging = false;
    private Vector3 dragStartPosition;
    private Vector3 dragEndPosition;
    private float dragDistance = 0;
    // scale cache
    List<Vector3> scaleCache;
    List<Vector3> positionCache;
    // elem data cache
    LinkedList<Transform> elemCache;

    private int CacheSize { get => leftElemCount + rightElemCount + 1 + 2; }
    private int CacheLeftCount { get => leftElemCount + 1; }
    private float StepLength { get => positionCache[leftElemCount + 1].x - positionCache[leftElemCount].x; }
    private int CurSelectDataIdx
    {
        get => curSelectDataIdx;
        set
        {
            if (value != curSelectDataIdx && IsValidIdx(value))
            {
                curSelectDataIdx = value;
                RefreshElemImmediately();

                if (value == 0) { OnReachSide?.Invoke(true, false); }
                else if (value == CacheSize - 1) { OnReachSide?.Invoke(false, true); }
                else { OnReachSide?.Invoke(false, false); }
                OnSelectElem?.Invoke(elemData[value]);
            }
        }
    }
    private float DragDistance
    {
        get => dragDistance;
        set
        {
            if (value == 0) { needUpdate = false; }
            if (value != dragDistance)
            {
                dragDistance = value;
                needUpdate = true;
            }
        }
    }

    void Awake()
    {
        InitDefaultCurve();
        InitScaleCache();   //这里和位置初始化有时序耦合,必须先放在前
    }

    void OnEnable()
    {
        InitPosCache();
    }

    void Update()
    {
        if (!needUpdate) return;

        var normalizedDistance = GetNormalizedLength(DragDistance);
        normalizedDistance *= dragFactor;
        normalizedDistance += adjustOffset;
        if (normalizedDistance == 0) return;    // 原地就不移动

        TryProcessInputAbsGreaterThanOne(ref normalizedDistance);
        if (!IsCanScroll(normalizedDistance))
        {
            normalizedDistance = 0;
        }
        lastDragNormalizedDistance = normalizedDistance;

        var f = normalizedDistance > 0 ? elemCache.First : elemCache.Last;
        int idx = normalizedDistance > 0 ? 0 : CacheSize - 1;

        while (IsValidIdx(idx) && f != null)
        {
            LerpElemWithInput(f, idx, normalizedDistance);
            f = normalizedDistance > 0 ? f.Next : f.Previous;

            idx += normalizedDistance > 0 ? 1 : -1;
        }
        if (useInterestedElem)
        {
            Transform tf = IsInterestedElemDisp() ? GetInterestedElemTrans() : null;
            OnInterestedIdxDisplay?.Invoke(tf);
        }
        DragDistance = 0;
    }
    #region Interface Implementation
    public void OnBeginDrag(PointerEventData eventData)
    {
        isDraging = true;
        dragStartPosition = eventData.position;
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        isDraging = false;
        dragEndPosition = eventData.position;
        adjustOffset = 0;
        DragDistance = 0;
        TryStartSanp();
    }
    public void OnDrag(PointerEventData eventData)
    {
        float deltaDistance = eventData.position.x - dragStartPosition.x;
        if (deltaDistance == 0 || !IsCanScroll(deltaDistance)) return;
        DragDistance = deltaDistance;
    }
    #endregion
    #region Data Init
    /// <summary>
    /// 在曲线没初始化的情况下初始化默认曲线
    /// </summary>
    void InitDefaultCurve()
    {
        if (curve.keys.Length > 0) return;
        curve.AddKey(0, 0.6866682f);
        curve.AddKey(1f / 6f, 0.7557174f);
        curve.AddKey(1f / 3f, 0.86251f);
        curve.AddKey(0.5f, 1f);
        curve.AddKey(2f / 3f, 0.86251f);
        curve.AddKey(5f / 6f, 0.7557174f);
        curve.AddKey(1, 0.6866682f);
    }

    void InitScaleCache()
    {
        if (scaleCache == null) scaleCache = new List<Vector3>(CacheSize);
        scaleCache.Clear();

        for (int i = 0; i < CacheLeftCount; i++)    // i
        {
            scaleCache.Add(GetScaleInCurve(CacheLeftCount - i, true) * Vector3.one);
        }
        scaleCache.Add(Vector3.one);
        for (int i = 0; i < rightElemCount + 1; i++)    // CacheLeftCount + 1 + i
        {
            scaleCache.Add(GetScaleInCurve(i + 1, false) * Vector3.one);
        }
    }

    void InitPosCache()
    {
        if (positionCache == null) positionCache = new List<Vector3>(CacheSize);
        positionCache.Clear();
        for (int i = 0; i < CacheSize; i++)
        {
            positionCache.Add(Vector3.zero);
        }

        positionCache[CacheLeftCount] = Vector3.zero;
        Vector3 preScale;
        Vector3 thisScale;
        Vector3 prePosition;
        Vector3 temp = Vector3.zero;
        //var pivot = elemCache.First.Value.GetComponent<RectTransform>().pivot;
        Vector2 pivot = new Vector2(0.5f, 0.5f);

        for (int i = 0; i < CacheLeftCount; i++)
        {
            temp = Vector3.zero;
            preScale = scaleCache[CacheLeftCount - i];
            thisScale = scaleCache[CacheLeftCount - 1 - i];
            prePosition = positionCache[CacheLeftCount - i];

            temp.x = preScale.x * maxElemWidth * pivot.x + elemBias + thisScale.x * maxElemWidth * (1f - pivot.x);
            temp.x *= -1;
            positionCache[CacheLeftCount - 1 - i] = temp + prePosition;
        }
        for (int i = 0; i < rightElemCount + 1; i++)
        {
            temp = Vector3.zero;
            preScale = scaleCache[CacheLeftCount + i];
            thisScale = scaleCache[CacheLeftCount + 1 + i];
            prePosition = positionCache[CacheLeftCount + i];

            temp.x = preScale.x * maxElemWidth * (1f - pivot.x) + elemBias + thisScale.x * maxElemWidth * pivot.x;
            positionCache[CacheLeftCount + 1 + i] = temp + prePosition;
        }
    }

    public void InitScrollData(List<int> elemData, int selectDataIdx, Action<Transform, int, int> refreshAction)
    {
        this.elemData = elemData;
        this.refreshElem = refreshAction;

        if (elemCache == null) elemCache = new LinkedList<Transform>();
        int childCount = transform.childCount;
        for (int i = 0; i < transform.childCount; i++)
        {
            var temp = transform.GetChild(i);
            elemCache.AddLast(temp);
            var rt = temp.GetComponent<RectTransform>();
            rt.anchoredPosition = positionCache[i];
            rt.localScale = scaleCache[i];
        }

        CurSelectDataIdx = selectDataIdx;
    }

    public void Init4Test(int selectDataIndex)
    {
        if (elemCache == null) elemCache = new LinkedList<Transform>();
        int childCount = transform.childCount;
        for (int i = 0; i < transform.childCount; i++)
        {
            var temp = transform.GetChild(i);
            elemCache.AddLast(temp);
            var rt = temp.GetComponent<RectTransform>();
            rt.anchoredPosition = positionCache[i];
            rt.localScale = scaleCache[i];
        }
        elemData = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8 };
        refreshElem = (Transform t, int idx, int i) =>
        {
            var numText = t.Find("#txt").GetComponent<Text>();
            numText.text = idx.ToString();
            // click index == i
        };
        RefreshElemImmediately();
    }

    public void RefreshScroll(int selectDataIndex)
    {
        CurSelectDataIdx = selectDataIndex;
    }
    #endregion

    #region LERP Logic
    float GetNormalizedLength(float input)
    {
        return input / StepLength;
    }

    bool IsCanScroll(float input)
    {
        bool res = true;
        if (input > 0 && curSelectDataIdx == 0) res = false;
        if (input < 0 && curSelectDataIdx == elemData.Count - 1) res = false;
        return res;
    }

    /// <summary>
    /// 对输入值的绝对值大于1的进行处理,确保每一次移动操作都是和相邻元素进行
    /// </summary>
    /// <param name="distance"></param>
    void TryProcessInputAbsGreaterThanOne(ref float distance)
    {
        var moveCount = CountIntegersToZero(distance);
        if (moveCount > 0)
        {
            if (distance < 0)
            {
                dragStartPosition.x -= StepLength;
                CurSelectDataIdx += moveCount;
                distance += moveCount;
            }
            else
            {
                dragStartPosition.x += StepLength;
                CurSelectDataIdx -= moveCount;
                distance -= moveCount;
            }
        }
        // 至此distance是一个介于-1到1之间的值,这样可以把任何一次移动都转化为向相邻位置元素的移动
    }

    void LerpElemWithInput(LinkedListNode<Transform> node, int index, float distance)
    {
        var rt = node.Value.GetComponent<RectTransform>();

        rt.localPosition = GetLerpPos(distance, index);
        rt.localScale = GetLerpScale(distance, index);
    }
    int CountIntegersToZero(float number)
    {
        return Mathf.FloorToInt(Mathf.Abs(number));
    }

    Vector3 GetLerpPos(float inputValue, int index)
    {
        if (inputValue < 0 && index - 1 >= 0)
        {
            // 插值到 index-1
            return Vector3.Lerp(positionCache[index], positionCache[index - 1], Mathf.Abs(inputValue));
        }
        else if (inputValue > 0 && index + 1 < CacheSize)
        {
            // 插值到 index+1
            return Vector3.Lerp(positionCache[index], positionCache[index + 1], inputValue);
        }
        else
        {
            // 输入值为 0,直接返回当前值
            return positionCache[index];
        }
    }

    Vector3 GetLerpScale(float inputValue, int index)
    {
        if (inputValue < 0 && index - 1 >= 0)
        {
            // 插值到 index-1
            return Vector3.Lerp(scaleCache[index], scaleCache[index - 1], Mathf.Abs(inputValue));
        }
        else if (inputValue > 0 && index + 1 < CacheSize)
        {
            // 插值到 index+1
            return Vector3.Lerp(scaleCache[index], scaleCache[index + 1], inputValue);
        }
        else
        {
            // 输入值为 0,直接返回当前值
            return scaleCache[index];
        }
    }
    #endregion

    #region Move Logic
    /// <summary>
    /// 根据CurSelectDataIdx修改elemCache的元素到其本应该出现的位置(无动画,这个位置就是动画结束的位置)
    /// </summary>
    void RefreshElemImmediately()
    {
        var ptr = elemCache.First;
        var idx = CurSelectDataIdx - leftElemCount - 1;
        var loop = 0;
        RectTransform rt;
        bool isValid;
        while (ptr != null)
        {
            isValid = IsValidIdx(idx);
            rt = ptr.Value.GetComponent<RectTransform>();

            ptr.Value.gameObject.SetActive(isValid);
            if (isValid) { refreshElem(rt, elemData[idx], loop); }

            rt.localPosition = positionCache[loop];
            rt.localScale = scaleCache[loop];

            idx++;
            loop++;
            ptr = ptr.Next;
        }
        if (useInterestedElem)
        {
            Transform tf = IsInterestedElemDisp() ? GetInterestedElemTrans() : null;
            OnInterestedIdxDisplay?.Invoke(tf);
        }
    }

    bool IsValidIdx(int idx)
    {
        return idx >= 0 && idx < elemData.Count;
    }
    #endregion

    #region Snap Logic
    void TryStartSanp()
    {
        if (lastDragNormalizedDistance <= -0.5f) CurSelectDataIdx++;
        else if (lastDragNormalizedDistance >= 0.5f) CurSelectDataIdx--;
        else
        {
            RefreshElemImmediately();
        }
        OnSelectElemStable?.Invoke(CurSelectDataIdx);
        lastDragNormalizedDistance = 0;
    }
    #endregion

    #region Interested Elem
    bool IsInterestedElemDisp()
    {
        bool res = false;
        if (interestedIdx >= curSelectDataIdx)
        {
            res = interestedIdx - curSelectDataIdx <= rightElemCount;
        }
        else
        {
            res = curSelectDataIdx - interestedIdx <= leftElemCount;
        }
        return res;
    }

    Transform GetInterestedElemTrans()
    {
        int idx = curSelectDataIdx - CacheLeftCount;
        var ptr = elemCache.First;
        while (ptr != null)
        {
            if (idx == interestedIdx)
            {
                return ptr.Value;
            }
            idx++;
            ptr = ptr.Next;
        }
        return null;
    }
    #endregion

    #region Cache Utils
    /// <summary>
    /// 获取曲线单侧距离中间元素第idx个节点的值(一般仅初始化cache用)
    /// </summary>
    /// <param name="idx">第idx个元素(例如中间元素左侧的idx==1)</param>
    /// <param name="isLeft">是否是左侧(false==右侧)</param>
    /// <returns></returns>
    float GetScaleInCurve(int idx, bool isLeft)
    {
        float offset = isLeft ? 0 : 0.5f;

        if (isLeft && idx == leftElemCount + 1) idx--;
        if (!isLeft && idx == rightElemCount + 1) idx--;
        idx = isLeft ? leftElemCount - idx : idx;

        return curve.Evaluate(offset + 0.5f * (float)idx / (isLeft ? leftElemCount : rightElemCount));
    }

    #endregion

    #region Drag Sim
    public void SetDrag2Elem(int idx)
    {
        var i = CurSelectDataIdx - 4 + idx;
        if (i < 0) i = 0;
        if (i >= elemData.Count) i = elemData.Count - 1;
        CurSelectDataIdx = i;
        OnSelectElemStable?.Invoke(i);
    }
    #endregion
}

垂直滚动版本

讲道理做成通用组件该写垂直版本的,但是,哎,时值中秋,我想玩游戏,遂不写。

缺陷与声明

缺陷

  1. 没有垂直版本
  2. 每次有一个新的元素经过最大的(被select的位置)位置会导致整体被刷新一遍(实现原理就是如此),这是一个性能开销,我知道本应如何处理,我只是写的时候思路歪了写成这样了。这样确实没有传统的无限列表的性能高。不过考虑到本文还是展示思路为主,就这样吧。(所以其实大可不必使用LinkedList)
  3. 没有实现元素的点击移动。目前我只是refreshElem的第三个参数传信息,直接把界面刷新了,相当于点击元素直接出现在最大的位置,没有移动过去的过程。可以考虑添加一个函数模拟拖动,按照恒定的速度把一定的偏移量添加至dragDIstance
  4. 吸附较为生硬(正如我所说,我只是放出了一个初步版本)

声明

本文涉及的代码遵从CC4.0协议

趁着放假写了一下,比较草率(然后拖到现在才整理并发出来),可改进的地方也有很多,不代表本人用到项目中的最终品质。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值