找回密码
 立即注册
查看: 702|回复: 0

Unity学习笔记:LoopScrollRect插件学习小记

[复制链接]
发表于 2020-12-10 19:40 | 显示全部楼层 |阅读模式
前言

LoopScrollRect 是基于 UGUI 的一个用来代替 ScrollRect 的插件,其特点是:不同于原生的 ScrollRect,LoopScrollRect 会根据 Viewport 的大小来加载子元素,比如说我们的总数据条目是 500 条,但 Viewport 只显示得下十来个 cell,这时候 LoopScrollRect 就不会傻傻地生成全部 500 个 cell,大大优化了性能。
按我暂时的浅薄理解来看,其实现的基本方式类似于:给 content 挂上一个 ContentSizeFitter 组件,这样 content 就会根据其底下挂了几个子物体来自适应改变自身的大小,而 LoopScrollRect 则会根据这个 content 的大小有没有超过 viewport 的大小,来决定要不要给这个 content 生成新的 Item 或者删除多余的 Item。
然后,这里不准备记 LoopScrollRect 的基本用法(官方有提供中文文档),主要记一下自己在用 LoopScrollRect 实现一些乱七八糟效果的时候瞎写的代码 (˙ー˙),做个备忘(仅仅是私底下写着玩的,估计有 Bug,只能说提供个实现思路 (⊙_⊙)
自动轮播功能

主要是应对这种需求:比如一个转盘抽奖活动里,我们要无限循环播放所有中奖信息,效果如下图:
这里的思路是利用 LoopScrollRect 自带的无尽模式(totalCount = -1)来实现。
我们先给 LoopScrollRect 加上以下控制项:
[Serializable]
struct AutoScrollProperties
{
    public bool enable;
    public Vector2 speed;
}

[SerializeField, Tooltip("自动轮播")]
AutoScrollProperties autoScrollProperties = new AutoScrollProperties { enable = false, speed = Vector2.one };
enable 表示是否启用自动轮播功能,speed 则是 ScrollRect 自动滚动的速度。
滚动逻辑的实现其实是直接复制原有的 OnScroll 函数,然后将函数输入从鼠标事件替换成了一个 Vector2 类型值:
public virtual void OnAutoScroll(Vector2 delta)
{
    if (!IsActive())
        return;

    EnsureLayoutHasRebuilt();
    UpdateBounds();

    if (vertical && !horizontal)
    {
        if (Mathf.Abs(delta.x) > Mathf.Abs(delta.y))
            delta.y = delta.x;
        delta.x = 0;
    }
    if (horizontal && !vertical)
    {
        if (Mathf.Abs(delta.y) > Mathf.Abs(delta.x))
            delta.x = delta.y;
        delta.y = 0;
    }

    Vector2 position = m_Content.anchoredPosition;
    position += delta * m_ScrollSensitivit;
    if (m_MovementType == MovementType.Clamped)
        position += CalculateOffset(position - m_Content.anchoredPosition);

    SetContentAnchoredPosition(position);
    UpdateBounds();
}
然后在 Update 里驱动 ScrollRect 进行滚动:
protected virtual void Update()
{
    if (autoScrollProperties.enable)
    {
        OnAutoScroll(autoScrollProperties.speed);
    }
}
到此 LoopScrollRect 这边就写完了ww,接下来是子元素 cell 这边的逻辑。
在 LoopScrollRect 的无尽模式里,提供给子元素 ScrollCellIndex 函数的输入索引值是无限增长的,而我们实际拥有的数据条目是有限的,比如我们现在只有 10 条中奖信息用于显示,这时需要在 ScrollCellIndex 里做以下处理:
void ScrollCellIndex(int idx)
{
    int dataIndex = idx % 10;

    string name = "Cell " + dataIndex.ToString();
    if (text != null)
    {
        text.text = name;
    }
    if (image != null)
    {
        image.color = Rainbow(dataIndex / 50.0f);
    }
    gameObject.name = name;
}
令索引值对当前拥有的数据条目数量取余,如此就可实现无限轮播。
实际使用时,注意将 totalCount 设为 -1,同时通过 speed 来控制自动轮播的条目滚动速度:
拖拽 ScrollRect 结束时,让距离 Viewport 中心点最近的一个 Cell 自动吸附过去,实现平滑居中

想实现的效果如下图:
代码方面,照例先加上控制项:
[Serializable]
struct ScrollToCenterProperties
{
    public bool enable;
    public float speed;
}

[SerializeField, Tooltip("子元素自动居中")]
ScrollToCenterProperties scrollToCenterProperties = new ScrollToCenterProperties { enable = false, speed = 100f };
enable 表示是否启用居中功能,speed 则表示子元素吸附过去的速度。
实现方面我是参考了 LoopScrollRect 原有的 SrollToCell 函数:
private void ScrollToCenter()
{
    if (totalCount == 0 || itemTypeEnd <= itemTypeStart || scrollToCenterProperties.speed <= 0)
    {
        return;
    }

    StopAllCoroutines();
    StartCoroutine(ScrollToCenterCoroutine());
}
因为自动居中应该支持无尽模式,所以这里允许 totalCount < 0。
我们要先找到距离 Viewport 中心点最近的 cell 的 Index:
private int FindClosestIndexToCenter()
{
    m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);
    int closestIndex = itemTypeStart;
    float closestDistance = float.MaxValue;

    for (int i = itemTypeStart; i < itemTypeEnd; i++)
    {
        int childIdx = i - itemTypeStart;

        if (childIdx >= 0 && childIdx < content.childCount)
        {
            var itemBounds = GetBounds4Item(i);

            float itemDistance = float.MaxValue;
            if (directionSign == -1)
                itemDistance = Mathf.Abs(m_ViewBounds.center.y - itemBounds.center.y);
            else if (directionSign == 1)
                itemDistance = Mathf.Abs(itemBounds.center.x - m_ViewBounds.center.x);

            if (itemDistance < closestDistance)
            {
                closestIndex = i;
                closestDistance = itemDistance;
            }
        }
    }

    return closestIndex;
}
然后再平滑居中:
IEnumerator ScrollToCenterCoroutine()
{
    int centerIndex = FindClosestIndexToCenter();

    bool needMoving = true;
    while (needMoving)
    {
        yield return null;
        if (!m_Dragging)
        {
            float move = 0;
            if (centerIndex < itemTypeStart)
            {
                move = -Time.deltaTime * scrollToCenterProperties.speed;
            }
            else if (centerIndex >= itemTypeEnd)
            {
                move = Time.deltaTime * scrollToCenterProperties.speed;
            }
            else
            {
                m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);
                var m_ItemBounds = GetBounds4Item(centerIndex);
                var offset = 0.0f;
                if (directionSign == -1)
                    offset = m_ViewBounds.center.y - m_ItemBounds.center.y;
                else if (directionSign == 1)
                    offset = m_ItemBounds.center.x - m_ViewBounds.center.x;

                float maxMove = Time.deltaTime * scrollToCenterProperties.speed;
                if (Mathf.Abs(offset) < maxMove)
                {
                    needMoving = false;
                    move = offset;
                }
                else
                    move = Mathf.Sign(offset) * maxMove;
            }

            if (move != 0)
            {
                Vector2 offset = GetVector(move);
                content.anchoredPosition += offset;
                m_PrevPosition += offset;
                m_ContentStartPosition += offset;
            }
        }
    }
    StopMovement();
    UpdatePrevData();
}
这里的关键点是根据 Bounds.center 这个中心点来计算。
最后是何时触发平滑居中的逻辑,我是选择放在 OnEndDrag 函数里(即拖拽之后):
public virtual void OnEndDrag(PointerEventData eventData)
{
    if (eventData.button != PointerEventData.InputButton.Left)
        return;

    m_Dragging = false;

    if (scrollToCenterProperties.enable)
    {
        ScrollToCenter();
    }
}
注意配置时 Movement Type 必须选 Unrestricted,否则 ScrollRect 滚动到列表边缘时无法让靠边的那几个 cell 居中显示。
平滑居中的动画实现

