效果与使用说明
效果
- 可以滑动
- 无限列表(严格来说也和常规的不太一样)
- 可以通过曲线调整元素大小
使用说明
- 列表元素位于脚本挂载处的直接子级
- 最大的元素位于脚本挂载元素的pivot处
- 水平列表的对齐依据是所有元素pivot都在一条线上
- 默认在最左侧和最右侧元素外有1个元素(本身看不见,但是在移动的时候可能会移动到视野内)
- 基于4,如果希望view外还有更多的元素(虽然不知道出于什么目的),可以调大“左/右侧元素数目”并加mask遮罩住。
- 通过
dragFactor
调整拖动的敏感度 - 设置
InterestedElem
的意义是动态传递出某个感兴趣的数据的下标(因而可以实现某些视觉效果,例如某个特效的跟随) - 不同的项目资源管理和加载的方法不太一样,我这里Init是简单地读取直接子级的元素(作演示用),在实际使用时需要更改为自己项目的方式
- 如果期望在列表滑到最左侧或者最右侧有一些效果,则需要为
Action<bool,bool> OnReachSide
添加实现,其中第一个bool代表是否到达左侧,第二个bool代表是否到达右侧。 - 如果期望某个元素被选中(即该元素出现在最大的那个位置)有效果,则需要为
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
}
垂直滚动版本
讲道理做成通用组件该写垂直版本的,但是,哎,时值中秋,我想玩游戏,遂不写。
缺陷与声明
缺陷
- 没有垂直版本
- 每次有一个新的元素经过最大的(被select的位置)位置会导致整体被刷新一遍(实现原理就是如此),这是一个性能开销,我知道本应如何处理,我只是写的时候思路歪了写成这样了。这样确实没有传统的无限列表的性能高。不过考虑到本文还是展示思路为主,就这样吧。(所以其实大可不必使用LinkedList)
- 没有实现元素的点击移动。目前我只是
refreshElem
的第三个参数传信息,直接把界面刷新了,相当于点击元素直接出现在最大的位置,没有移动过去的过程。可以考虑添加一个函数模拟拖动,按照恒定的速度把一定的偏移量添加至dragDIstance
。 - 吸附较为生硬(正如我所说,我只是放出了一个初步版本)
声明
本文涉及的代码遵从CC4.0协议
趁着放假写了一下,比较草率(然后拖到现在才整理并发出来),可改进的地方也有很多,不代表本人用到项目中的最终品质。