今天我們來做一個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的計時器。