WOS 每日特惠,选择英雄界面实现

选择框始终位于中心,左右滑动时,会就近选择一个

Scroll的外框应该是绿色部分。但是可以发现如果最左边处于中心的时候,左边一大段是空的,如果设计为Elastic的话,则达不到这个状态。为了解决这个问题,我们在scroll_content的左右分别填入空白的gameObject占位。

这样的话,最左端和最右端的item就都可以居中显示了。

剩下的部分就很简单了,关键函数是
setup_placeholder_width:根据屏幕长度,自动设置左右填充的place_hoder的长度
get_scroll_pos(index):获得第X个item居中时,需要设置的scroll滚动位置
核心就是如上两个函数。当我滑动屏幕时,在drag_end时,需要判断当前的scroll的位置,与遍历所有item的位置,找到最近的那个,把他设置到中间就好。

如下代码由AI根据lua脚本生成

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections.Generic;
using DG.Tweening;

/// <summary>
/// 水平滚动视图,支持自动居中吸附功能的示例
/// 演示功能:
/// 1. UI对象池管理 - 复用列表项
/// 2. 动态占位符计算 - 使第一个和最后一个元素可以居中
/// 3. 拖拽交互 - 监听拖拽开始和结束
/// 4. 自动吸附 - 拖拽结束后自动吸附到最近的元素
/// 5. 平滑滚动动画 - 使用 DOTween 实现
/// </summary>
public class HorizontalScrollWithSnapExample : MonoBehaviour, IBeginDragHandler, IEndDragHandler
{
    [Header("UI 引用")]
    [Tooltip("滚动视图组件")]
    public ScrollRect scrollRect;
    
    [Tooltip("内容容器(HorizontalLayoutGroup)")]
    public RectTransform scrollContent;
    
    [Tooltip("左侧占位符")]
    public RectTransform leftPlaceholder;
    
    [Tooltip("右侧占位符")]
    public RectTransform rightPlaceholder;
    
    [Tooltip("列表项预制体")]
    public GameObject itemPrefab;
    
    [Tooltip("左切换按钮")]
    public Button btnLeft;
    
    [Tooltip("右切换按钮")]
    public Button btnRight;

    [Header("配置参数")]
    [Tooltip("元素总数")]
    public int itemCount = 5;
    
    [Tooltip("滚动动画持续时间")]
    public float scrollDuration = 0.3f;
    
    [Tooltip("拖拽结束后延迟吸附时间")]
    public float snapDelay = 0.1f;

    // 私有变量
    private List<GameObject> itemList = new List<GameObject>(); // 元素列表
    private HorizontalLayoutGroup layoutGroup; // 布局组件
    private Canvas canvas; // Canvas 引用
    
    private int currentIndex = 0; // 当前选中的索引(从0开始)
    private bool isDragging = false; // 是否正在拖拽
    private float dragStartPos = 0f; // 拖拽开始时的位置
    
    // 尺寸相关(屏幕像素)
    private float scrollWidth; // 滚动区域宽度
    private float itemWidth; // 单个元素宽度
    private float leftPlaceholderWidth; // 左占位符宽度
    private float rightPlaceholderWidth; // 右占位符宽度
    private float spacing; // 元素间距
    private float padding; // 边距
    private float contentLength; // 内容总长度
    private float baseLength; // 可滚动的基础长度
    
    private Tweener scrollTween; // 滚动动画
    private System.Threading.CancellationTokenSource snapCancellation; // 吸附延迟取消令牌

    void Start()
    {
        Initialize();
    }

    /// <summary>
    /// 初始化
    /// </summary>
    private void Initialize()
    {
        // 获取组件引用
        canvas = GetComponentInParent<Canvas>();
        layoutGroup = scrollContent.GetComponent<HorizontalLayoutGroup>();
        
        // 计算尺寸
        CalculateDimensions();
        
        // 设置占位符宽度
        SetupPlaceholderWidth();
        
        // 创建列表项
        CreateItems();
        
        // 绑定按钮事件
        btnLeft?.onClick.AddListener(() => SwitchItem(-1));
        btnRight?.onClick.AddListener(() => SwitchItem(1));
        
        // 初始化滚动到第一个元素
        ScrollToIndex(0, false);
    }

