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

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 层级结构
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() 方法
原理说明:
- 滚动视图的宽度固定
- 元素居中时,左右两侧应该留出相等的空白
- 占位符填充这些空白,使首尾元素可以居中
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
可视化示例:
3. 拖拽吸附算法
核心方法: GetTargetItemByDirection()
算法逻辑:
- 记录拖拽开始位置 (dragStartPos)
- 获取拖拽结束位置 (currentPos)
- 判断滑动方向:
- currentPos > dragStartPos → 向左滑动
- currentPos < dragStartPos → 向右滑动
- 根据方向选择目标元素:
- 向左滑: 选择左侧最近的元素(position ≥ currentPos 且最小)
- 向右滑: 选择右侧最近的元素(position ≤ currentPos 且最大)
- 如果没找到方向上的元素,则选择距离最近的元素
代码示例:
bool isDragLeft = currentPos > startPos;
if (isDragLeft)
{
// 向左滑动,选择左边的元素(position 值更大的)
if (itemPos >= currentPos && itemPos < targetPos)
targetIndex = i;
}
else
{
// 向右滑动,选择右边的元素(position 值更小的)
if (itemPos <= currentPos && itemPos > targetPos)
targetIndex = i;
}
可视化示例:
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.OutCubic或Ease.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. 场景搭建
- 创建 Canvas
- 添加 ScrollRect
- 设置 Content(HorizontalLayoutGroup)
- 添加占位符(两个空的 RectTransform)
- 创建列表项预制体
2. 组件配置
- 添加
HorizontalScrollWithSnapExample脚本 - 拖入所有 UI 引用
- 设置参数(元素数量、动画时长等)
3. 自定义扩展
- 修改
CreateItems()设置列表项数据 - 实现
UpdateSelectedState()添加选中效果 - 根据需要添加事件回调
4. 测试调试
- 运行场景
- 测试拖拽吸附
- 测试按钮切换
- 调整动画参数
总结
这个组件演示了完整的水平滚动选择界面实现,包括:
- 自动居中吸附
- 平滑动画过渡
- 智能方向判断
- 对象池优化
- 动态布局计算
核心要点:
- 占位符计算 – 使首尾元素可居中
- 位置计算 – 精确的 Normalized Position 转换
- 方向判断 – 根据滑动方向选择目标
- 动画管理 – 正确取消和创建动画
希望这个示例能帮助您理解和实现类似的功能!