上面我们实现了子元素自动吸附平滑居中的功能,而在手游里通常都会伴有这样的动画效果:
对于类似这种动画,我觉得上层的 ScrollRect 只需给各个 cell 提供其在整个 Viewport 里的相对位置信息,然后由 cell 自己决定它应该变成什么样,比如在上面的动画里,越接近 Viewport 中心点的 cell 其 Alpha 值越高。
所以我们可以先给 cell 加个接口,用于向 cell 提供其本身在 Viewport 里的相对位置信息:
public interface IScrollCell
{
    void UpdateNormalizedPos(float normalizedPos);
}

public abstract class LoopScrollDataSource
{
    public abstract void ProvideData(Transform transform, int idx);

    public void UpdateCellNormalizedPos(Transform transform, float normalizedPos)
    {
        IScrollCell cell = transform.GetComponent<IScrollCell>();
        if (cell != null)
        {
            cell.UpdateNormalizedPos(normalizedPos);
        }
    }
}
然后给 LoopScrollRect 加一个这样的函数,各个 cell 是用中心点参与位置计算的:
private void UpdateCells()
{
    m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);

    for (int i = itemTypeStart; i < itemTypeEnd; i++)
    {
        int childIdx = i - itemTypeStart;

        if (childIdx >= 0 && childIdx < content.childCount)
        {
            var itemBounds = GetBounds4Item(i);

            var normalizedPos = 0f;
            if (directionSign == -1)
            {
                float offset = reverseDirection ? (itemBounds.center.y - m_ViewBounds.min.y) : (m_ViewBounds.max.y - itemBounds.center.y);
                normalizedPos = Mathf.Clamp(offset / m_ViewBounds.size.y, 0f, 1f);
            }
            else if (directionSign == 1)
            {
                float offset = reverseDirection ? (m_ViewBounds.max.x - itemBounds.center.x) : (itemBounds.center.x - m_ViewBounds.min.x);
                normalizedPos = Mathf.Clamp(offset / m_ViewBounds.size.x, 0f, 1f);
            }
            dataSource.UpdateCellNormalizedPos(content.GetChild(childIdx), normalizedPos);
        }
    }
}
而函数调用我是选择放在了 UpdateBounds 函数最后:
private void UpdateBounds(bool updateItems = false)
{
    m_ViewBounds = new Bounds(viewRect.rect.center, viewRect.rect.size);
    m_ContentBounds = GetBounds();

    if (m_Content == null)
        return;

    // ============LoopScrollRect============
    // Don't do this in Rebuild
    if (Application.isPlaying && updateItems && UpdateItems(m_ViewBounds, m_ContentBounds))
    {
        Canvas.ForceUpdateCanvases();
        m_ContentBounds = GetBounds();
    }
    // ============LoopScrollRect============

    // Make sure content bounds are at least as large as view by adding padding if not.
    // One might think at first that if the content is smaller than the view, scrolling should be allowed.
    // However, that's not how scroll views normally work.
    // Scrolling is *only* possible when content is *larger* than view.
    // We use the pivot of the content rect to decide in which directions the content bounds should be expanded.
    // E.g. if pivot is at top, bounds are expanded downwards.
    // This also works nicely when ContentSizeFitter is used on the content.
    Vector3 contentSize = m_ContentBounds.size;
    Vector3 contentPos = m_ContentBounds.center;
    Vector3 excess = m_ViewBounds.size - contentSize;
    if (excess.x > 0)
    {
        contentPos.x -= excess.x * (m_Content.pivot.x - 0.5f);
        contentSize.x = m_ViewBounds.size.x;
    }
    if (excess.y > 0)
    {
        contentPos.y -= excess.y * (m_Content.pivot.y - 0.5f);
        contentSize.y = m_ViewBounds.size.y;
    }

    m_ContentBounds.size = contentSize;
    m_ContentBounds.center = contentPos;

    UpdateCells();
}
至此,cell 拿到了自己需要的相对位置信息,接下来就简单了,我们可以给 cell 加一个 Canvas Group 组件,再做一个这样的 Animation:
这个动画的要点在于:normalizedTime 等于 0 和 1 的时候,设置 Canvas Group 的 Alpha 为 0,而 normalizedTime 等于 0.5 的时候,设置 Canvas Group 的 Alpha 为 1。
我们将这个动画的速度设为 0(这样此动画就会卡在我们设置的对应帧,而不会自动往下播放):
接下来在 cell 脚本里如此实现 IScrollCell 接口:
public Animator animator;

private readonly int scrollTriggerHash = Animator.StringToHash("scroll");

public void UpdateNormalizedPos(float normalizedPos)
{
    animator.Play(scrollTriggerHash, -1, normalizedPos);
    animator.speed = 0;
}
其实,这种实现方式的本质就是将 cell 在 Viewport 里处于各个相对位置的样子存储在 Animation 里,然后各个 cell 直接播放对应一帧即可。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-27 12:09 , Processed in 0.095069 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表