    /// <summary>
    /// 计算各种尺寸(转换为屏幕像素)
    /// </summary>
    private void CalculateDimensions()
    {
        float canvasScaleFactor = canvas.scaleFactor;
        
        // 滚动区域宽度
        scrollWidth = scrollRect.GetComponent<RectTransform>().rect.width * canvasScaleFactor;
        
        // 元素宽度(包含缩放)
        itemWidth = itemPrefab.GetComponent<RectTransform>().rect.width 
                    * itemPrefab.transform.localScale.x 
                    * canvasScaleFactor;
        
        // 布局参数
        spacing = layoutGroup.spacing * canvasScaleFactor;
        padding = layoutGroup.padding.horizontal * canvasScaleFactor;
    }

    /// <summary>
    /// 设置占位符宽度,使第一个和最后一个元素可以居中显示
    /// </summary>
    private void SetupPlaceholderWidth()
    {
        float canvasScaleFactor = canvas.scaleFactor;
        
        // 计算占位符宽度 = (滚动区域宽度 - 元素宽度) / 2
        // 这样第一个/最后一个元素就能正好居中
        float placeholderWidth = (scrollWidth - itemWidth) / 2f;
        
        // 转换回 UI 单位
        float placeholderWidthUI = placeholderWidth / canvasScaleFactor;
        
        // 设置占位符尺寸
        leftPlaceholder.sizeDelta = new Vector2(placeholderWidthUI, leftPlaceholder.sizeDelta.y);
        rightPlaceholder.sizeDelta = new Vector2(placeholderWidthUI, rightPlaceholder.sizeDelta.y);
        
        // 更新屏幕像素值
        leftPlaceholderWidth = placeholderWidth;
        rightPlaceholderWidth = placeholderWidth;
    }

    /// <summary>
    /// 创建列表项
    /// </summary>
    private void CreateItems()
    {
        // 清空现有项
        foreach (var item in itemList)
        {
            Destroy(item);
        }
        itemList.Clear();
        
        // 创建新项
        for (int i = 0; i < itemCount; i++)
        {
            GameObject item = Instantiate(itemPrefab, scrollContent);
            item.SetActive(true);
            
            // 设置排序顺序(在两个占位符之后)
            int siblingIndex = i + 1; // +1 因为左占位符在索引0
            item.transform.SetSiblingIndex(siblingIndex);
            
            // 可以在这里设置项的数据
            // 例如:item.GetComponent<ItemComponent>().SetData(i);
            
            itemList.Add(item);
        }
        
        // 计算内容总长度
        float canvasScaleFactor = canvas.scaleFactor;
        contentLength = itemWidth * itemCount 
                       + spacing * itemCount 
                       + padding 
                       + leftPlaceholderWidth 
                       + rightPlaceholderWidth 
                       + spacing;
        
        baseLength = contentLength - scrollWidth;
    }

    /// <summary>
    /// 切换元素(通过左右按钮)
    /// </summary>
    /// <param name="direction">方向:-1 向左,+1 向右</param>
    private void SwitchItem(int direction)
    {
        // 循环索引计算
        currentIndex = (currentIndex + direction + itemCount) % itemCount;
        ScrollToIndex(currentIndex, true);
    }

    /// <summary>
    /// 滚动到指定索引的元素
    /// </summary>
    /// <param name="index">目标索引</param>
    /// <param name="animated">是否使用动画</param>
    private void ScrollToIndex(int index, bool animated)
    {
        currentIndex = index;
        float targetPos = GetScrollPositionForIndex(index);
        
        // 取消之前的动画
        scrollTween?.Kill();
        
        if (animated)
        {
            // 使用 DOTween 平滑滚动
            scrollTween = scrollRect.DOHorizontalNormalizedPos(targetPos, scrollDuration)
                .SetEase(Ease.OutCubic);
        }
        else
        {
            // 直接设置位置
            scrollRect.horizontalNormalizedPosition = targetPos;
        }
        
        // 更新选中状态(可选)
        UpdateSelectedState();
    }

