migration
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DG.Tweening;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
[RequireComponent(typeof(EventTrigger))]
|
||||
public class CardCarousel : MonoBehaviour
|
||||
{
|
||||
[SerializeField] RectTransform[] items;
|
||||
[SerializeField] float spacing = 40f;
|
||||
[SerializeField] float swipeThreshold = 60f;
|
||||
[SerializeField] float snapDuration = 0.25f;
|
||||
[SerializeField] Ease snapEase = Ease.OutCubic;
|
||||
[Tooltip("Horizontal half-width of the center tap zone, in local units of this RectTransform.")]
|
||||
[SerializeField] float centerTapHalfWidth = 260f;
|
||||
[SerializeField] bool looped = true;
|
||||
[SerializeField] int selectedIndex;
|
||||
[Header("Focus Animation")]
|
||||
[SerializeField] float activeScale = 1.05f;
|
||||
[SerializeField] float inactiveScale = 0.88f;
|
||||
[SerializeField] float activePullDown = 35f;
|
||||
|
||||
public UnityEvent<int> onSelected = new UnityEvent<int>();
|
||||
|
||||
EventTrigger eventTrigger;
|
||||
RectTransform rectTransform;
|
||||
RectTransform dragSpace;
|
||||
RectTransform duplicateClone;
|
||||
readonly Dictionary<RectTransform, Vector3> baseScales = new();
|
||||
|
||||
float slotStep;
|
||||
float focusX;
|
||||
float focusY;
|
||||
float focusPosition;
|
||||
float dragStartPosition;
|
||||
float pointerDownLocalX;
|
||||
bool isDragging;
|
||||
bool isAnimating;
|
||||
Tween snapTween;
|
||||
|
||||
public int SelectedIndex => selectedIndex;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
rectTransform = (RectTransform)transform;
|
||||
eventTrigger = GetComponent<EventTrigger>();
|
||||
RegisterTrigger(EventTriggerType.PointerDown, OnPointerDown);
|
||||
RegisterTrigger(EventTriggerType.Drag, OnDrag);
|
||||
RegisterTrigger(EventTriggerType.PointerUp, OnPointerUp);
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (items == null || items.Length < 2)
|
||||
{
|
||||
Debug.LogWarning($"{nameof(CardCarousel)} on {name} needs at least 2 items.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIndex = Mathf.Clamp(selectedIndex, 0, items.Length - 1);
|
||||
dragSpace = items[0].parent as RectTransform;
|
||||
slotStep = items[0].rect.width + spacing;
|
||||
focusX = items[0].anchoredPosition.x;
|
||||
focusY = items[0].anchoredPosition.y;
|
||||
|
||||
duplicateClone = CreateClone(items[0], "_carouselClone_Dup");
|
||||
|
||||
foreach (var item in items)
|
||||
CacheBaseScale(item);
|
||||
|
||||
focusPosition = selectedIndex;
|
||||
ApplyLayout();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
snapTween?.Kill();
|
||||
}
|
||||
|
||||
void RegisterTrigger(EventTriggerType type, Action<BaseEventData> handler)
|
||||
{
|
||||
var entry = new EventTrigger.Entry { eventID = type };
|
||||
entry.callback.AddListener(handler.Invoke);
|
||||
eventTrigger.triggers.Add(entry);
|
||||
}
|
||||
|
||||
RectTransform CreateClone(RectTransform source, string suffix)
|
||||
{
|
||||
var clone = Instantiate(source, source.parent);
|
||||
clone.name = source.name + suffix;
|
||||
DisableInteractions(clone);
|
||||
return clone;
|
||||
}
|
||||
|
||||
static void DisableInteractions(RectTransform target)
|
||||
{
|
||||
foreach (var graphic in target.GetComponentsInChildren<Graphic>(true))
|
||||
graphic.raycastTarget = false;
|
||||
|
||||
foreach (var button in target.GetComponentsInChildren<Button>(true))
|
||||
button.interactable = false;
|
||||
}
|
||||
|
||||
void OnPointerDown(BaseEventData eventData)
|
||||
{
|
||||
if (!TryGetPointer(eventData, out var pointer) || isAnimating || items == null || items.Length < 2)
|
||||
return;
|
||||
|
||||
snapTween?.Kill();
|
||||
isAnimating = false;
|
||||
isDragging = false;
|
||||
dragStartPosition = focusPosition;
|
||||
pointerDownLocalX = GetLocalDragX(pointer);
|
||||
}
|
||||
|
||||
void OnDrag(BaseEventData eventData)
|
||||
{
|
||||
if (!TryGetPointer(eventData, out var pointer) || items == null || items.Length < 2)
|
||||
return;
|
||||
|
||||
isDragging = true;
|
||||
float delta = GetLocalDragX(pointer) - pointerDownLocalX;
|
||||
focusPosition = dragStartPosition - delta / slotStep;
|
||||
if (!looped)
|
||||
focusPosition = Mathf.Clamp(focusPosition, 0f, items.Length - 1);
|
||||
|
||||
ApplyLayout();
|
||||
}
|
||||
|
||||
void OnPointerUp(BaseEventData eventData)
|
||||
{
|
||||
if (!TryGetPointer(eventData, out var pointer) || isAnimating || items == null || items.Length < 2)
|
||||
return;
|
||||
|
||||
float pointerDelta = GetLocalDragX(pointer) - pointerDownLocalX;
|
||||
float dragDelta = focusPosition - dragStartPosition;
|
||||
bool passedSwipeThreshold = Mathf.Abs(pointerDelta) >= swipeThreshold
|
||||
|| Mathf.Abs(dragDelta * slotStep) >= swipeThreshold;
|
||||
|
||||
if (!passedSwipeThreshold)
|
||||
{
|
||||
if (TryGetTapZone(pointer, out int tapDirection))
|
||||
{
|
||||
if (tapDirection == 0)
|
||||
SelectCurrent();
|
||||
else if (CanMoveBy(tapDirection))
|
||||
AnimateToDirection(tapDirection);
|
||||
else
|
||||
SnapTo(selectedIndex, null);
|
||||
}
|
||||
else
|
||||
SnapTo(selectedIndex, null);
|
||||
|
||||
isDragging = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
|
||||
int direction = dragDelta > 0f ? 1 : -1;
|
||||
if (Mathf.Abs(focusPosition - selectedIndex) > 0.5f)
|
||||
{
|
||||
int nearest = Mathf.RoundToInt(focusPosition);
|
||||
if (!looped)
|
||||
nearest = Mathf.Clamp(nearest, 0, items.Length - 1);
|
||||
if (nearest != selectedIndex && CanMoveBy(nearest > selectedIndex ? 1 : -1))
|
||||
{
|
||||
AnimateTo(nearest);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!CanMoveBy(direction))
|
||||
{
|
||||
SnapTo(selectedIndex, null);
|
||||
return;
|
||||
}
|
||||
|
||||
AnimateToDirection(direction);
|
||||
}
|
||||
|
||||
float GetLocalDragX(PointerEventData pointer)
|
||||
{
|
||||
if (dragSpace == null)
|
||||
return pointer.position.x;
|
||||
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
dragSpace, pointer.position, pointer.pressEventCamera, out var local);
|
||||
return local.x;
|
||||
}
|
||||
|
||||
bool TryGetTapZone(PointerEventData pointer, out int direction)
|
||||
{
|
||||
direction = 0;
|
||||
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
rectTransform, pointer.position, pointer.pressEventCamera, out var local))
|
||||
return false;
|
||||
|
||||
if (Mathf.Abs(local.x) <= centerTapHalfWidth)
|
||||
return true;
|
||||
|
||||
direction = local.x < 0f ? -1 : 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SelectCurrent()
|
||||
{
|
||||
if (Mathf.Approximately(focusPosition, selectedIndex))
|
||||
onSelected.Invoke(selectedIndex);
|
||||
else
|
||||
SnapTo(selectedIndex, () => onSelected.Invoke(selectedIndex));
|
||||
}
|
||||
|
||||
void AnimateToDirection(int direction)
|
||||
{
|
||||
int newIndex = GetAdjacentIndex(direction, items.Length);
|
||||
if (newIndex < 0)
|
||||
return;
|
||||
|
||||
AnimateTo(newIndex);
|
||||
}
|
||||
|
||||
void AnimateTo(int newIndex)
|
||||
{
|
||||
isAnimating = true;
|
||||
|
||||
snapTween?.Kill();
|
||||
snapTween = DOTween.To(
|
||||
() => focusPosition,
|
||||
value =>
|
||||
{
|
||||
focusPosition = value;
|
||||
ApplyLayout();
|
||||
},
|
||||
newIndex,
|
||||
snapDuration)
|
||||
.SetEase(snapEase)
|
||||
.OnComplete(() => CompleteSlide(newIndex));
|
||||
}
|
||||
|
||||
void CompleteSlide(int newIndex)
|
||||
{
|
||||
selectedIndex = newIndex;
|
||||
focusPosition = newIndex;
|
||||
isAnimating = false;
|
||||
ApplyLayout();
|
||||
}
|
||||
|
||||
void SnapTo(float targetPosition, Action onComplete)
|
||||
{
|
||||
isAnimating = true;
|
||||
snapTween?.Kill();
|
||||
snapTween = DOTween.To(
|
||||
() => focusPosition,
|
||||
value =>
|
||||
{
|
||||
focusPosition = value;
|
||||
ApplyLayout();
|
||||
},
|
||||
targetPosition,
|
||||
snapDuration)
|
||||
.SetEase(snapEase)
|
||||
.OnComplete(() =>
|
||||
{
|
||||
isAnimating = false;
|
||||
onComplete?.Invoke();
|
||||
});
|
||||
}
|
||||
|
||||
float GetItemOffset(int itemIndex)
|
||||
{
|
||||
return (itemIndex - focusPosition) * slotStep;
|
||||
}
|
||||
|
||||
bool IsItemVisible(float offset)
|
||||
{
|
||||
return Mathf.Abs(offset) <= slotStep * 1.5f;
|
||||
}
|
||||
|
||||
void ApplyLayout()
|
||||
{
|
||||
int count = items.Length;
|
||||
bool useDuplicate = looped && count == 2;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
float offset = GetItemOffset(i);
|
||||
if (IsItemVisible(offset))
|
||||
{
|
||||
SetItemTransform(items[i], offset);
|
||||
items[i].gameObject.SetActive(true);
|
||||
}
|
||||
else
|
||||
items[i].gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
if (useDuplicate && Mathf.Approximately(focusPosition, Mathf.Round(focusPosition)))
|
||||
{
|
||||
int restIndex = Mathf.RoundToInt(focusPosition);
|
||||
int otherIndex = 1 - restIndex;
|
||||
float otherOffset = GetItemOffset(otherIndex);
|
||||
float duplicateOffset = -otherOffset;
|
||||
|
||||
if (IsItemVisible(duplicateOffset))
|
||||
{
|
||||
SyncDuplicateClone(items[otherIndex]);
|
||||
SetItemTransform(duplicateClone, duplicateOffset);
|
||||
duplicateClone.gameObject.SetActive(true);
|
||||
}
|
||||
else if (duplicateClone != null)
|
||||
duplicateClone.gameObject.SetActive(false);
|
||||
}
|
||||
else if (duplicateClone != null)
|
||||
{
|
||||
duplicateClone.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
int topIndex = 0;
|
||||
float bestFocus = float.MaxValue;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!items[i].gameObject.activeSelf)
|
||||
continue;
|
||||
|
||||
float focus = GetFocusT(GetItemOffset(i));
|
||||
if (focus < bestFocus)
|
||||
{
|
||||
bestFocus = focus;
|
||||
topIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
items[topIndex].SetAsLastSibling();
|
||||
}
|
||||
|
||||
void SyncDuplicateClone(RectTransform source)
|
||||
{
|
||||
string expectedName = source.name + "_carouselClone_Dup";
|
||||
if (duplicateClone == null || duplicateClone.name != expectedName)
|
||||
{
|
||||
var parent = source.parent;
|
||||
int sibling = duplicateClone != null ? duplicateClone.GetSiblingIndex() : source.GetSiblingIndex();
|
||||
if (duplicateClone != null)
|
||||
{
|
||||
baseScales.Remove(duplicateClone);
|
||||
Destroy(duplicateClone.gameObject);
|
||||
}
|
||||
|
||||
duplicateClone = CreateClone(source, "_carouselClone_Dup");
|
||||
duplicateClone.SetParent(parent, false);
|
||||
duplicateClone.SetSiblingIndex(sibling);
|
||||
}
|
||||
|
||||
CacheBaseScale(duplicateClone, source);
|
||||
}
|
||||
|
||||
void CacheBaseScale(RectTransform item, RectTransform source = null)
|
||||
{
|
||||
if (baseScales.ContainsKey(item))
|
||||
return;
|
||||
|
||||
baseScales[item] = source != null ? source.localScale : item.localScale;
|
||||
}
|
||||
|
||||
void SetItemTransform(RectTransform item, float xOffset)
|
||||
{
|
||||
CacheBaseScale(item);
|
||||
|
||||
float focusT = GetFocusT(xOffset);
|
||||
float scale = Mathf.Lerp(activeScale, inactiveScale, focusT);
|
||||
item.localScale = baseScales[item] * scale;
|
||||
|
||||
var pos = item.anchoredPosition;
|
||||
pos.x = focusX + xOffset;
|
||||
pos.y = focusY - Mathf.Lerp(activePullDown, 0f, focusT);
|
||||
item.anchoredPosition = pos;
|
||||
}
|
||||
|
||||
float GetFocusT(float xOffset)
|
||||
{
|
||||
if (slotStep <= 0f)
|
||||
return 0f;
|
||||
|
||||
float t = Mathf.Clamp01(Mathf.Abs(xOffset) / slotStep);
|
||||
return t * t * (3f - 2f * t);
|
||||
}
|
||||
|
||||
static int WrapIndex(int index, int count)
|
||||
{
|
||||
if (count <= 0)
|
||||
return 0;
|
||||
return ((index % count) + count) % count;
|
||||
}
|
||||
|
||||
int GetAdjacentIndex(int direction, int count)
|
||||
{
|
||||
int index = selectedIndex + direction;
|
||||
if (looped)
|
||||
return WrapIndex(index, count);
|
||||
return index >= 0 && index < count ? index : -1;
|
||||
}
|
||||
|
||||
bool CanMoveBy(int direction)
|
||||
{
|
||||
if (items == null || items.Length == 0)
|
||||
return false;
|
||||
return GetAdjacentIndex(direction, items.Length) >= 0;
|
||||
}
|
||||
|
||||
int NormalizeIndex(int index)
|
||||
{
|
||||
if (items == null || items.Length == 0)
|
||||
return 0;
|
||||
return looped ? WrapIndex(index, items.Length) : Mathf.Clamp(index, 0, items.Length - 1);
|
||||
}
|
||||
|
||||
static bool TryGetPointer(BaseEventData eventData, out PointerEventData pointer)
|
||||
{
|
||||
pointer = eventData as PointerEventData;
|
||||
return pointer != null;
|
||||
}
|
||||
|
||||
public void SetSelectedIndex(int index, bool animate = false)
|
||||
{
|
||||
if (items == null || items.Length == 0)
|
||||
return;
|
||||
|
||||
int target = NormalizeIndex(index);
|
||||
if (target == selectedIndex)
|
||||
return;
|
||||
|
||||
if (!animate || !isActiveAndEnabled)
|
||||
{
|
||||
snapTween?.Kill();
|
||||
isAnimating = false;
|
||||
selectedIndex = target;
|
||||
focusPosition = target;
|
||||
ApplyLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
int direction = target > selectedIndex ? 1 : -1;
|
||||
if (!CanMoveBy(direction) || Mathf.Abs(target - selectedIndex) != 1)
|
||||
{
|
||||
selectedIndex = target;
|
||||
focusPosition = target;
|
||||
ApplyLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
AnimateToDirection(direction);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user