Tanoda
ScrollPositionController.cs
Go to the documentation of this file.
1
3
4using System;
7
9{
10 public class ScrollPositionController : UIBehaviour, IBeginDragHandler, IEndDragHandler, IDragHandler
11 {
12 [SerializeField]
13 RectTransform viewport = null;
14 [SerializeField]
15 ScrollDirection directionOfRecognize = ScrollDirection.Vertical;
16 [SerializeField]
17 MovementType movementType = MovementType.Elastic;
18 [SerializeField]
19 float elasticity = 0.1f;
20 [SerializeField]
21 float scrollSensitivity = 1f;
22 [SerializeField]
23 bool inertia = true;
24 [SerializeField, Tooltip("Only used when inertia is enabled")]
25 float decelerationRate = 0.03f;
26 [SerializeField, Tooltip("Only used when inertia is enabled")]
27 Snap snap = new Snap { Enable = true, VelocityThreshold = 0.5f, Duration = 0.3f };
28 [SerializeField]
29 int dataCount;
30
31 readonly AutoScrollState autoScrollState = new AutoScrollState();
32
33 Action<float> onUpdatePosition;
34 Action<int> onItemSelected;
35
36 Vector2 pointerStartLocalPosition;
37 float dragStartScrollPosition;
38 float prevScrollPosition;
39 float currentScrollPosition;
40
41 bool dragging;
42 float velocity;
43
44 enum ScrollDirection
45 {
48 }
49
50 enum MovementType
51 {
52 Unrestricted = ScrollRect.MovementType.Unrestricted,
53 Elastic = ScrollRect.MovementType.Elastic,
54 Clamped = ScrollRect.MovementType.Clamped
55 }
56
57 [Serializable]
58 struct Snap
59 {
60 public bool Enable;
61 public float VelocityThreshold;
62 public float Duration;
63 }
64
65 class AutoScrollState
66 {
67 public bool Enable;
68 public bool Elastic;
69 public float Duration;
70 public float StartTime;
71 public float EndScrollPosition;
72
73 public void Reset()
74 {
75 Enable = false;
76 Elastic = false;
77 Duration = 0f;
78 StartTime = 0f;
79 EndScrollPosition = 0f;
80 }
81 }
82
83 public void OnUpdatePosition(Action<float> onUpdatePosition)
84 {
85 this.onUpdatePosition = onUpdatePosition;
86 }
87
88 public void OnItemSelected(Action<int> onItemSelected)
89 {
90 this.onItemSelected = onItemSelected;
91 }
92
93 public void SetDataCount(int dataCount)
94 {
95 this.dataCount = dataCount;
96 }
97
98 public void ScrollTo(int index, float duration)
99 {
100 autoScrollState.Reset();
101 autoScrollState.Enable = true;
102 autoScrollState.Duration = duration;
103 autoScrollState.StartTime = Time.unscaledTime;
104 autoScrollState.EndScrollPosition = CalculateDestinationIndex(index);
105
106 velocity = 0f;
107 dragStartScrollPosition = currentScrollPosition;
108
109 ItemSelected(Mathf.RoundToInt(GetCircularPosition(autoScrollState.EndScrollPosition, dataCount)));
110 }
111
112 public void JumpTo(int index)
113 {
114 autoScrollState.Reset();
115
116 velocity = 0f;
117 dragging = false;
118
119 index = CalculateDestinationIndex(index);
120
121 ItemSelected(index);
122 UpdatePosition(index);
123 }
124
125 void IBeginDragHandler.OnBeginDrag(PointerEventData eventData)
126 {
127 if (eventData.button != PointerEventData.InputButton.Left)
128 {
129 return;
130 }
131
132 pointerStartLocalPosition = Vector2.zero;
133 RectTransformUtility.ScreenPointToLocalPointInRectangle(
134 viewport,
135 eventData.position,
136 eventData.pressEventCamera,
137 out pointerStartLocalPosition);
138
139 dragStartScrollPosition = currentScrollPosition;
140 dragging = true;
141 autoScrollState.Reset();
142 }
143
144 void IDragHandler.OnDrag(PointerEventData eventData)
145 {
146 if (eventData.button != PointerEventData.InputButton.Left)
147 {
148 return;
149 }
150
151 if (!dragging)
152 {
153 return;
154 }
155
156 Vector2 localCursor;
157 if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
158 viewport,
159 eventData.position,
160 eventData.pressEventCamera,
161 out localCursor))
162 {
163 return;
164 }
165
166 var pointerDelta = localCursor - pointerStartLocalPosition;
167 var position = (directionOfRecognize == ScrollDirection.Horizontal ? -pointerDelta.x : pointerDelta.y)
168 / GetViewportSize()
169 * scrollSensitivity
170 + dragStartScrollPosition;
171
172 var offset = CalculateOffset(position);
173 position += offset;
174
175 if (movementType == MovementType.Elastic)
176 {
177 if (offset != 0f)
178 {
179 position -= RubberDelta(offset, scrollSensitivity);
180 }
181 }
182
183 UpdatePosition(position);
184 }
185
186 void IEndDragHandler.OnEndDrag(PointerEventData eventData)
187 {
188 if (eventData.button != PointerEventData.InputButton.Left)
189 {
190 return;
191 }
192
193 dragging = false;
194 }
195
196 float GetViewportSize()
197 {
198 return directionOfRecognize == ScrollDirection.Horizontal
199 ? viewport.rect.size.x
200 : viewport.rect.size.y;
201 }
202
203 float CalculateOffset(float position)
204 {
205 if (movementType == MovementType.Unrestricted)
206 {
207 return 0f;
208 }
209
210 if (position < 0f)
211 {
212 return -position;
213 }
214
215 if (position > dataCount - 1)
216 {
217 return dataCount - 1 - position;
218 }
219
220 return 0f;
221 }
222
223 void UpdatePosition(float position)
224 {
225 currentScrollPosition = position;
226
227 if (onUpdatePosition != null)
228 {
229 onUpdatePosition(currentScrollPosition);
230 }
231 }
232
233 void ItemSelected(int index)
234 {
235 if (onItemSelected != null)
236 {
237 onItemSelected(index);
238 }
239 }
240
241 float RubberDelta(float overStretching, float viewSize)
242 {
243 return (1 - (1 / ((Mathf.Abs(overStretching) * 0.55f / viewSize) + 1))) * viewSize * Mathf.Sign(overStretching);
244 }
245
246 void Update()
247 {
248 var deltaTime = Time.unscaledDeltaTime;
249 var offset = CalculateOffset(currentScrollPosition);
250
251 if (autoScrollState.Enable)
252 {
253 var position = 0f;
254
255 if (autoScrollState.Elastic)
256 {
257 var speed = velocity;
258 position = Mathf.SmoothDamp(currentScrollPosition, currentScrollPosition + offset, ref speed, elasticity, Mathf.Infinity, deltaTime);
259 velocity = speed;
260
261 if (Mathf.Abs(velocity) < 0.01f)
262 {
263 position = Mathf.Clamp(Mathf.RoundToInt(position), 0, dataCount - 1);
264 velocity = 0f;
265 autoScrollState.Reset();
266 }
267 }
268 else
269 {
270 var alpha = Mathf.Clamp01((Time.unscaledTime - autoScrollState.StartTime) / Mathf.Max(autoScrollState.Duration, float.Epsilon));
271 position = Mathf.Lerp(dragStartScrollPosition, autoScrollState.EndScrollPosition, EaseInOutCubic(0, 1, alpha));
272
273 if (Mathf.Approximately(alpha, 1f))
274 {
275 autoScrollState.Reset();
276 }
277 }
278
279 UpdatePosition(position);
280 }
281 else if (!dragging && (!Mathf.Approximately(offset, 0f) || !Mathf.Approximately(velocity, 0f)))
282 {
283 var position = currentScrollPosition;
284
285 if (movementType == MovementType.Elastic && !Mathf.Approximately(offset, 0f))
286 {
287 autoScrollState.Reset();
288 autoScrollState.Enable = true;
289 autoScrollState.Elastic = true;
290
291 ItemSelected(Mathf.Clamp(Mathf.RoundToInt(position), 0, dataCount - 1));
292 }
293 else if (inertia)
294 {
295 velocity *= Mathf.Pow(decelerationRate, deltaTime);
296
297 if (Mathf.Abs(velocity) < 0.001f)
298 {
299 velocity = 0f;
300 }
301
302 position += velocity * deltaTime;
303
304 if (snap.Enable && Mathf.Abs(velocity) < snap.VelocityThreshold)
305 {
306 ScrollTo(Mathf.RoundToInt(currentScrollPosition), snap.Duration);
307 }
308 }
309 else
310 {
311 velocity = 0f;
312 }
313
314 if (!Mathf.Approximately(velocity, 0f))
315 {
316 if (movementType == MovementType.Clamped)
317 {
318 offset = CalculateOffset(position);
319 position += offset;
320
321 if (Mathf.Approximately(position, 0f) || Mathf.Approximately(position, dataCount - 1f))
322 {
323 velocity = 0f;
324 ItemSelected(Mathf.RoundToInt(position));
325 }
326 }
327
328 UpdatePosition(position);
329 }
330 }
331
332 if (!autoScrollState.Enable && dragging && inertia)
333 {
334 var newVelocity = (currentScrollPosition - prevScrollPosition) / deltaTime;
335 velocity = Mathf.Lerp(velocity, newVelocity, deltaTime * 10f);
336 }
337
338 if (currentScrollPosition != prevScrollPosition)
339 {
340 prevScrollPosition = currentScrollPosition;
341 }
342 }
343
344 int CalculateDestinationIndex(int index)
345 {
346 return movementType == MovementType.Unrestricted
347 ? CalculateClosestIndex(index)
348 : Mathf.Clamp(index, 0, dataCount - 1);
349 }
350
351 int CalculateClosestIndex(int index)
352 {
353 var diff = GetCircularPosition(index, dataCount)
354 - GetCircularPosition(currentScrollPosition, dataCount);
355
356 if (Mathf.Abs(diff) > dataCount * 0.5f)
357 {
358 diff = Mathf.Sign(-diff) * (dataCount - Mathf.Abs(diff));
359 }
360
361 return Mathf.RoundToInt(diff + currentScrollPosition);
362 }
363
364 float GetCircularPosition(float position, int length)
365 {
366 return position < 0 ? length - 1 + (position + 1) % length : position % length;
367 }
368
369 float EaseInOutCubic(float start, float end, float value)
370 {
371 value /= 0.5f;
372 end -= start;
373
374 if (value < 1f)
375 {
376 return end * 0.5f * value * value * value + start;
377 }
378
379 value -= 2f;
380 return end * 0.5f * (value * value * value + 2f) + start;
381 }
382 }
383}
void OnUpdatePosition(Action< float > onUpdatePosition)
Credit Erdener Gonenc - @PixelEnvision.