    /// <summary>
    /// 计算指定索引元素的滚动位置(normalized position: 0-1)
    /// </summary>
    /// <param name="index">元素索引</param>
    /// <returns>Normalized position (0-1)</returns>
    private float GetScrollPositionForIndex(int index)
    {
        // 获取滚动视图在世界空间的位置
        Vector3 worldPos = scrollRect.transform.position;
        Camera uiCamera = canvas.worldCamera ?? Camera.main;
        Vector2 screenPos = RectTransformUtility.WorldToScreenPoint(uiCamera, worldPos);
        
        // 滚动视图左边缘的屏幕位置
        float scrollLeftPos = screenPos.x - scrollWidth / 2f;
        
        // 元素在内容中的原始位置(从左边缘开始)
        float originPos = padding 
                         + (itemWidth + spacing) * index 
                         + leftPlaceholderWidth 
                         + spacing;
        
        // 计算需要移动的距离,使元素居中
        float screenCenterX = Screen.width / 2f;
        float itemCenterOffset = itemWidth / 2f;
        float moveDistance = screenCenterX - itemCenterOffset - scrollLeftPos - originPos;
        
        // 转换为 normalized position
        // 注意:Unity ScrollRect 的 horizontalNormalizedPosition 0表示最左,1表示最右
        // 所以我们需要用负的移动距离除以基础长度
        float normalizedPos = (-moveDistance) / baseLength;
        
        // 限制在有效范围内
        return Mathf.Clamp01(normalizedPos);
    }

    /// <summary>
    /// 更新选中状态(可以在这里改变元素的外观)
    /// </summary>
    private void UpdateSelectedState()
    {
        for (int i = 0; i < itemList.Count; i++)
        {
            // 这里可以添加选中/未选中的视觉效果
            // 例如:改变颜色、缩放、添加边框等
            // itemList[i].GetComponent<ItemComponent>().SetSelected(i == currentIndex);
        }
    }

    #region 拖拽事件处理

    /// <summary>
    /// 拖拽开始
    /// </summary>
    public void OnBeginDrag(PointerEventData eventData)
    {
        isDragging = true;
        
        // 记录拖拽开始位置,用于判断滑动方向
        dragStartPos = scrollRect.horizontalNormalizedPosition;
        
        // 取消正在进行的滚动动画
        scrollTween?.Kill();
        scrollTween = null;
        
        // 取消延迟吸附
        snapCancellation?.Cancel();
        snapCancellation = null;
    }

    /// <summary>
    /// 拖拽结束
    /// </summary>
    public void OnEndDrag(PointerEventData eventData)
    {
        isDragging = false;
        
        // 延迟后自动吸附到最近的元素
        snapCancellation = new System.Threading.CancellationTokenSource();
        Invoke(nameof(SnapToNearestItem), snapDelay);
    }

    /// <summary>
    /// 吸附到最近的元素
    /// </summary>
    private void SnapToNearestItem()
    {
        int targetIndex = GetTargetItemByDirection();
        
        if (targetIndex != currentIndex)
        {
            // 切换到新元素
            currentIndex = targetIndex;
            ScrollToIndex(currentIndex, true);
        }
        else
        {
            // 如果已经是当前选中的,只需要平滑移动到精确位置
            float targetPos = GetScrollPositionForIndex(currentIndex);
            scrollTween?.Kill();
            scrollTween = scrollRect.DOHorizontalNormalizedPos(targetPos, scrollDuration)
                .SetEase(Ease.OutCubic);
        }
    }

