using System; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; using System.Collections; using System.Collections.Generic; using DG.Tweening; using Sirenix.OdinInspector; namespace Ichni.Menu.UI { // 一个完全自定义的列表控制器,实现了拖拽、惯性、边界和吸附 public class SongListControllerUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { [Title("核心组件")] [SerializeField] public RectTransform content; [SerializeField] public RectTransform viewport; [Title("预制体")] [SerializeField] private GameObject songItemPrefab; [Title("对齐与动画")] [SerializeField] public RectTransform centerPoint; [SerializeField] private float snapSpeed = 5f; [SerializeField] private float decelerationRate = 0.135f; [Title("平滑度优化")] [SerializeField] [Range(1f, 20f)] private float dragSmoothing = 16f; // 拖拽平滑度的阻尼值,可在Inspector中调节 [SerializeField][Range(1f, 20f)] private float releaseSmoothing = 4f; // 松手后平滑度的阻尼值,可在Inspector中调节 [Title("甩动判定")] [Tooltip("当松手时的速度大于此值,才会被判定为一次“甩动”并产生惯性")] [SerializeField] private float flickThreshold = 50f; public SongSelectionTab selectedTab; // 内部变量 private List songItems = new List(); private Vector2 velocity; private bool isDragging = false; public float topBound; public float bottomBound = 0f; private Vector2 targetPosition; // 【新增】内容的目标位置 private float targetX = 1500f; public bool isDuringSnap = false; public RectTransform closestTab; void Start() { InitializeList(); targetPosition = content.anchoredPosition; // 初始化目标位置 targetX = -1500f; } private void Update() { RectTransform closestItem = closestTab; float cloesestY = Mathf.Infinity; foreach (RectTransform item in songItems) { float distance = Mathf.Abs(item.position.y - centerPoint.position.y); if (distance < cloesestY) { cloesestY = distance; closestItem = item; } } if (closestItem != null && closestTab != closestItem) { closestTab = closestItem; MenuAudioManager.instance.audioContainer.PlaySoundFX("SelectTab"); } } // 使用LateUpdate来处理所有位置更新,防止抖动 void LateUpdate() { // 如果不在拖拽,则处理惯性 // 注意:我们只在 isDragging 为 false 时处理惯性, // OnEndDrag中已经对速度进行了判断,所以这里的逻辑依然适用 if (!isDragging && Mathf.Abs(velocity.y) > 0.1f) { // 将速度施加于目标位置 targetPosition.y += velocity.y * Time.deltaTime; // 速度衰减 velocity *= (1 - decelerationRate); // 当速度足够小时,停止惯性并触发吸附 if (Mathf.Abs(velocity.y) <= 0.1f) { velocity = Vector2.zero; StartCoroutine(SnapToClosest()); } } // 无论何时,都将Content的实际位置平滑地Lerp到目标位置 float damping = isDragging ? dragSmoothing : releaseSmoothing; Vector2 finalPosition = Vector2.Lerp(content.anchoredPosition, targetPosition, Time.deltaTime * damping); finalPosition.x = targetX; content.anchoredPosition = finalPosition; // 每次更新后都施加边界限制 ClampContentPosition(); } void InitializeList() { // ... (这部分代码无需改动) foreach (Transform child in content) { Destroy(child.gameObject); } songItems.Clear(); if (songItemPrefab != null) { GenerateSongTabs(); } Canvas.ForceUpdateCanvases(); topBound = -songItems[^1].anchoredPosition.y; bottomBound = -songItems[0].anchoredPosition.y; if (songItems.Count > 0) { StartCoroutine(SnapToItem(songItems[0], true)); } closestTab = songItems[0]; } public void GenerateSongTabs() { ChapterSelectionUnit chapterUnit = ChapterSelectionManager.instance.currentChapter; foreach (SongItemData song in chapterUnit.songs) { SongSelectionTab tab = Instantiate(songItemPrefab ,content).GetComponent(); songItems.Add(tab.GetComponent()); tab.SetUpTab(song); } } public void OnBeginDrag(PointerEventData eventData) { isDragging = true; velocity = Vector2.zero; StopAllCoroutines(); // 开始拖拽时,将目标位置与当前位置同步 targetPosition = content.anchoredPosition; selectedTab?.SetSelection(false); selectedTab = null; // 清除当前选中的Tab //DOTween.To(x=>targetX = x, targetX, -1550f, 0.2f).SetEase(Ease.OutQuad).Play(); //songItems.ForEach(item => item.DOScale(1.1f,0.2f).SetEase(Ease.OutQuad).Play()); } public void OnDrag(PointerEventData eventData) { // 拖拽时,只更新目标位置,而不是直接移动Content targetPosition += new Vector2(0, eventData.delta.y); // 【核心修正 #1】计算速度时,只使用Y轴分量 velocity = new Vector2(0, eventData.delta.y / Time.deltaTime); } public void OnEndDrag(PointerEventData eventData) { isDragging = false; //DOTween.To(x => targetX = x, targetX, -1500f, 0.2f).SetEase(Ease.OutQuad).Play(); //songItems.ForEach(item => item.DOScale(1,0.2f).SetEase(Ease.OutQuad).Play()); // 【核心修正】在这里根据速度决定下一步做什么 if (Mathf.Abs(velocity.y) > flickThreshold) { // 速度足够大,是“甩动”,让 LateUpdate 中的惯性逻辑接管 // 我们什么都不用做,LateUpdate会自动处理 } else { // 速度很小,是“慢速拖拽”,立即开始吸附 velocity = Vector2.zero; // 清除残余速度 StartCoroutine(SnapToClosest()); } } private void ClampContentPosition() { // 【核心修正 #2】现在我们限制的是目标位置,让Lerp去处理实际位置 targetPosition.y = Mathf.Clamp(targetPosition.y, bottomBound, topBound); } // SnapToClosest 和 SnapToItem 这两个协程也需要微调,以确保它们能正确地与新的平滑系统协作 private IEnumerator SnapToClosest() { // ... (寻找最近项的逻辑不变) ... yield return new WaitForEndOfFrame(); float minDistance = float.MaxValue; RectTransform closestItem = null; foreach (RectTransform item in songItems) { float distance = Mathf.Abs(item.position.y - centerPoint.position.y); if (distance < minDistance) { minDistance = distance; closestItem = item; } } if (closestItem != null) { yield return SnapToItem(closestItem, false); } } public IEnumerator SnapCoroutine; public IEnumerator SnapToTab(SongSelectionTab tab) { selectedTab?.SetSelection(false); selectedTab = null; // 清除当前选中的Tab if(isDuringSnap && SnapCoroutine != null) StopCoroutine(SnapCoroutine); SnapCoroutine = SnapToItem(tab.GetComponent(), false); yield return StartCoroutine(SnapCoroutine); } private IEnumerator SnapToItem(RectTransform targetItem, bool immediate) { if (!immediate) { yield return new WaitForEndOfFrame(); } Debug.Log("开始对齐到: " + targetItem.name); selectedTab = targetItem.GetComponent(); selectedTab.SetSelection(true); SongItemData connectedSong = selectedTab.connectedSong; MenuManager.instance.songSelectionUIPage.selectedSong = connectedSong; MenuManager.instance.songSelectionUIPage.selectedSave = GameSaveManager.instance.SongSaveModule.GetSongStatusSave(connectedSong.songName); Vector3 closestItemLocalPos = viewport.InverseTransformPoint(targetItem.position); Vector3 centerPointLocalPos = viewport.InverseTransformPoint(centerPoint.position); float localOffsetY = centerPointLocalPos.y - closestItemLocalPos.y; // 【核心修正 #3】吸附动画现在也是通过更新targetPosition来实现 Vector2 finalTargetPosition = content.anchoredPosition + new Vector2(0, localOffsetY); finalTargetPosition.y = Mathf.Clamp(finalTargetPosition.y, bottomBound, topBound); if (immediate) { // 立即模式:直接设置Content和Target targetPosition = finalTargetPosition; content.anchoredPosition = finalTargetPosition; } else { isDuringSnap = true; // 动画模式:只更新Target,让LateUpdate中的Lerp来完成动画 targetPosition = finalTargetPosition; // 我们也可以在这里保留一个独立的Lerp循环,以使用不同的snapSpeed velocity = Vector2.zero; while (Mathf.Abs(content.anchoredPosition.y - targetPosition.y) > 1f) { content.anchoredPosition = Vector2.Lerp(content.anchoredPosition, targetPosition, Time.deltaTime * snapSpeed); yield return null; } content.anchoredPosition = targetPosition; } Debug.Log($"已对齐到: {targetItem.GetComponent().songNameText.text}"); SongSelectionManager.instance.SetPreview(connectedSong, selectedTab.isLocked); MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.SetUp(connectedSong.difficultyDataList); MenuManager.instance.songSelectionUIPage.songInfoUI.SetIllustration(connectedSong.illustration, connectedSong.illustratorName); isDuringSnap = false; } } }