Tanoda
Scroller.cs
Go to the documentation of this file.
1
3
4using System;
7
9{
13 public class Scroller : UIBehaviour, IPointerUpHandler, IPointerDownHandler, IBeginDragHandler, IEndDragHandler, IDragHandler, IScrollHandler
14 {
15 [SerializeField] RectTransform viewport = default;
16
20 public float ViewportSize => scrollDirection == ScrollDirection.Horizontal
21 ? viewport.rect.size.x
22 : viewport.rect.size.y;
23
24 [SerializeField] ScrollDirection scrollDirection = ScrollDirection.Vertical;
25
29 public ScrollDirection ScrollDirection => scrollDirection;
30
31 [SerializeField] MovementType movementType = MovementType.Elastic;
32
37 {
38 get => movementType;
39 set => movementType = value;
40 }
41
42 [SerializeField] float elasticity = 0.1f;
43
47 public float Elasticity
48 {
49 get => elasticity;
50 set => elasticity = value;
51 }
52
53 [SerializeField] float scrollSensitivity = 1f;
54
58 public float ScrollSensitivity
59 {
60 get => scrollSensitivity;
61 set => scrollSensitivity = value;
62 }
63
64 [SerializeField] bool inertia = true;
65
69 public bool Inertia
70 {
71 get => inertia;
72 set => inertia = value;
73 }
74
75 [SerializeField] float decelerationRate = 0.03f;
76
80 public float DecelerationRate
81 {
82 get => decelerationRate;
83 set => decelerationRate = value;
84 }
85
86 [SerializeField] Snap snap = new Snap {
87 Enable = true,
88 VelocityThreshold = 0.5f,
89 Duration = 0.3f,
90 Easing = Ease.InOutCubic
91 };
92
99 public bool SnapEnabled
100 {
101 get => snap.Enable;
102 set => snap.Enable = value;
103 }
104
105 [SerializeField] bool draggable = true;
106
110 public bool Draggable
111 {
112 get => draggable;
113 set => draggable = value;
114 }
115
116 [SerializeField] Scrollbar scrollbar = default;
117
121 public Scrollbar Scrollbar => scrollbar;
122
127 public float Position
128 {
129 get => currentPosition;
130 set
131 {
132 autoScrollState.Reset();
133 velocity = 0f;
134 dragging = false;
135
136 UpdatePosition(value);
137 }
138 }
139
140 readonly AutoScrollState autoScrollState = new AutoScrollState();
141
142 Action<float> onValueChanged;
143 Action<int> onSelectionChanged;
144
145 Vector2 beginDragPointerPosition;
146 float scrollStartPosition;
147 float prevPosition;
148 float currentPosition;
149
150 int totalCount;
151
152 bool hold;
153 bool scrolling;
154 bool dragging;
155 float velocity;
156
157 [Serializable]
158 class Snap
159 {
160 public bool Enable;
161 public float VelocityThreshold;
162 public float Duration;
163 public Ease Easing;
164 }
165
166 static readonly EasingFunction DefaultEasingFunction = Easing.Get(Ease.OutCubic);
167
168 class AutoScrollState
169 {
170 public bool Enable;
171 public bool Elastic;
172 public float Duration;
174 public float StartTime;
175 public float EndPosition;
176
177 public Action OnComplete;
178
179 public void Reset()
180 {
181 Enable = false;
182 Elastic = false;
183 Duration = 0f;
184 StartTime = 0f;
185 EasingFunction = DefaultEasingFunction;
186 EndPosition = 0f;
187 OnComplete = null;
188 }
189
190 public void Complete()
191 {
192 OnComplete?.Invoke();
193 Reset();
194 }
195 }
196
197 protected override void Start()
198 {
199 base.Start();
200
201 if (scrollbar)
202 {
203 scrollbar.onValueChanged.AddListener(x => UpdatePosition(x * (totalCount - 1f), false));
204 }
205 }
206
211 public void OnValueChanged(Action<float> callback) => onValueChanged = callback;
212
217 public void OnSelectionChanged(Action<int> callback) => onSelectionChanged = callback;
218
226 public void SetTotalCount(int totalCount) => this.totalCount = totalCount;
227
234 public void ScrollTo(float position, float duration, Action onComplete = null) => ScrollTo(position, duration, Ease.OutCubic, onComplete);
235
243 public void ScrollTo(float position, float duration, Ease easing, Action onComplete = null) => ScrollTo(position, duration, Easing.Get(easing), onComplete);
244
252 public void ScrollTo(float position, float duration, EasingFunction easingFunction, Action onComplete = null)
253 {
254 if (duration <= 0f)
255 {
256 Position = CircularPosition(position, totalCount);
257 onComplete?.Invoke();
258 return;
259 }
260
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;
268
269 velocity = 0f;
270 scrollStartPosition = currentPosition;
271
272 UpdateSelection(Mathf.RoundToInt(CircularPosition(autoScrollState.EndPosition, totalCount)));
273 }
274
279 public void JumpTo(int index)
280 {
281 if (index < 0 || index > totalCount - 1)
282 {
283 throw new ArgumentOutOfRangeException(nameof(index));
284 }
285
286 UpdateSelection(index);
287 Position = index;
288 }
289
297 public MovementDirection GetMovementDirection(int sourceIndex, int destIndex)
298 {
299 var movementAmount = CalculateMovementAmount(sourceIndex, destIndex);
300 return scrollDirection == ScrollDirection.Horizontal
301 ? movementAmount > 0
302 ? MovementDirection.Left
303 : MovementDirection.Right
304 : movementAmount > 0
306 : MovementDirection.Down;
307 }
308
310 void IPointerDownHandler.OnPointerDown(PointerEventData eventData)
311 {
312 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
313 {
314 return;
315 }
316
317 hold = true;
318 velocity = 0f;
319 autoScrollState.Reset();
320 }
321
323 void IPointerUpHandler.OnPointerUp(PointerEventData eventData)
324 {
325 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
326 {
327 return;
328 }
329
330 if (hold && snap.Enable)
331 {
332 UpdateSelection(Mathf.Clamp(Mathf.RoundToInt(currentPosition), 0, totalCount - 1));
333 ScrollTo(Mathf.RoundToInt(currentPosition), snap.Duration, snap.Easing);
334 }
335
336 hold = false;
337 }
338
340 void IScrollHandler.OnScroll(PointerEventData eventData)
341 {
342 if (!draggable)
343 {
344 return;
345 }
346
347 var delta = eventData.scrollDelta;
348
349 // Down is positive for scroll events, while in UI system up is positive.
350 delta.y *= -1;
351 var scrollDelta = scrollDirection == ScrollDirection.Horizontal
352 ? Mathf.Abs(delta.y) > Mathf.Abs(delta.x)
353 ? delta.y
354 : delta.x
355 : Mathf.Abs(delta.x) > Mathf.Abs(delta.y)
356 ? delta.x
357 : delta.y;
358
359 if (eventData.IsScrolling())
360 {
361 scrolling = true;
362 }
363
364 var position = currentPosition + scrollDelta / ViewportSize * scrollSensitivity;
365 if (movementType == MovementType.Clamped)
366 {
367 position += CalculateOffset(position);
368 }
369
370 if (autoScrollState.Enable)
371 {
372 autoScrollState.Reset();
373 }
374
375 UpdatePosition(position);
376 }
377
379 void IBeginDragHandler.OnBeginDrag(PointerEventData eventData)
380 {
381 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
382 {
383 return;
384 }
385
386 hold = false;
387 RectTransformUtility.ScreenPointToLocalPointInRectangle(
388 viewport,
389 eventData.position,
390 eventData.pressEventCamera,
391 out beginDragPointerPosition);
392
393 scrollStartPosition = currentPosition;
394 dragging = true;
395 autoScrollState.Reset();
396 }
397
399 void IDragHandler.OnDrag(PointerEventData eventData)
400 {
401 if (!draggable || eventData.button != PointerEventData.InputButton.Left || !dragging)
402 {
403 return;
404 }
405
406 if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
407 viewport,
408 eventData.position,
409 eventData.pressEventCamera,
410 out var dragPointerPosition))
411 {
412 return;
413 }
414
415 var pointerDelta = dragPointerPosition - beginDragPointerPosition;
416 var position = (scrollDirection == ScrollDirection.Horizontal ? -pointerDelta.x : pointerDelta.y)
418 * scrollSensitivity
419 + scrollStartPosition;
420
421 var offset = CalculateOffset(position);
422 position += offset;
423
424 if (movementType == MovementType.Elastic)
425 {
426 if (offset != 0f)
427 {
428 position -= RubberDelta(offset, scrollSensitivity);
429 }
430 }
431
432 UpdatePosition(position);
433 }
434
436 void IEndDragHandler.OnEndDrag(PointerEventData eventData)
437 {
438 if (!draggable || eventData.button != PointerEventData.InputButton.Left)
439 {
440 return;
441 }
442
443 dragging = false;
444 }
445
446 float CalculateOffset(float position)
447 {
448 if (movementType == MovementType.Unrestricted)
449 {
450 return 0f;
451 }
452
453 if (position < 0f)
454 {
455 return -position;
456 }
457
458 if (position > totalCount - 1)
459 {
460 return totalCount - 1 - position;
461 }
462
463 return 0f;
464 }
465
466 void UpdatePosition(float position, bool updateScrollbar = true)
467 {
468 onValueChanged?.Invoke(currentPosition = position);
469
470 if (scrollbar && updateScrollbar)
471 {
472 scrollbar.value = Mathf.Clamp01(position / Mathf.Max(totalCount - 1f, 1e-4f));
473 }
474 }
475
476 void UpdateSelection(int index) => onSelectionChanged?.Invoke(index);
477
478 float RubberDelta(float overStretching, float viewSize) =>
479 (1 - 1 / (Mathf.Abs(overStretching) * 0.55f / viewSize + 1)) * viewSize * Mathf.Sign(overStretching);
480
481 void Update()
482 {
483 var deltaTime = Time.unscaledDeltaTime;
484 var offset = CalculateOffset(currentPosition);
485
486 if (autoScrollState.Enable)
487 {
488 var position = 0f;
489
490 if (autoScrollState.Elastic)
491 {
492 position = Mathf.SmoothDamp(currentPosition, currentPosition + offset, ref velocity,
493 elasticity, Mathf.Infinity, deltaTime);
494
495 if (Mathf.Abs(velocity) < 0.01f)
496 {
497 position = Mathf.Clamp(Mathf.RoundToInt(position), 0, totalCount - 1);
498 velocity = 0f;
499 autoScrollState.Complete();
500 }
501 }
502 else
503 {
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));
508
509 if (Mathf.Approximately(alpha, 1f))
510 {
511 autoScrollState.Complete();
512 }
513 }
514
515 UpdatePosition(position);
516 }
517 else if (!(dragging || scrolling) && (!Mathf.Approximately(offset, 0f) || !Mathf.Approximately(velocity, 0f)))
518 {
519 var position = currentPosition;
520
521 if (movementType == MovementType.Elastic && !Mathf.Approximately(offset, 0f))
522 {
523 autoScrollState.Reset();
524 autoScrollState.Enable = true;
525 autoScrollState.Elastic = true;
526
527 UpdateSelection(Mathf.Clamp(Mathf.RoundToInt(position), 0, totalCount - 1));
528 }
529 else if (inertia)
530 {
531 velocity *= Mathf.Pow(decelerationRate, deltaTime);
532
533 if (Mathf.Abs(velocity) < 0.001f)
534 {
535 velocity = 0f;
536 }
537
538 position += velocity * deltaTime;
539
540 if (snap.Enable && Mathf.Abs(velocity) < snap.VelocityThreshold)
541 {
542 ScrollTo(Mathf.RoundToInt(currentPosition), snap.Duration, snap.Easing);
543 }
544 }
545 else
546 {
547 velocity = 0f;
548 }
549
550 if (!Mathf.Approximately(velocity, 0f))
551 {
552 if (movementType == MovementType.Clamped)
553 {
554 offset = CalculateOffset(position);
555 position += offset;
556
557 if (Mathf.Approximately(position, 0f) || Mathf.Approximately(position, totalCount - 1f))
558 {
559 velocity = 0f;
560 UpdateSelection(Mathf.RoundToInt(position));
561 }
562 }
563
564 UpdatePosition(position);
565 }
566 }
567
568 if (!autoScrollState.Enable && (dragging || scrolling) && inertia)
569 {
570 var newVelocity = (currentPosition - prevPosition) / deltaTime;
571 velocity = Mathf.Lerp(velocity, newVelocity, deltaTime * 10f);
572 }
573
574 prevPosition = currentPosition;
575 scrolling = false;
576 }
577
578 float CalculateMovementAmount(float sourcePosition, float destPosition)
579 {
580 if (movementType != MovementType.Unrestricted)
581 {
582 return Mathf.Clamp(destPosition, 0, totalCount - 1) - sourcePosition;
583 }
584
585 var amount = CircularPosition(destPosition, totalCount) - CircularPosition(sourcePosition, totalCount);
586
587 if (Mathf.Abs(amount) > totalCount * 0.5f)
588 {
589 amount = Mathf.Sign(-amount) * (totalCount - Mathf.Abs(amount));
590 }
591
592 return amount;
593 }
594
595 float CircularPosition(float p, int size) => size < 1 ? 0 : p < 0 ? size - 1 + (p + 1) % size : p % size;
596 }
597}
スクロール位置の制御を行うコンポーネント.
Definition: Scroller.cs:14
void OnValueChanged(Action< float > callback)
スクロール位置が変化したときのコールバックを設定します.
float Position
現在のスクロール位置.
Definition: Scroller.cs:128
MovementDirection GetMovementDirection(int sourceIndex, int destIndex)
sourceIndex から destIndex に移動する際の移動方向を返します. スクロール範囲が無制限に設定されている場合は, 最短距離の移動方向を返します.
Definition: Scroller.cs:297
ScrollDirection ScrollDirection
スクロール方向.
Definition: Scroller.cs:29
void SetTotalCount(int totalCount)
アイテムの総数を設定します.
bool Inertia
慣性を使用するかどうか. true を指定すると慣性が有効に, false を指定すると慣性が無効になります.
Definition: Scroller.cs:70
float ScrollSensitivity
ViewportSize の端から端まで Drag したときのスクロール位置の変化量.
Definition: Scroller.cs:59
void OnSelectionChanged(Action< int > callback)
選択位置が変化したときのコールバックを設定します.
void ScrollTo(float position, float duration, EasingFunction easingFunction, Action onComplete=null)
指定した位置まで移動します.
Definition: Scroller.cs:252
void JumpTo(int index)
指定したインデックスの位置までジャンプします.
Definition: Scroller.cs:279
MovementType MovementType
コンテンツがスクロール範囲を越えて移動するときに使用する挙動.
Definition: Scroller.cs:37
bool Draggable
Drag 入力を受付けるかどうか.
Definition: Scroller.cs:111
bool SnapEnabled
true ならスナップし, falseならスナップしません.
Definition: Scroller.cs:100
Scrollbar Scrollbar
スクロールバーのオブジェクト.
Definition: Scroller.cs:121
float Elasticity
コンテンツがスクロール範囲を越えて移動するときに使用する弾力性の量.
Definition: Scroller.cs:48
void ScrollTo(float position, float duration, Ease easing, Action onComplete=null)
指定した位置まで移動します.
float ViewportSize
ビューポートのサイズ.
Definition: Scroller.cs:20
float DecelerationRate
スクロールの減速率. Inertia が true の場合のみ有効です.
Definition: Scroller.cs:81
void ScrollTo(float position, float duration, Action onComplete=null)
指定した位置まで移動します.
delegate float EasingFunction(float t)
Credit Erdener Gonenc - @PixelEnvision.