    /// <summary>
    /// 根据滑动方向获取应该选中的元素索引
    /// 算法说明:
    /// 1. 向左滑动(手指向左移动):选择当前位置左侧最近的元素
    /// 2. 向右滑动(手指向右移动):选择当前位置右侧最近的元素
    /// 3. 如果没有找到方向上的元素,则选择最近的元素
    /// </summary>
    private int GetTargetItemByDirection()
    {
        if (itemCount == 0)
            return 0;
        
        float currentPos = scrollRect.horizontalNormalizedPosition;
        float startPos = dragStartPos;
        
        // 判断滑动方向
        // 向左滑动:horizontalNormalizedPosition 增大
        // 向右滑动:horizontalNormalizedPosition 减小
        bool isDragLeft = currentPos > startPos;
        
        float minDistance = float.MaxValue;
        int nearestIndex = 0;
        int targetIndex = -1;
        
        // 遍历所有元素
        for (int i = 0; i < itemCount; i++)
        {
            float itemPos = GetScrollPositionForIndex(i);
            float distance = Mathf.Abs(currentPos - itemPos);
            
            // 记录最近的元素作为备选
            if (distance < minDistance)
            {
                minDistance = distance;
                nearestIndex = i;
            }
            
            // 根据滑动方向选择元素
            if (isDragLeft)
            {
                // 向左滑动,选择左边的元素(position 值更大的)
                if (itemPos >= currentPos)
                {
                    if (targetIndex == -1 || itemPos < GetScrollPositionForIndex(targetIndex))
                    {
                        targetIndex = i;
                    }
                }
            }
            else
            {
                // 向右滑动,选择右边的元素(position 值更小的)
                if (itemPos <= currentPos)
                {
                    if (targetIndex == -1 || itemPos > GetScrollPositionForIndex(targetIndex))
                    {
                        targetIndex = i;
                    }
                }
            }
        }
        
        // 如果找到了方向上的元素就用它,否则用最近的
        return targetIndex != -1 ? targetIndex : nearestIndex;
    }

    #endregion

    #region 对象池模式示例(简化版)

    /// <summary>
    /// 简化的对象池实现示例
    /// 实际项目中建议使用更完善的对象池系统
    /// </summary>
    public class SimpleObjectPool<T> where T : Component
    {
        private T prefab;
        private Transform parent;
        private List<T> activeObjects = new List<T>();
        private Queue<T> poolQueue = new Queue<T>();

        public SimpleObjectPool(T prefab, Transform parent, int initialSize = 10)
        {
            this.prefab = prefab;
            this.parent = parent;
            
            // 预创建对象
            for (int i = 0; i < initialSize; i++)
            {
                T obj = Object.Instantiate(prefab, parent);
                obj.gameObject.SetActive(false);
                poolQueue.Enqueue(obj);
            }
        }

        /// <summary>
        /// 从池中获取对象
        /// </summary>
        public T Get()
        {
            T obj;
            if (poolQueue.Count > 0)
            {
                obj = poolQueue.Dequeue();
                obj.gameObject.SetActive(true);
            }
            else
            {
                obj = Object.Instantiate(prefab, parent);
            }
            
            activeObjects.Add(obj);
            return obj;
        }

        /// <summary>
        /// 回收对象到池中
        /// </summary>
        public void Recycle(T obj)
        {
            if (activeObjects.Remove(obj))
            {
                obj.gameObject.SetActive(false);
                poolQueue.Enqueue(obj);
            }
        }

        /// <summary>
        /// 回收所有活动对象
        /// </summary>
        public void RecycleAll()
        {
            while (activeObjects.Count > 0)
            {
                Recycle(activeObjects[0]);
            }
        }
    }

    #endregion

    void OnDestroy()
    {
        // 清理资源
        scrollTween?.Kill();
        snapCancellation?.Cancel();
    }
}

水平滚动自动吸附组件使用指南

水平滚动自动吸附组件使用指南

概述

HorizontalScrollWithSnapExample.cs 是一个功能完整的水平滚动视图组件,支持自动居中吸附功能。该组件演示了以下核心技术:

  • UI 对象池管理 – 高效复用列表项
  • 动态占位符计算 – 使首尾元素可以居中显示
  • 拖拽交互监听 – 监听拖拽开始和结束事件
  • 智能吸附算法 – 根据滑动方向自动吸附到目标元素
  • 平滑动画过渡 – 使用 DOTween 实现流畅的滚动效果

使用场景

  • 角色选择界面
  • 商品展示轮播
  • 关卡选择列表
  • 卡片浏览界面
  • 任何需要居中展示的水平滚动列表

组件设置

1. Unity 层级结构

Canvas └── HorizontalScrollPanel ├── ScrollRect (ScrollRect 组件) │ └── Content (HorizontalLayoutGroup) │ ├── LeftPlaceholder (RectTransform) │ ├── Item1 (从预制体实例化) │ ├── Item2 │ ├── … │ └── RightPlaceholder (RectTransform) ├── BtnLeft (Button) └── BtnRight (Button)

