今天我们来做一个UI里经常做的东西:无限滚动列表。
首先我们得写清楚实现的基本思路:
所谓的无限滚动当然不是真的无限滚动,我们只要把离开列表的框再丢到列表的后面就行,核心理念和对象池是类似的。
我们来一点一点实现:
首先是:
public enum UICyclicScrollDirection {Vertical,Horizontal
}
滚动列表的方向枚举,竖直和水平。
public class ViewCellBundle<TCell> : IPoolObject where TCell : MonoBehaviour {public int index; // 当前Bundle在数据源中的起始索引public Vector2 position; // 在Content中的锚点位置public TCell[] Cells { get; } // 单元格对象数组public int CellCapacity => Cells.Length; // 当前Bundle的容量public ViewCellBundle(int capacity) {Cells = new TCell[capacity]; // 预初始化对象池}public void Clear() {index = -1;foreach(var cell in Cells) {cell.gameObject.SetActive(false); // 对象池回收逻辑}}
}
这个是我们的视图单元格类,支持泛型的同时带有约束。
[SerializeField] protected C _cellObject; // 单元格预制体
[SerializeField] protected RectTransform content; // 内容容器
[SerializeField] private RectTransform _viewRange; // 可视区域矩形
[SerializeField] private Vector2 _cellSpace; // 单元格间距
private LinkedList<ViewCellBundle<C>> viewCellBundles; // 当前显示的Bundle链表
我们用一个LinkedList来存储单元格。
public Vector2 ItemSize => CellSize + _cellSpace; // 单元格+间距的总尺寸
private Vector2 CellSize => _cellRectTransform.sizeDelta; // 原始单元格尺寸
这里只是定义了一个总尺寸和一个单元格尺寸,这里可以说一下=>,C++中这个一般用于lambda表达式,但是在这里:
public virtual void Initlize(ICollection<D> datas, bool resetPos = false) {_cellRectTransform = _cellObject.GetComponent<RectTransform>();Datas = datas;RecalculateContentSize(resetPos); // 根据数据量计算Content尺寸UpdateDisplay(); // 初始渲染
}public void Refrash(bool resetContentPos = false) {RecalculateContentSize(resetContentPos);UpdateDisplay(); // 数据变化时重新渲染
}
初始化函数中,我们输入数据以及一个bool变量来表示是否有更新Content,我们获取单元格的transform与数据,然后根据传入的bool变量来决定是否重新计算Content尺寸之后进行初始渲染;
Refrash函数中就是负责重新计算尺寸和渲染的,用于需要更新Content时。
private void UpdateDisplay() {RemoveHead(); RemoveTail();if(viewCellBundles.Count == 0) {RefreshAllCellInViewRange(); // 初始填充可视区域} else {AddHead(); // 滚动时动态扩展AddTail();}RemoveItemOutOfListRange(); // 清理越界元素
}
渲染函数中,我们先移除头部尾部元素,之后如果链表的长度为0则一口气填充所有可视区域,否则我们就填充滑动离开列表的空缺,对于已经离开列表的元素我们进行清除。
private void AddHead() {// 计算需要新增的Bundle位置while(OnViewRange(newHeadPos)) {var bundle = GetViewBundle(index, pos);viewCellBundles.AddFirst(bundle);}
}private bool OnViewRange(Vector2 pos) {// 判断坐标是否在可视区域内[1](@ref)return viewDirection == UICyclicScrollDirection.Horizontal ? !InViewRangeLeft(pos) && !InViewRangeRight(pos): !AboveViewRange(pos) && !UnderViewRange(pos);
}
添加头部元素的函数中,我们首先从链表中找到用于填充的视图元素和填充的位置,然后用LinkedList中的AddFirst方法填充到到链表头部。
判断坐标是否在可视范围内,我们根据滑动列表的方向来判断是否超过范围。
public int GetIndex(Vector2 position) {return viewDirection == UICyclicScrollDirection.Vertical ? Mathf.RoundToInt(-position.y / ItemSize.y) : Mathf.RoundToInt(position.x / ItemSize.x);
}public Vector2 CaculateRelativePostion(Vector2 curPosition) {// 将绝对坐标转换为相对Content的坐标return viewDirection == UICyclicScrollDirection.Horizontal ? new Vector2(curPosition.x + content.anchoredPosition.x, curPosition.y): new Vector2(curPosition.x, curPosition.y + content.anchoredPosition.y);
}
GetIndex函数的作用是根据具体的坐标得到具体的序号,RoundToInt的用法就是一个基于四舍五入的将浮点数转换成整数的方法。
坐标转换方法则是一个基于锚点位置来计算相对位置的过程。
效果如图。
关于定时器:
Unity是有自己的定时器的:
这些方法各有优劣,但是总的来说:
所以我们需要一个更独立(不依赖MonoBehavior等)、更精准(误差更小)、更灵活(不用频繁地通过如StopCoroutine方法来控制)的定时器方法。
public float Duration { get; } // 定时器总时长(秒)
public bool IsLooped { get; } // 是否循环执行
public bool IsCompleted { get; private set; } // 是否完成(非循环任务完成时设置)
public bool UsesRealTime { get; } // 使用游戏时间(Time.time)或真实时间(Time.realtimeSinceStartup)
public bool IsPaused => _timeElapsedBeforePause.HasValue; // 暂停状态
public bool IsCancelled => _timeElapsedBeforeCancel.HasValue; // 取消状态
public bool IsDone => IsCompleted || IsCancelled || IsOwnerDestroyed; // 终止条件
定义了一系列变量,都有注释。
public static Timer Register(float duration, Action onComplete, Action<float> onUpdate = null,bool isLooped = false, bool useRealTime = false, MonoBehaviour autoDestroyOwner = null){if (_manager == null){var managerInScene = Object.FindObjectOfType<TimerManager>();if (managerInScene != null){_manager = managerInScene;}else{var managerObject = new GameObject { name = "TimerManager" };_manager = managerObject.AddComponent<TimerManager>();}}var timer = new Timer(duration, onComplete, onUpdate, isLooped, useRealTime, autoDestroyOwner);_manager.RegisterTimer(timer);return timer;}public static Timer Register(float duration, bool isLooped, bool useRealTime, Action onComplete) {return Register(duration, onComplete, null, isLooped, useRealTime);}
这里是两个重载的静态方法,我们首先判断场景中是否有manager,没有的话就新建一个manager,这个manager是我们实现时间管理的基础和载体。
生成一个Timer,也就是定时器,包含一系列参数如持续时长,计时完成的回调,每帧更新的回调,是否循环等。我们生成定时器之后把定时器加入manager的列表中并返回定时器。
下面还有一个简化版的注册方法,没有每帧调用的回调函数参数,显然更符合不需要实时更新的定时器。
/// <summary>/// Cancels a timer. The main benefit of this over the method on the instance is that you will not get/// a <see cref="NullReferenceException"/> if the timer is null./// </summary>/// <param name="timer">The timer to cancel.</param>public static void Cancel(Timer timer){timer?.Cancel();}/// <summary>/// Pause a timer. The main benefit of this over the method on the instance is that you will not get/// a <see cref="NullReferenceException"/> if the timer is null./// </summary>/// <param name="timer">The timer to pause.</param>public static void Pause(Timer timer){timer?.Pause();}/// <summary>/// Resume a timer. The main benefit of this over the method on the instance is that you will not get/// a <see cref="NullReferenceException"/> if the timer is null./// </summary>/// <param name="timer">The timer to resume.</param>public static void Resume(Timer timer){if (timer != null){timer.Resume();}}public static void CancelAllRegisteredTimers(){if (Timer._manager != null){Timer._manager.CancelAllTimers();}// if the manager doesn't exist, we don't have any registered timers yet, so don't// need to do anything in this case}public static void PauseAllRegisteredTimers(){if (Timer._manager != null){Timer._manager.PauseAllTimers();}// if the manager doesn't exist, we don't have any registered timers yet, so don't// need to do anything in this case}public static void ResumeAllRegisteredTimers(){if (Timer._manager != null){Timer._manager.ResumeAllTimers();}// if the manager doesn't exist, we don't have any registered timers yet, so don't// need to do anything in this case}
写了一系列方法:Cancel,Pause,Resume,CancelAllRegisteredTimers,PauseAllRegisteredTimers,ResumeAllRegisteredTimers。总的来说就是针对单个计时器的删除,暂停和复原以及针对所有已注册的计时器的删除,暂停和复原。这些都是静态方法,显然是专门针对我们的静态变量,也就是我们的manager的。
/// <summary>/// Stop a timer that is in-progress or paused. The timer's on completion callback will not be called./// </summary>public void Cancel(){if (IsDone){return;}_timeElapsedBeforeCancel = GetTimeElapsed();_timeElapsedBeforePause = null;}/// <summary>/// Pause a running timer. A paused timer can be resumed from the same point it was paused./// </summary>public void Pause(){if (IsPaused || IsDone){return;}_timeElapsedBeforePause = GetTimeElapsed();}/// <summary>/// Continue a paused timer. Does nothing if the timer has not been paused./// </summary>public void Resume(){if (!IsPaused || IsDone){return;}_timeElapsedBeforePause = null;}/// <summary>/// Get how many seconds have elapsed since the start of this timer's current cycle./// </summary>/// <returns>The number of seconds that have elapsed since the start of this timer's current cycle, i.e./// the current loop if the timer is looped, or the start if it isn't.////// If the timer has finished running, this is equal to the duration.////// If the timer was cancelled/paused, this is equal to the number of seconds that passed between the timer/// starting and when it was cancelled/paused.</returns>public float GetTimeElapsed(){if (IsCompleted || GetWorldTime() >= GetFireTime()){return Duration;}return _timeElapsedBeforeCancel ??_timeElapsedBeforePause ??GetWorldTime() - _startTime;}/// <summary>/// Get how many seconds remain before the timer completes./// </summary>/// <returns>The number of seconds that remain to be elapsed until the timer is completed. A timer/// is only elapsing time if it is not paused, cancelled, or completed. This will be equal to zero/// if the timer completed.</returns>public float GetTimeRemaining(){return Duration - GetTimeElapsed();}/// <summary>/// Get how much progress the timer has made from start to finish as a ratio./// </summary>/// <returns>A value from 0 to 1 indicating how much of the timer's duration has been elapsed.</returns>public float GetRatioComplete(){return GetTimeElapsed() / Duration;}/// <summary>/// Get how much progress the timer has left to make as a ratio./// </summary>/// <returns>A value from 0 to 1 indicating how much of the timer's duration remains to be elapsed.</returns>public float GetRatioRemaining(){return GetTimeRemaining() / Duration;}
这里就是上述manager方法中具体调用的函数
首先依然是我们的删除,暂停和重启:
剩下的函数用于查询计时器状态:
#region Private Static Properties/Fields// responsible for updating all registered timersprivate static TimerManager _manager;#endregion#region Private Properties/Fieldsprivate bool IsOwnerDestroyed => _hasAutoDestroyOwner && _autoDestroyOwner == null;private readonly Action _onComplete;private readonly Action<float> _onUpdate;private float _startTime;private float _lastUpdateTime;// for pausing, we push the start time forward by the amount of time that has passed.// this will mess with the amount of time that elapsed when we're cancelled or paused if we just// check the start time versus the current world time, so we need to cache the time that was elapsed// before we paused/cancelledprivate float? _timeElapsedBeforeCancel;private float? _timeElapsedBeforePause;// after the auto destroy owner is destroyed, the timer will expire// this way you don't run into any annoying bugs with timers running and accessing objects// after they have been destroyedprivate readonly MonoBehaviour _autoDestroyOwner;private readonly bool _hasAutoDestroyOwner;
生成唯一的静态变量实例_manager。
定义两个只读委托:计时完成和每帧更新,两个浮点数:开始时间和最后更新时间,取消前时长和暂停前时长,至于最后的两个DestoryOwner:
一个是显式的自动销毁标志,一个则是判断MonoBehavior是否存在的自动销毁。_autoDestroyOwner
实现的是定时器与宿主对象的生命周期绑定,而TimerManager
作为全局单例独立存在。只有当显式启用_hasAutoDestroyOwner
且宿主对象销毁时,定时器自身才会终止,但不会影响_manager
的存活状态
private Timer(float duration, Action onComplete, Action<float> onUpdate,bool isLooped, bool usesRealTime, MonoBehaviour autoDestroyOwner){Duration = duration;_onComplete = onComplete;_onUpdate = onUpdate;IsLooped = isLooped;UsesRealTime = usesRealTime;_autoDestroyOwner = autoDestroyOwner;_hasAutoDestroyOwner = autoDestroyOwner != null;_startTime = GetWorldTime();_lastUpdateTime = _startTime;}
构造函数,主要就是获取输入参数。
private float GetWorldTime(){return UsesRealTime ? Time.realtimeSinceStartup : Time.time;}private float GetFireTime(){return _startTime + Duration;}private float GetTimeDelta(){return GetWorldTime() - _lastUpdateTime;}
三个获取时间的函数,第一个函数提供两个时间基准:不受游戏影响的真实时间和受游戏影响的游戏逻辑时间;第二个函数计算预期的计时结束时间;第三个函数计算两次更新之间的时间间隔。
private void Update(){if (IsDone){return;}if (IsPaused){_startTime += GetTimeDelta();_lastUpdateTime = GetWorldTime();return;}_lastUpdateTime = GetWorldTime();if (_onUpdate != null){_onUpdate(GetTimeElapsed());}if (GetWorldTime() >= GetFireTime()){if (_onComplete != null){_onComplete();}if (IsLooped){_startTime = GetWorldTime();}else{IsCompleted = true;}}}
Update生命周期函数,如果计时完成则返回,如果暂停则:把更新间隔的时间加到开始时间上,然后更新上次更新时间;暂停结束后再更新一次最后更新时间;如果有每帧更新的回调,我们执行回调(把计时器启动以来的时长作为参数传入);如果时间已经到了计时结束的时间点:如果有计时完成的回调则执行,如果开启了循环计时则更新开始时间,否则返回IsCompleted = true。
private class TimerManager : MonoBehaviour{private List<Timer> _timers = new List<Timer>();// buffer adding timers so we don't edit a collection during iterationprivate List<Timer> _timersToAdd = new List<Timer>();public void RegisterTimer(Timer timer){_timersToAdd.Add(timer);}public void CancelAllTimers(){foreach (var timer in _timers){timer.Cancel();}_timers = new List<Timer>();_timersToAdd = new List<Timer>();}public void PauseAllTimers(){foreach (var timer in _timers){timer.Pause();}}public void ResumeAllTimers(){foreach (var timer in _timers){timer.Resume();}}// update all the registered timers on every frame[UsedImplicitly]private void Update(){UpdateAllTimers();}private void UpdateAllTimers(){if (_timersToAdd.Count > 0){_timers.AddRange(_timersToAdd);_timersToAdd.Clear();}foreach (var timer in _timers){timer.Update();}_timers.RemoveAll(t => t.IsDone);}}
这是我们的管理器类的内部:
我们有两个数组:一个存储timer计时器而另一个存储准备加入数组的计时器。我们首先在这里实现了之前使用过的针对所有计时器的删除、暂停和重启,当然还有注册;然后在我们的update里,我们会把准备加入数组的计时器加入数组并清空另一个数组,然后对数组中每一个计时器执行Update函数(前文已定义),最后我们批量删除满足IsDone的计时器。