| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602 |
- /*
- * FancyScrollView (https://github.com/setchi/FancyScrollView)
- * Copyright (c) 2020 setchi
- * Licensed under MIT (https://github.com/setchi/FancyScrollView/blob/master/LICENSE)
- */
- using System;
- using UnityEngine;
- using UnityEngine.EventSystems;
- using UnityEngine.UI;
- using EasingCore;
- namespace FancyScrollView
- {
- /// <summary>
- /// スクロール位置の制御を行うコンポーネント.
- /// </summary>
- public class Scroller : UIBehaviour, IPointerUpHandler, IPointerDownHandler, IBeginDragHandler, IEndDragHandler, IDragHandler, IScrollHandler
- {
- [SerializeField] RectTransform viewport = default;
- /// <summary>
- /// ビューポートのサイズ.
- /// </summary>
- public float ViewportSize => scrollDirection == ScrollDirection.Horizontal
- ? viewport.rect.size.x
- : viewport.rect.size.y;
- [SerializeField] ScrollDirection scrollDirection = ScrollDirection.Vertical;
- /// <summary>
- /// スクロール方向.
- /// </summary>
- public ScrollDirection ScrollDirection => scrollDirection;
- [SerializeField] MovementType movementType = MovementType.Elastic;
- /// <summary>
- /// コンテンツがスクロール範囲を越えて移動するときに使用する挙動.
- /// </summary>
- public MovementType MovementType
- {
- get => movementType;
- set => movementType = value;
- }
- [SerializeField] float elasticity = 0.1f;
- /// <summary>
- /// コンテンツがスクロール範囲を越えて移動するときに使用する弾力性の量.
- /// </summary>
- public float Elasticity
- {
- get => elasticity;
- set => elasticity = value;
- }
- [SerializeField] float scrollSensitivity = 1f;
- /// <summary>
- /// <see cref="ViewportSize"/> の端から端まで Drag したときのスクロール位置の変化量.
- /// </summary>
- public float ScrollSensitivity
- {
- get => scrollSensitivity;
- set => scrollSensitivity = value;
- }
- [SerializeField] bool inertia = true;
- /// <summary>
- /// 慣性を使用するかどうか. <c>true</c> を指定すると慣性が有効に, <c>false</c> を指定すると慣性が無効になります.
- /// </summary>
- public bool Inertia
- {
- get => inertia;
- set => inertia = value;
- }
- [SerializeField] float decelerationRate = 0.03f;
- /// <summary>
- /// スクロールの減速率. <see cref="Inertia"/> が <c>true</c> の場合のみ有効です.
- /// </summary>
- public float DecelerationRate
- {
- get => decelerationRate;
- set => decelerationRate = value;
- }
- [SerializeField] Snap snap = new Snap {
- Enable = true,
- VelocityThreshold = 0.5f,
- Duration = 0.3f,
- Easing = Ease.InOutCubic
- };
- /// <summary>
- /// <c>true</c> ならスナップし, <c>false</c>ならスナップしません.
- /// </summary>
- /// <remarks>
- /// スナップを有効にすると, 慣性でスクロールが止まる直前に最寄りのセルへ移動します.
- /// </remarks>
- public bool SnapEnabled
- {
- get => snap.Enable;
- set => snap.Enable = value;
- }
- [SerializeField] bool draggable = true;
- /// <summary>
- /// Drag 入力を受付けるかどうか.
- /// </summary>
- public bool Draggable
- {
- get => draggable;
- set => draggable = value;
- }
- [SerializeField] Scrollbar scrollbar = default;
- /// <summary>
- /// スクロールバーのオブジェクト.
- /// </summary>
- public Scrollbar Scrollbar => scrollbar;
- /// <summary>
- /// 現在のスクロール位置.
- /// </summary>
- /// <value></value>
- public float Position
- {
- get => currentPosition;
- set
- {
- autoScrollState.Reset();
- velocity = 0f;
- dragging = false;
- UpdatePosition(value);
- }
- }
- readonly AutoScrollState autoScrollState = new AutoScrollState();
- Action<float> onValueChanged;
- Action<int> onSelectionChanged;
- Vector2 beginDragPointerPosition;
- float scrollStartPosition;
- float prevPosition;
- float currentPosition;
- int totalCount;
- bool hold;
- bool scrolling;
- bool dragging;
- float velocity;
- [Serializable]
- class Snap
- {
- public bool Enable;
- public float VelocityThreshold;
- public float Duration;
- public Ease Easing;
- }
- static readonly EasingFunction DefaultEasingFunction = Easing.Get(Ease.OutCubic);
- class AutoScrollState
- {
- public bool Enable;
- public bool Elastic;
- public float Duration;
- public EasingFunction EasingFunction;
- public float StartTime;
- public float EndPosition;
- public Action OnComplete;
- public void Reset()
- {
- Enable = false;
- Elastic = false;
- Duration = 0f;
- StartTime = 0f;
- EasingFunction = DefaultEasingFunction;
- EndPosition = 0f;
- OnComplete = null;
- }
- public void Complete()
- {
- OnComplete?.Invoke();
- Reset();
- }
- }
- protected override void Start()
- {
- base.Start();
- if (scrollbar)
- {
- scrollbar.onValueChanged.AddListener(x => UpdatePosition(x * (totalCount - 1f), false));
- }
- }
- /// <summary>
- /// スクロール位置が変化したときのコールバックを設定します.
- /// </summary>
- /// <param name="callback">スクロール位置が変化したときのコールバック.</param>
- public void OnValueChanged(Action<float> callback) => onValueChanged = callback;
- /// <summary>
- /// 選択位置が変化したときのコールバックを設定します.
- /// </summary>
- /// <param name="callback">選択位置が変化したときのコールバック.</param>
- public void OnSelectionChanged(Action<int> callback) => onSelectionChanged = callback;
- /// <summary>
- /// アイテムの総数を設定します.
- /// </summary>
- /// <remarks>
- /// <paramref name="totalCount"/> を元に最大スクロール位置を計算します.
- /// </remarks>
- /// <param name="totalCount">アイテムの総数.</param>
- public void SetTotalCount(int totalCount) => this.totalCount = totalCount;
- /// <summary>
- /// 指定した位置まで移動します.
- /// </summary>
- /// <param name="position">スクロール位置. <c>0f</c> ~ <c>totalCount - 1f</c> の範囲.</param>
- /// <param name="duration">移動にかける秒数.</param>
- /// <param name="onComplete">移動が完了した際に呼び出されるコールバック.</param>
- public void ScrollTo(float position, float duration, Action onComplete = null) => ScrollTo(position, duration, Ease.OutCubic, onComplete);
- /// <summary>
- /// 指定した位置まで移動します.
- /// </summary>
- /// <param name="position">スクロール位置. <c>0f</c> ~ <c>totalCount - 1f</c> の範囲.</param>
- /// <param name="duration">移動にかける秒数.</param>
- /// <param name="easing">移動に使用するイージング.</param>
- /// <param name="onComplete">移動が完了した際に呼び出されるコールバック.</param>
- public void ScrollTo(float position, float duration, Ease easing, Action onComplete = null) => ScrollTo(position, duration, Easing.Get(easing), onComplete);
- /// <summary>
- /// 指定した位置まで移動します.
- /// </summary>
- /// <param name="position">スクロール位置. <c>0f</c> ~ <c>totalCount - 1f</c> の範囲.</param>
- /// <param name="duration">移動にかける秒数.</param>
- /// <param name="easingFunction">移動に使用するイージング関数.</param>
- /// <param name="onComplete">移動が完了した際に呼び出されるコールバック.</param>
- public void ScrollTo(float position, float duration, EasingFunction easingFunction, Action onComplete = null)
- {
- if (duration <= 0f)
- {
- Position = CircularPosition(position, totalCount);
- onComplete?.Invoke();
- return;
- }
- autoScrollState.Reset();
- autoScrollState.Enable = true;
- autoScrollState.Duration = duration;
- autoScrollState.EasingFunction = easingFunction ?? DefaultEasingFunction;
- autoScrollState.StartTime = Time.unscaledTime;
- autoScrollState.EndPosition = currentPosition + CalculateMovementAmount(currentPosition, position);
- autoScrollState.OnComplete = onComplete;
- velocity = 0f;
- scrollStartPosition = currentPosition;
- UpdateSelection(Mathf.RoundToInt(CircularPosition(autoScrollState.EndPosition, totalCount)));
- }
- /// <summary>
- /// 指定したインデックスの位置までジャンプします.
- /// </summary>
- /// <param name="index">アイテムのインデックス.</param>
- public void JumpTo(int index)
- {
- if (index < 0 || index > totalCount - 1)
- {
- throw new ArgumentOutOfRangeException(nameof(index));
- }
- UpdateSelection(index);
- Position = index;
- }
- /// <summary>
- /// <paramref name="sourceIndex"/> から <paramref name="destIndex"/> に移動する際の移動方向を返します.
- /// スクロール範囲が無制限に設定されている場合は, 最短距離の移動方向を返します.
- /// </summary>
- /// <param name="sourceIndex">移動元のインデックス.</param>
- /// <param name="destIndex">移動先のインデックス.</param>
- /// <returns></returns>
- public MovementDirection GetMovementDirection(int sourceIndex, int destIndex)
- {
- var movementAmount = CalculateMovementAmount(sourceIndex, destIndex);
- return scrollDirection == ScrollDirection.Horizontal
- ? movementAmount > 0
- ? MovementDirection.Left
- : MovementDirection.Right
- : movementAmount > 0
- ? MovementDirection.Up
- : MovementDirection.Down;
- }
- /// <inheritdoc/>
- void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
- {
- if (!draggable || eventData.button != PointerEventData.InputButton.Left)
- {
- return;
- }
- hold = true;
- velocity = 0f;
- autoScrollState.Reset();
- }
- /// <inheritdoc/>
- void IPointerUpHandler.OnPointerUp(PointerEventData eventData)
- {
- if (!draggable || eventData.button != PointerEventData.InputButton.Left)
- {
- return;
- }
- if (hold && snap.Enable)
- {
- UpdateSelection(Mathf.RoundToInt(CircularPosition(currentPosition, totalCount)));
- ScrollTo(Mathf.RoundToInt(currentPosition), snap.Duration, snap.Easing);
- }
- hold = false;
- }
- /// <inheritdoc/>
- void IScrollHandler.OnScroll(PointerEventData eventData)
- {
- if (!draggable)
- {
- return;
- }
- var delta = eventData.scrollDelta;
- // Down is positive for scroll events, while in UI system up is positive.
- delta.y *= -1;
- var scrollDelta = scrollDirection == ScrollDirection.Horizontal
- ? Mathf.Abs(delta.y) > Mathf.Abs(delta.x)
- ? delta.y
- : delta.x
- : Mathf.Abs(delta.x) > Mathf.Abs(delta.y)
- ? delta.x
- : delta.y;
- if (eventData.IsScrolling())
- {
- scrolling = true;
- }
- var position = currentPosition + scrollDelta / ViewportSize * scrollSensitivity;
- if (movementType == MovementType.Clamped)
- {
- position += CalculateOffset(position);
- }
- if (autoScrollState.Enable)
- {
- autoScrollState.Reset();
- }
- UpdatePosition(position);
- }
- /// <inheritdoc/>
- void IBeginDragHandler.OnBeginDrag(PointerEventData eventData)
- {
- if (!draggable || eventData.button != PointerEventData.InputButton.Left)
- {
- return;
- }
- hold = false;
- RectTransformUtility.ScreenPointToLocalPointInRectangle(
- viewport,
- eventData.position,
- eventData.pressEventCamera,
- out beginDragPointerPosition);
- scrollStartPosition = currentPosition;
- dragging = true;
- autoScrollState.Reset();
- }
- /// <inheritdoc/>
- void IDragHandler.OnDrag(PointerEventData eventData)
- {
- if (!draggable || eventData.button != PointerEventData.InputButton.Left || !dragging)
- {
- return;
- }
- if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
- viewport,
- eventData.position,
- eventData.pressEventCamera,
- out var dragPointerPosition))
- {
- return;
- }
- var pointerDelta = dragPointerPosition - beginDragPointerPosition;
- var position = (scrollDirection == ScrollDirection.Horizontal ? -pointerDelta.x : pointerDelta.y)
- / ViewportSize
- * scrollSensitivity
- + scrollStartPosition;
- var offset = CalculateOffset(position);
- position += offset;
- if (movementType == MovementType.Elastic)
- {
- if (offset != 0f)
- {
- position -= RubberDelta(offset, scrollSensitivity);
- }
- }
- UpdatePosition(position);
- }
- /// <inheritdoc/>
- void IEndDragHandler.OnEndDrag(PointerEventData eventData)
- {
- if (!draggable || eventData.button != PointerEventData.InputButton.Left)
- {
- return;
- }
- dragging = false;
- }
- float CalculateOffset(float position)
- {
- if (movementType == MovementType.Unrestricted)
- {
- return 0f;
- }
- if (position < 0f)
- {
- return -position;
- }
- if (position > totalCount - 1)
- {
- return totalCount - 1 - position;
- }
- return 0f;
- }
- void UpdatePosition(float position, bool updateScrollbar = true)
- {
- onValueChanged?.Invoke(currentPosition = position);
- if (scrollbar && updateScrollbar)
- {
- scrollbar.value = Mathf.Clamp01(position / Mathf.Max(totalCount - 1f, 1e-4f));
- }
- }
- void UpdateSelection(int index) => onSelectionChanged?.Invoke(index);
- float RubberDelta(float overStretching, float viewSize) =>
- (1 - 1 / (Mathf.Abs(overStretching) * 0.55f / viewSize + 1)) * viewSize * Mathf.Sign(overStretching);
- void Update()
- {
- var deltaTime = Time.unscaledDeltaTime;
- var offset = CalculateOffset(currentPosition);
- if (autoScrollState.Enable)
- {
- var position = 0f;
- if (autoScrollState.Elastic)
- {
- position = Mathf.SmoothDamp(currentPosition, currentPosition + offset, ref velocity,
- elasticity, Mathf.Infinity, deltaTime);
- if (Mathf.Abs(velocity) < 0.01f)
- {
- position = Mathf.Clamp(Mathf.RoundToInt(position), 0, totalCount - 1);
- velocity = 0f;
- autoScrollState.Complete();
- }
- }
- else
- {
- var alpha = Mathf.Clamp01((Time.unscaledTime - autoScrollState.StartTime) /
- Mathf.Max(autoScrollState.Duration, float.Epsilon));
- position = Mathf.LerpUnclamped(scrollStartPosition, autoScrollState.EndPosition,
- autoScrollState.EasingFunction(alpha));
- if (Mathf.Approximately(alpha, 1f))
- {
- autoScrollState.Complete();
- }
- }
- UpdatePosition(position);
- }
- else if (!(dragging || scrolling) && (!Mathf.Approximately(offset, 0f) || !Mathf.Approximately(velocity, 0f)))
- {
- var position = currentPosition;
- if (movementType == MovementType.Elastic && !Mathf.Approximately(offset, 0f))
- {
- autoScrollState.Reset();
- autoScrollState.Enable = true;
- autoScrollState.Elastic = true;
- UpdateSelection(Mathf.Clamp(Mathf.RoundToInt(position), 0, totalCount - 1));
- }
- else if (inertia)
- {
- velocity *= Mathf.Pow(decelerationRate, deltaTime);
- if (Mathf.Abs(velocity) < 0.001f)
- {
- velocity = 0f;
- }
- position += velocity * deltaTime;
- if (snap.Enable && Mathf.Abs(velocity) < snap.VelocityThreshold)
- {
- ScrollTo(Mathf.RoundToInt(currentPosition), snap.Duration, snap.Easing);
- }
- }
- else
- {
- velocity = 0f;
- }
- if (!Mathf.Approximately(velocity, 0f))
- {
- if (movementType == MovementType.Clamped)
- {
- offset = CalculateOffset(position);
- position += offset;
- if (Mathf.Approximately(position, 0f) || Mathf.Approximately(position, totalCount - 1f))
- {
- velocity = 0f;
- UpdateSelection(Mathf.RoundToInt(position));
- }
- }
- UpdatePosition(position);
- }
- }
- if (!autoScrollState.Enable && (dragging || scrolling) && inertia)
- {
- var newVelocity = (currentPosition - prevPosition) / deltaTime;
- velocity = Mathf.Lerp(velocity, newVelocity, deltaTime * 10f);
- }
- prevPosition = currentPosition;
- scrolling = false;
- }
- float CalculateMovementAmount(float sourcePosition, float destPosition)
- {
- if (movementType != MovementType.Unrestricted)
- {
- return Mathf.Clamp(destPosition, 0, totalCount - 1) - sourcePosition;
- }
- var amount = CircularPosition(destPosition, totalCount) - CircularPosition(sourcePosition, totalCount);
- if (Mathf.Abs(amount) > totalCount * 0.5f)
- {
- amount = Mathf.Sign(-amount) * (totalCount - Mathf.Abs(amount));
- }
- return amount;
- }
- float CircularPosition(float p, int size) => size < 1 ? 0 : p < 0 ? size - 1 + (p + 1) % size : p % size;
- }
- }
|