2. 组件配置

在 Inspector 中配置以下参数:

UI 引用

  • Scroll Rect: 拖入 ScrollRect 组件
  • Scroll Content: 拖入 Content(带 HorizontalLayoutGroup 的对象)
  • Left Placeholder: 左侧空白占位符
  • Right Placeholder: 右侧空白占位符
  • Item Prefab: 列表项预制体
  • Btn Left: 向左切换按钮
  • Btn Right: 向右切换按钮

配置参数

  • Item Count: 元素总数(默认 5)
  • Scroll Duration: 滚动动画时长(默认 0.3 秒)
  • Snap Delay: 拖拽结束后延迟吸附时间(默认 0.1 秒)

3. ScrollRect 设置

  • ✅ Horizontal: 勾选
  • ❌ Vertical: 不勾选
  • ✅ Inertia: 勾选(惯性滚动)
  • Deceleration Rate: 0.135(减速率,可调整)
  • Scroll Sensitivity: 1.0

4. HorizontalLayoutGroup 设置

  • Child Alignment: Middle Center
  • Spacing: 元素间距(例如:20)
  • Padding: 左右边距(通常为 0,由占位符控制)
  • ✅ Child Force Expand: Width 和 Height 都不勾选
  • ✅ Child Control Size: Width 和 Height 根据需要勾选

核心功能详解

1. 动态占位符宽度计算

目的: 使第一个和最后一个元素可以滚动到屏幕中央。

算法:

占位符宽度 = (滚动区域宽度 - 单个元素宽度) / 2

实现位置: SetupPlaceholderWidth() 方法

原理说明:

  • 滚动视图的宽度固定
  • 元素居中时,左右两侧应该留出相等的空白
  • 占位符填充这些空白,使首尾元素可以居中
┌─────────────────────────────────┐ │ [占位符] [元素] [占位符] │ │ └─ w/2 ┘ └ w ┘ └─ w/2 ┘ │ └─────────────────────────────────┘ 滚动视图宽度 = W

2. 滚动位置计算

目的: 精确计算使指定元素居中的滚动位置。

核心方法: GetScrollPositionForIndex(int index)

返回值: Normalized Position (0-1)

  • 0 = 最左侧
  • 1 = 最右侧

计算步骤:

// 1. 元素在内容中的原始位置
originPos = padding + (itemWidth + spacing) × index + leftPlaceholderWidth + spacing

// 2. 计算需要移动的距离(使元素居中)
moveDistance = 屏幕中心X - 元素中心偏移 - 滚动视图左边缘 - 原始位置

// 3. 转换为 Normalized Position
normalizedPos = (-moveDistance) / baseLength

可视化示例:

内容布局(Content): ┌─────┬────┬────┬────┬────┬─────┐ │ LP │ I0 │ I1 │ I2 │ I3 │ RP │ └─────┴────┴────┴────┴────┴─────┘ ↑ originPos (元素0的起始位置) 滚动视图(Viewport): ┌───────────────┐ │ [元素居中] │ └───────────────┘ ↑ 屏幕中心

3. 拖拽吸附算法

目的: 拖拽结束后,根据滑动方向智能吸附到目标元素。

核心方法: GetTargetItemByDirection()

算法逻辑:

  1. 记录拖拽开始位置 (dragStartPos)
  2. 获取拖拽结束位置 (currentPos)
  3. 判断滑动方向:
    • currentPos > dragStartPos → 向左滑动
    • currentPos < dragStartPos → 向右滑动
  4. 根据方向选择目标元素:
    • 向左滑: 选择左侧最近的元素(position ≥ currentPos 且最小)
    • 向右滑: 选择右侧最近的元素(position ≤ currentPos 且最大)
  5. 如果没找到方向上的元素,则选择距离最近的元素

代码示例:

bool isDragLeft = currentPos > startPos;

if (isDragLeft)
{
    // 向左滑动,选择左边的元素(position 值更大的)
    if (itemPos >= currentPos && itemPos < targetPos)
        targetIndex = i;
}
else
{
    // 向右滑动,选择右边的元素(position 值更小的)
    if (itemPos <= currentPos && itemPos > targetPos)
        targetIndex = i;
}

