13 public class Scroller : UIBehaviour, IPointerUpHandler, IPointerDownHandler, IBeginDragHandler, IEndDragHandler, IDragHandler, IScrollHandler
15 [SerializeField] RectTransform viewport =
default;
21 ? viewport.rect.size.x
22 : viewport.rect.size.y;
39 set => movementType = value;
42 [SerializeField]
float elasticity = 0.1f;
50 set => elasticity = value;
53 [SerializeField]
float scrollSensitivity = 1f;
60 get => scrollSensitivity;
61 set => scrollSensitivity = value;
64 [SerializeField]
bool inertia =
true;
72 set => inertia = value;
75 [SerializeField]
float decelerationRate = 0.03f;
82 get => decelerationRate;
83 set => decelerationRate = value;
86 [SerializeField] Snap snap =
new Snap {
88 VelocityThreshold = 0.5f,
90 Easing =
Ease.InOutCubic
102 set => snap.Enable = value;
105 [SerializeField]
bool draggable =
true;
113 set => draggable = value;
116 [SerializeField]
Scrollbar scrollbar =
default;
129 get => currentPosition;
132 autoScrollState.Reset();
136 UpdatePosition(value);
140 readonly AutoScrollState autoScrollState =
new AutoScrollState();
142 Action<float> onValueChanged;
143 Action<int> onSelectionChanged;
145 Vector2 beginDragPointerPosition;
146 float scrollStartPosition;
148 float currentPosition;
161 public float VelocityThreshold;
162 public float Duration;
168 class AutoScrollState
172 public float Duration;
174 public float StartTime;
175 public float EndPosition;
177 public Action OnComplete;
190 public void Complete()
192 OnComplete?.Invoke();
203 scrollbar.onValueChanged.AddListener(x => UpdatePosition(x * (totalCount - 1f),
false));
234 public void ScrollTo(
float position,
float duration, Action onComplete =
null) =>
ScrollTo(position, duration,
Ease.OutCubic, onComplete);
243 public void ScrollTo(
float position,
float duration,
Ease easing, Action onComplete =
null) =>
ScrollTo(position, duration, Easing.Get(easing), onComplete);
256 Position = CircularPosition(position, totalCount);
257 onComplete?.Invoke();
261 autoScrollState.Reset();
262 autoScrollState.Enable =
true;
263 autoScrollState.Duration = duration;
264 autoScrollState.EasingFunction = easingFunction ?? DefaultEasingFunction;
265 autoScrollState.StartTime = Time.unscaledTime;
266 autoScrollState.EndPosition = currentPosition + CalculateMovementAmount(currentPosition, position);
267 autoScrollState.OnComplete = onComplete;
270 scrollStartPosition = currentPosition;
272 UpdateSelection(Mathf.RoundToInt(CircularPosition(autoScrollState.EndPosition, totalCount)));
281 if (index < 0 || index > totalCount - 1)
283 throw new ArgumentOutOfRangeException(nameof(index));
286 UpdateSelection(index);
299 var movementAmount = CalculateMovementAmount(sourceIndex, destIndex);
310 void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
312 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
319 autoScrollState.Reset();
323 void IPointerUpHandler.OnPointerUp(PointerEventData eventData)
325 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
330 if (hold && snap.Enable)
332 UpdateSelection(Mathf.Clamp(Mathf.RoundToInt(currentPosition), 0, totalCount - 1));
333 ScrollTo(Mathf.RoundToInt(currentPosition), snap.Duration, snap.Easing);
340 void IScrollHandler.OnScroll(PointerEventData eventData)
347 var delta = eventData.scrollDelta;
352 ? Mathf.Abs(delta.y) > Mathf.Abs(delta.x)
355 : Mathf.Abs(delta.x) > Mathf.Abs(delta.y)
359 if (eventData.IsScrolling())
364 var position = currentPosition + scrollDelta /
ViewportSize * scrollSensitivity;
367 position += CalculateOffset(position);
370 if (autoScrollState.Enable)
372 autoScrollState.Reset();
375 UpdatePosition(position);
379 void IBeginDragHandler.OnBeginDrag(PointerEventData eventData)
381 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
387 RectTransformUtility.ScreenPointToLocalPointInRectangle(
390 eventData.pressEventCamera,
391 out beginDragPointerPosition);
393 scrollStartPosition = currentPosition;
395 autoScrollState.Reset();
399 void IDragHandler.OnDrag(PointerEventData eventData)
401 if (!draggable || eventData.button != PointerEventData.InputButton.Left || !dragging)
406 if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
409 eventData.pressEventCamera,
410 out var dragPointerPosition))
415 var pointerDelta = dragPointerPosition - beginDragPointerPosition;
416 var position = (scrollDirection ==
ScrollDirection.Horizontal ? -pointerDelta.x : pointerDelta.y)
419 + scrollStartPosition;
421 var offset = CalculateOffset(position);
428 position -= RubberDelta(offset, scrollSensitivity);
432 UpdatePosition(position);
436 void IEndDragHandler.OnEndDrag(PointerEventData eventData)
438 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
446 float CalculateOffset(
float position)
458 if (position > totalCount - 1)
460 return totalCount - 1 - position;
466 void UpdatePosition(
float position,
bool updateScrollbar =
true)
468 onValueChanged?.Invoke(currentPosition = position);
470 if (scrollbar && updateScrollbar)
472 scrollbar.value = Mathf.Clamp01(position / Mathf.Max(totalCount - 1f, 1e-4f));
476 void UpdateSelection(
int index) => onSelectionChanged?.Invoke(index);
478 float RubberDelta(
float overStretching,
float viewSize) =>
479 (1 - 1 / (Mathf.Abs(overStretching) * 0.55f / viewSize + 1)) * viewSize * Mathf.Sign(overStretching);
483 var deltaTime = Time.unscaledDeltaTime;
484 var offset = CalculateOffset(currentPosition);
486 if (autoScrollState.Enable)
490 if (autoScrollState.Elastic)
492 position = Mathf.SmoothDamp(currentPosition, currentPosition + offset, ref velocity,
493 elasticity, Mathf.Infinity, deltaTime);
495 if (Mathf.Abs(velocity) < 0.01f)
497 position = Mathf.Clamp(Mathf.RoundToInt(position), 0, totalCount - 1);
499 autoScrollState.Complete();
504 var alpha = Mathf.Clamp01((Time.unscaledTime - autoScrollState.StartTime) /
505 Mathf.Max(autoScrollState.Duration,
float.Epsilon));
506 position = Mathf.LerpUnclamped(scrollStartPosition, autoScrollState.EndPosition,
507 autoScrollState.EasingFunction(alpha));
509 if (Mathf.Approximately(alpha, 1f))
511 autoScrollState.Complete();
515 UpdatePosition(position);
517 else if (!(dragging || scrolling) && (!Mathf.Approximately(offset, 0f) || !Mathf.Approximately(velocity, 0f)))
519 var position = currentPosition;
521 if (movementType ==
MovementType.Elastic && !Mathf.Approximately(offset, 0f))
523 autoScrollState.Reset();
524 autoScrollState.Enable =
true;
525 autoScrollState.Elastic =
true;
527 UpdateSelection(Mathf.Clamp(Mathf.RoundToInt(position), 0, totalCount - 1));
531 velocity *= Mathf.Pow(decelerationRate, deltaTime);
533 if (Mathf.Abs(velocity) < 0.001f)
538 position += velocity * deltaTime;
540 if (snap.Enable && Mathf.Abs(velocity) < snap.VelocityThreshold)
542 ScrollTo(Mathf.RoundToInt(currentPosition), snap.Duration, snap.Easing);
550 if (!Mathf.Approximately(velocity, 0f))
554 offset = CalculateOffset(position);
557 if (Mathf.Approximately(position, 0f) || Mathf.Approximately(position, totalCount - 1f))
560 UpdateSelection(Mathf.RoundToInt(position));
564 UpdatePosition(position);
568 if (!autoScrollState.Enable && (dragging || scrolling) && inertia)
570 var newVelocity = (currentPosition - prevPosition) / deltaTime;
571 velocity = Mathf.Lerp(velocity, newVelocity, deltaTime * 10f);
574 prevPosition = currentPosition;
578 float CalculateMovementAmount(
float sourcePosition,
float destPosition)
582 return Mathf.Clamp(destPosition, 0, totalCount - 1) - sourcePosition;
585 var amount = CircularPosition(destPosition, totalCount) - CircularPosition(sourcePosition, totalCount);
587 if (Mathf.Abs(amount) > totalCount * 0.5f)
589 amount = Mathf.Sign(-amount) * (totalCount - Mathf.Abs(amount));
595 float CircularPosition(
float p,
int size) => size < 1 ? 0 : p < 0 ? size - 1 + (p + 1) % size : p % size;
delegate float EasingFunction(float t)
Credit Erdener Gonenc - @PixelEnvision.