457 lines
13 KiB
C#
457 lines
13 KiB
C#
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);
|
|
}
|
|
}
|