可视化示例:

向左滑动(手指向左移): dragStart current ↓ ↓ ┌────┬────┬────┬────┬────┬────┐ │ I0 │ I1 │ I2 │ I3 │ I4 │ I5 │ └────┴────┴────┴────┴────┴────┘ ↑ 选择 I4(左侧最近) 向右滑动(手指向右移): current dragStart ↓ ↓ ┌────┬────┬────┬────┬────┬────┐ │ I0 │ I1 │ I2 │ I3 │ I4 │ I5 │ └────┴────┴────┴────┴────┴────┘ ↑ 选择 I1(右侧最近)

4. 平滑动画过渡

使用 DOTween 实现:

scrollTween = scrollRect.DOHorizontalNormalizedPos(targetPos, scrollDuration)
    .SetEase(Ease.OutCubic);

缓动函数说明:

  • Ease.OutCubic: 快速开始,缓慢结束(推荐)
  • Ease.InOutCubic: 慢-快-慢
  • Ease.Linear: 匀速(不推荐)

动画管理:

  • 开始新动画前,先 Kill() 旧动画
  • 拖拽开始时,取消正在进行的动画
  • 组件销毁时,清理所有动画

对象池模式

为什么使用对象池?

  • 性能优化: 避免频繁实例化和销毁对象
  • 减少 GC: 降低垃圾回收压力
  • 流畅体验: 避免卡顿

简化实现

public class SimpleObjectPool<T> where T : Component
{
    private Queue<T> poolQueue = new Queue<T>();
    
    public T Get()
    {
        if (poolQueue.Count > 0)
            return poolQueue.Dequeue(); // 复用对象
        else
            return Instantiate(prefab); // 创建新对象
    }
    
    public void Recycle(T obj)
    {
        obj.gameObject.SetActive(false);
        poolQueue.Enqueue(obj); // 放回池中
    }
}

使用示例

// 1. 创建对象池
var pool = new SimpleObjectPool<ItemComponent>(itemPrefab, parent, 10);

// 2. 获取对象
var item = pool.Get();
item.SetData(data);

// 3. 回收对象
pool.Recycle(item);

// 4. 回收所有
pool.RecycleAll();

扩展功能建议

1. 添加选中效果

private void UpdateSelectedState()
{
    for (int i = 0; i < itemList.Count; i++)
    {
        var item = itemList[i];
        bool isSelected = (i == currentIndex);
        
        // 缩放效果
        item.transform.DOScale(isSelected ? 1.2f : 1.0f, 0.2f);
        
        // 颜色效果
        var image = item.GetComponent<Image>();
        image.DOColor(isSelected ? Color.white : Color.gray, 0.2f);
        
        // 透明度效果
        var canvasGroup = item.GetComponent<CanvasGroup>();
        canvasGroup.DOFade(isSelected ? 1.0f : 0.5f, 0.2f);
    }
}

2. 添加循环滚动

// 无限循环模式
private int GetWrappedIndex(int index)
{
    while (index < 0) index += itemCount;
    return index % itemCount;
}

3. 添加滚动事件回调

public event System.Action<int> OnItemChanged;

private void ScrollToIndex(int index, bool animated)
{
    currentIndex = index;
    // ...
    OnItemChanged?.Invoke(currentIndex);
}

4. 添加快速滑动检测

private Vector2 dragVelocity;

public void OnEndDrag(PointerEventData eventData)
{
    // 计算滑动速度
    float velocity = Mathf.Abs(eventData.delta.x);
    
    if (velocity > fastSwipeThreshold)
    {
        // 快速滑动:跳过多个元素
        int skipCount = Mathf.CeilToInt(velocity / 100f);
        currentIndex += isDragLeft ? skipCount : -skipCount;
    }
    
    SnapToNearestItem();
}

性能优化建议

1. 虚拟滚动(大量元素)

对于超过 20 个元素的列表,建议实现虚拟滚动:

// 只实例化可见元素 + 左右各一个缓冲
int visibleCount = Mathf.CeilToInt(scrollWidth / itemWidth) + 2;

// 根据滚动位置动态更新元素
void UpdateVisibleItems(float scrollPos)
{
    int startIndex = CalculateStartIndex(scrollPos);
    for (int i = 0; i < visibleCount; i++)
    {
        int dataIndex = startIndex + i;
        itemList[i].SetData(allData[dataIndex]);
    }
}

2. 对象池预热

void Start()
{
    // 在游戏启动时预创建对象
    StartCoroutine(PrewarmPool());
}

IEnumerator PrewarmPool()
{
    for (int i = 0; i < initialPoolSize; i++)
    {
        var obj = Instantiate(prefab);
        obj.SetActive(false);
        pool.Enqueue(obj);
        
        // 分帧创建,避免卡顿
        if (i % 5 == 0)
            yield return null;
    }
}

3. 降低更新频率

// 不要在 Update 中每帧计算
void Update()
{
    // ❌ 错误:每帧计算
    UpdateScrollPosition();
}

// ✅ 正确:只在需要时计算
public void OnValueChanged(Vector2 pos)
{
    // 只在滚动时更新
}

常见问题

Q1: 占位符宽度不对,首个元素无法居中?

解决方案:

  • 检查 Canvas Scale Factor
  • 确保在 Start() 之后计算尺寸
  • 使用 LayoutRebuilder.ForceRebuildLayoutImmediate() 强制刷新布局
LayoutRebuilder.ForceRebuildLayoutImmediate(scrollContent);
CalculateDimensions();
SetupPlaceholderWidth();

Q2: 滚动位置计算不准确?

检查清单:

  • Canvas Render Mode 是否正确(Screen Space – Overlay/Camera)
  • UI Camera 是否正确设置
  • RectTransform 的 Pivot 是否为 (0.5, 0.5)
  • 元素的 Scale 是否正确计算

Q3: 拖拽时动画卡顿?

优化方案:

  • 降低 scrollDuration(0.2-0.3 较合适)
  • 使用 Ease.OutCubicEase.OutQuad
  • 检查是否有其他性能问题(Layout Rebuild)

Q4: DOTween 报错?

解决方案:

  • 安装 DOTween: Window → DOTween Utility Panel → Setup DOTween
  • 添加命名空间: using DG.Tweening;
  • 如果不想依赖 DOTween,可以使用 Coroutine 实现:
IEnumerator ScrollToPosition(float target, float duration)
{
    float elapsed = 0;
    float start = scrollRect.horizontalNormalizedPosition;
    
    while (elapsed < duration)
    {
        elapsed += Time.deltaTime;
        float t = elapsed / duration;
        float eased = EaseOutCubic(t);
        scrollRect.horizontalNormalizedPosition = Mathf.Lerp(start, target, eased);
        yield return null;
    }
}

float EaseOutCubic(float t) => 1f - Mathf.Pow(1f - t, 3f);

完整使用流程

1. 场景搭建

  1. 创建 Canvas
  2. 添加 ScrollRect
  3. 设置 Content(HorizontalLayoutGroup)
  4. 添加占位符(两个空的 RectTransform)
  5. 创建列表项预制体

2. 组件配置

  1. 添加 HorizontalScrollWithSnapExample 脚本
  2. 拖入所有 UI 引用
  3. 设置参数(元素数量、动画时长等)

3. 自定义扩展

  1. 修改 CreateItems() 设置列表项数据
  2. 实现 UpdateSelectedState() 添加选中效果
  3. 根据需要添加事件回调

4. 测试调试

  1. 运行场景
  2. 测试拖拽吸附
  3. 测试按钮切换
  4. 调整动画参数

总结

这个组件演示了完整的水平滚动选择界面实现,包括:

  • 自动居中吸附
  • 平滑动画过渡
  • 智能方向判断
  • 对象池优化
  • 动态布局计算

核心要点:

  1. 占位符计算 – 使首尾元素可居中
  2. 位置计算 – 精确的 Normalized Position 转换
  3. 方向判断 – 根据滑动方向选择目标
  4. 动画管理 – 正确取消和创建动画

希望这个示例能帮助您理解和实现类似的功能!

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

大纲

Share the Post:
滚动至顶部