1
This repository has been archived on 2025-03-15. You can view files and clone it, but cannot push or open issues or pull requests.

805 lines
25 KiB
C#
Raw Normal View History

2025-03-15 20:02:21 +01:00
// Copyright 2016 Google Inc. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.Events;
public class PagedScrollRect : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
#if UNITY_HAS_GOOGLEVR && (UNITY_ANDROID || UNITY_EDITOR)
/// Allows you to control how sensitive the paged
/// Scroll rect is to events from the gvr controller.
[Tooltip("The sensitivity to gvr touch events.")]
public float ScrollSensitivity = 1.0f;
/// The speed that the scroll rect snaps to a page
/// When the gvr touchpad is released.
[Tooltip("The speed that the rect snaps to a page.")]
public float SnapSpeed = 6.0f;
/// The index of the page to start the scroll rect on.
/// Will changes the local position of the transform on Start.
[Tooltip("The index of the page to start the scroll rect on.")]
public int StartPage = 0;
/// If true, the user can scroll continuously in any direction
/// and the pages will loop.
[Tooltip("Determines if the pages loop when scrolling.")]
public bool loop = false;
/// If true, the user must be pointing at the scroll rect with the controller
/// to be able to scroll.
[Tooltip("Determines whether the user must be pointing at the scroll rect with the controller to be able to scroll.")]
public bool onlyScrollWhenPointing = true;
/// Determines how many extra pages are shown on each side of
/// the scroll rect is shown when the scroll view is not moving.
/// If set to 0, only the activePage is shown.
/// If set to 1, an extra page is shown on each side.
[Tooltip("Determines how many extra pages are shown on each side of the scroll rect when the scroll view is not moving.")]
public int numExtraPagesShown = 0;
/// This is used to determine if the leftmost and rightmost page
/// should be shown when the scroll rect is not moving.
/// If numExtraPagesShown is zero, then this is the previous and next page.
[Tooltip("Determines if the last extra page should be shown when the scroll rect is at rest.")]
public bool showNextPagesAtRest = false;
/// This is used to determine if the tiles will be interactable
/// regardless of the state of the paged scroll rect. If false,
/// then tiles will not be interactable if they aren't on the active page.
[Tooltip("Determines if the tiles should always be interactable.")]
public bool shouldTilesAlwaysBeInteractable = true;
[Tooltip("Determines if scrolling is enabled.")]
public bool scrollingEnabled = true;
/// A callback to indicate that the active page has changed.
public delegate void ActivePageChangedDelegate(RectTransform activePage,int activePageIndex,RectTransform previousPage,int previousPageIndex);
/// Called whenever the active page changes.
public event ActivePageChangedDelegate OnActivePageChanged;
public UnityEvent OnSwipeLeft;
public UnityEvent OnSwipeRight;
public UnityEvent OnSnapClosest;
/// Interface used as the data source for the content in this scroll rect.
private IPageProvider pageProvider;
/// Interface used to implement visual effect for scrolling this scroll rect.
private BaseScrollEffect[] scrollEffects;
/// Keep track of the last few frames of touch positions, and the initial position
private bool isTrackingTouches = false;
private Vector2 initialTouchPos;
private Vector2 previousTouchPos;
private float previousTouchTimestamp;
private Vector2 overallVelocity;
private bool isScrolling = false;
private bool isPointerHovering = false;
private float scrollOffset = float.MaxValue;
/// Lerp towards the target scroll offset to smooth the motion.
private float targetScrollOffset;
// True is the scroll offset is overridden by an external source.
private bool isScrollOffsetOverridden = false;
private RectTransform activePage;
private Coroutine activeSnapCoroutine;
/// Keep track of the currently visible pages
private Dictionary<int, RectTransform> indexToVisiblePage = new Dictionary<int, RectTransform>();
private Dictionary<RectTransform, int> visiblePageToIndex = new Dictionary<RectTransform, int>();
/// Store the visible pages in a separate list
/// so that we have a collection that we can remove elements from while iterating through it.
private List<RectTransform> visiblePages = new List<RectTransform>();
/// Touch Delta is required to be higher than
/// the click threshold to avoid detecting clicks as swipes.
private const float kClickThreshold = 0.15f;
/// overallVelocity must be greater than the swipe threshold
/// to detect a swipe.
private const float kSwipeThreshold = 0.75f;
/// The difference between two timestamps must be greater than
/// this value to be considered different. Helps reduce noise.
private const float kTimestampDeltaThreshold = 1.0e-7f;
/// If the difference between the target scroll offset
/// and the current scroll offset is greater than the moving threshold,
/// then we are considered to be moving. This coeff is multiplied by the spacing
/// to get the moving threshold.
private const float kIsMovingThresholdCoeff = 0.1f;
// Snap the scroll offset to the target scroll offset when the delta between the two
// becomes smaller than kSnapScrollOffsetThresholdCoeff * pageProvider.GetSpacing().
private const float kSnapScrollOffsetThresholdCoeff = 0.002f;
/// Values used for low-pass-filter to improve the accuracy of
/// our tracked velocity.
private const float kCuttoffHz = 10.0f;
private const float kRc = (float) (1.0 / (2.0 * Mathf.PI * kCuttoffHz));
private enum SnapDirection {
Left,
Right,
Closest
}
/// The active page in the scroll rect.
public RectTransform ActivePage {
get {
return activePage;
}
private set {
if (value == ActivePage) {
return;
}
RectTransform previousPage = ActivePage;
int previousPageIndex = ActivePageIndex;
activePage = value;
activePage.SetAsLastSibling();
if (OnActivePageChanged != null) {
OnActivePageChanged(ActivePage, ActivePageIndex, previousPage, previousPageIndex);
}
}
}
/// The index of the active page.
/// If there is no active page, returns -1.
public int ActivePageIndex {
get {
if (ActivePage != null && visiblePageToIndex.ContainsKey(ActivePage)) {
int index = PageIndexFromRealIndex(ActiveRealIndex);
return index;
}
return -1;
}
}
/// If loop is set to false, this will always be the same as the ActivePageIndex
/// Otherwise, this will be the index the player is looking at, including all
/// of the aditional loops that the player has swiped through.
///
/// i.e.
/// If the user has swiped to the right 8 times and there are 5 pages:
/// ActivePageIndex will return 3.
/// ActiveRealIndex will return 8.
public int ActiveRealIndex {
get {
if (ActivePage != null && visiblePageToIndex.ContainsKey(ActivePage)) {
int index = visiblePageToIndex[ActivePage];
return index;
}
return -1;
}
}
/// The number of pages in the scroll rect.
/// If there is no pageProvider, returns -1.
public int PageCount {
get {
if (pageProvider == null) {
return -1;
}
return pageProvider.GetNumberOfPages();
}
}
/// The spacing between pages in the local coordinate system of this PagedScrollRect.
public float PageSpacing {
get {
if (pageProvider == null) {
return 0.0f;
}
return pageProvider.GetSpacing();
}
}
/// Returns the amount that the
/// rect has been scrolled in local coordinates.
public float ScrollOffset {
get {
return scrollOffset;
}
private set {
if (value != ScrollOffset) {
scrollOffset = value;
OnScrolled();
}
}
}
/// Returns true if scrolling is currently allowed
public bool CanScroll {
get {
return scrollingEnabled && (isPointerHovering || !onlyScrollWhenPointing);
}
}
/// Returns true if the scroll region is currently moving.
/// This is the case if the player is actively scrolling, and
/// when the scroll region is snapping to a page.
public bool IsMoving {
get {
if (isScrolling) {
return true;
}
float moveDistance = CurrentMoveDistance;
if (moveDistance > GetMovingThreshold()) {
return true;
}
return false;
}
}
/// Returns the distance between the targetScrollOffset and the ScrollOffset.
/// This can be used to determine how quickly the PagedScrollRect is scrolling.
public float CurrentMoveDistance {
get {
return Mathf.Abs(targetScrollOffset - ScrollOffset);
}
}
/// <summary>
/// Snaps the scroll rect to a particular page.
/// </summary>
/// <param name="index"> the index of the page to snap to.</param>
/// <param name="immediate">If set to <c>true</c> then snapping happens instantly,
/// otherwise it is animated.</param>
public void SnapToPage(int index, bool immediate = false, bool supressEvents=false) {
if (!loop && (index < 0 || index >= PageCount)) {
Debug.LogWarning("Attempting to snap to non-existant page: " + index);
return;
}
if (immediate) {
float offset = OffsetFromIndex(index);
targetScrollOffset = offset;
ScrollOffset = offset;
} else {
activeSnapCoroutine = StartCoroutine(SnapToPageCoroutine(index));
}
if (!supressEvents) {
int currentIndex = ActiveRealIndex;
if (index < currentIndex) {
OnSwipeLeft.Invoke();
} else {
OnSwipeRight.Invoke();
}
}
}
/// <summary>
/// Snaps the scroll rect to a particular page. Only works for pages that are
/// currently visible.
/// </summary>
/// <param name="visiblePage"> The page to snap to.</param>
/// <param name="immediate">If set to <c>true</c> then snapping happens instantly,
/// otherwise it is animated.</param>
public void SnapToVisiblePage(RectTransform visiblePage, bool immediate = false) {
if (visiblePage == null) {
Debug.LogWarning("visiblePage is null, cannot snap to it.");
return;
}
if (!visiblePageToIndex.ContainsKey(visiblePage)) {
Debug.LogWarning(visiblePage.name + " is not a visible page, cannot snap to it.");
return;
}
int index = visiblePageToIndex[visiblePage];
SnapToPage(index, immediate);
}
/// <summary>
/// Explicitly set the scroll offset of the PagedScrollRect. Useful when you need to control
/// the scroll offset from an external source (I.E. a scroll bar). When unset, the PagedScrollRect
/// will snap to the closest page.
/// </summary>
/// <param name="offsetOverride"> The scroll offset to set.</param>
/// <param name="immediate">If set to <c>true</c> then the scroll offset is set instantly,
/// otherwise it is animated.</param>
public void SetScrollOffsetOverride(float? offsetOverride, bool immediate = false) {
bool newIsScrollOffsetOverridden = offsetOverride != null;
// If we didn't previously have an offset override, stop scrolling.
if (!isScrollOffsetOverridden && newIsScrollOffsetOverridden) {
StopScrolling(false);
StopTouchTracking();
}
if (newIsScrollOffsetOverridden) {
targetScrollOffset = offsetOverride.Value;
if (immediate) {
ScrollOffset = targetScrollOffset;
}
} else if (isScrollOffsetOverridden) {
SnapToPageInDirection(SnapDirection.Closest);
}
isScrollOffsetOverridden = newIsScrollOffsetOverridden;
}
/// Removes all pages and goes back to the starting page.
/// Call this function if the PageProvider changes.
public void Reset() {
foreach (KeyValuePair<RectTransform, int> pair in visiblePageToIndex) {
pageProvider.RemovePage(pair.Value, pair.Key);
}
visiblePageToIndex.Clear();
indexToVisiblePage.Clear();
scrollOffset = float.MaxValue;
targetScrollOffset = 0.0f;
SetScrollOffsetOverride(null, true);
ScrollOffset = targetScrollOffset;
}
void OnDisable() {
if (pageProvider == null) {
return;
}
SetScrollOffsetOverride(null, true);
StopScrolling(true, true);
StopTouchTracking();
}
void Start() {
pageProvider = GetComponent<IPageProvider>();
if (pageProvider == null) {
throw new System.NullReferenceException(
"PagedScrollRect is missing an IPageProvider. " +
"Please look at IPageProvider.cs for details.");
}
scrollEffects = GetComponents<BaseScrollEffect>();
if (scrollEffects.Length == 0) {
Debug.LogWarning(
"PagedScrollRect does not have any BaseScrollEffects. " +
"Adding defaults.");
gameObject.AddComponent<TranslateScrollEffect>();
gameObject.AddComponent<FadeScrollEffect>();
scrollEffects = GetComponents<BaseScrollEffect>();
}
// Immediately snap to the starting page.
SnapToPage(StartPage, true, true);
}
#endif // UNITY_HAS_GOOGLEVR && (UNITY_ANDROID || UNITY_EDITOR)
public void OnPointerEnter(PointerEventData eventData) {
#if UNITY_HAS_GOOGLEVR && (UNITY_ANDROID || UNITY_EDITOR)
if (onlyScrollWhenPointing) {
isPointerHovering = true;
}
#endif // UNITY_HAS_GOOGLEVR && (UNITY_ANDROID || UNITY_EDITOR)
}
public void OnPointerExit(PointerEventData eventData) {
#if UNITY_HAS_GOOGLEVR && (UNITY_ANDROID || UNITY_EDITOR)
if (onlyScrollWhenPointing) {
isPointerHovering = false;
}
#endif // UNITY_HAS_GOOGLEVR && (UNITY_ANDROID || UNITY_EDITOR)
}
#if UNITY_HAS_GOOGLEVR && (UNITY_ANDROID || UNITY_EDITOR)
void Update() {
if (isScrollOffsetOverridden) {
LerpTowardsOffset(targetScrollOffset);
return;
}
if (!CanScroll) {
StopScrolling();
StopTouchTracking();
return;
}
/// Don't start scrolling until the touch pos has moved.
/// This is to prevent scrolling when the user intended to click.
if (!isScrolling && GvrController.IsTouching) {
if (!isTrackingTouches) {
StartTouchTracking();
} else {
Vector2 touchDelta = GvrController.TouchPos - initialTouchPos;
float xDeltaMagnitude = Mathf.Abs(touchDelta.x);
float yDeltaMagnitude = Mathf.Abs(touchDelta.y);
if (xDeltaMagnitude > kClickThreshold && xDeltaMagnitude > yDeltaMagnitude) {
StartScrolling();
}
}
}
if (isScrolling && GvrController.IsTouching) {
Vector2 touchDelta = GvrController.TouchPos - previousTouchPos;
if (Mathf.Abs(touchDelta.x) > 0) {
// Translate directly based on the touch value.
float spacingCoeff = -pageProvider.GetSpacing();
targetScrollOffset += touchDelta.x * spacingCoeff * ScrollSensitivity;
}
LerpTowardsOffset(targetScrollOffset);
}
if (GvrController.TouchUp) {
StopScrolling();
StopTouchTracking();
}
if (isTrackingTouches && GvrController.IsTouching) {
TrackTouch();
}
}
private void StartScrolling() {
if (isScrolling) {
return;
}
targetScrollOffset = ScrollOffset;
if (activeSnapCoroutine != null) {
StopCoroutine(activeSnapCoroutine);
}
isScrolling = true;
}
private void StopScrolling(bool snapToPage = true, bool snapImmediate = false) {
if (!isScrolling) {
return;
}
if (snapToPage) {
if (overallVelocity.x > kSwipeThreshold) {
/// If I was swiping to the right.
SnapToPageInDirection(SnapDirection.Left, snapImmediate);
} else if (overallVelocity.x < -kSwipeThreshold) {
/// If I was swiping to the left.
SnapToPageInDirection(SnapDirection.Right, snapImmediate);
} else {
/// If the touch delta is not big enough, just snap to the closest page.
SnapToPageInDirection(SnapDirection.Closest, snapImmediate);
}
}
isScrolling = false;
}
private void StartTouchTracking() {
isTrackingTouches = true;
initialTouchPos = GvrController.TouchPos;
previousTouchPos = initialTouchPos;
previousTouchTimestamp = Time.time;
overallVelocity = Vector2.zero;
}
private void StopTouchTracking() {
if (!isTrackingTouches) {
return;
}
isTrackingTouches = false;
initialTouchPos = Vector2.zero;
previousTouchPos = Vector2.zero;
previousTouchTimestamp = 0.0f;
overallVelocity = Vector2.zero;
}
private void TrackTouch() {
if (!isTrackingTouches) {
Debug.LogWarning("StartTouchTracking must be called before touches can be tracked.");
return;
}
float timeElapsedSeconds = (Time.time - previousTouchTimestamp);
// If the timestamp has not changed, do not update.
if (timeElapsedSeconds < kTimestampDeltaThreshold) {
return;
}
// Update velocity
Vector2 touchDelta = GvrController.TouchPos - previousTouchPos;
Vector2 velocity = touchDelta / timeElapsedSeconds;
float weight = timeElapsedSeconds / (kRc + timeElapsedSeconds);
overallVelocity = Vector2.Lerp(overallVelocity, velocity, weight);
// Update the previous touch
previousTouchPos = GvrController.TouchPos;
previousTouchTimestamp = Time.time;
}
private void SnapToPageInDirection(SnapDirection snapDirection, bool immediate = false) {
int closestPageIndex = 0;
bool didClamp;
float directionBias = pageProvider.GetSpacing() * 0.55f;
switch (snapDirection) {
case SnapDirection.Right:
float rightOffset = targetScrollOffset + directionBias;
closestPageIndex = IndexFromOffset(rightOffset, out didClamp);
if (!didClamp) {
OnSwipeRight.Invoke();
}
break;
case SnapDirection.Left:
float leftOffset = targetScrollOffset - directionBias;
closestPageIndex = IndexFromOffset(leftOffset, out didClamp);
if (!didClamp) {
OnSwipeLeft.Invoke();
}
break;
case SnapDirection.Closest:
closestPageIndex = IndexFromOffset(targetScrollOffset, out didClamp);
OnSnapClosest.Invoke();
break;
default:
throw new System.Exception("Invalid SnapDirection: " + snapDirection);
}
/// If we found a page in that direction.
SnapToPage(closestPageIndex, immediate, true);
}
private void OnScrolled() {
bool didClamp;
int newActiveIndex = IndexFromOffset(scrollOffset, out didClamp);
/// Make sure to update the active page
if (IsPageVisible(newActiveIndex)) {
ActivePage = indexToVisiblePage[newActiveIndex];
}
/// Update existing pages
for (int i = visiblePages.Count - 1; i >= 0; i--) {
RectTransform page = visiblePages[i];
/// If this object doesn't have a RectTransform it isn't a valid page.
/// Not necessarily an issue, could be something else.
if (page == null) {
continue;
}
bool isVisiblePage = visiblePageToIndex.ContainsKey(page);
/// This accounts for the case where not all of the children
/// are visible pages. Helpful to keep the ScrollRect flexible
/// and for potential pooling implementations.
if (!isVisiblePage) {
continue;
}
int pageIndex = visiblePageToIndex[page];
if (ShouldShowIndexForOffset(ScrollOffset, pageIndex)) {
ApplyScrollEffects(page);
} else {
RemovePage(page);
}
}
/// Add active page if it doesn't already exist
if (!indexToVisiblePage.ContainsKey(newActiveIndex)) {
AddPage(newActiveIndex, true);
}
/// Add additional pages to the left of the active page.
int nextIndex = newActiveIndex - 1;
while (true) {
if (!loop && nextIndex < 0) {
break;
}
if (IsPageVisible(nextIndex)) {
nextIndex--;
continue;
}
if (!AddPageIfNecessary(nextIndex)) {
break;
}
nextIndex--;
}
/// Add additional pages to the right of the active page.
nextIndex = newActiveIndex + 1;
while (true) {
if (!loop && nextIndex >= pageProvider.GetNumberOfPages()) {
break;
}
if (IsPageVisible(nextIndex)) {
nextIndex++;
continue;
}
if (!AddPageIfNecessary(nextIndex)) {
break;
}
nextIndex++;
}
}
private IEnumerator SnapToPageCoroutine(int index) {
targetScrollOffset = OffsetFromIndex(index);
while (true) {
if (LerpTowardsOffset(targetScrollOffset)) {
yield return null;
} else {
break;
}
}
}
/// Returns false if the ScrollOffset is already the same as the targetOffset.
private bool LerpTowardsOffset(float targetOffset) {
if (ScrollOffset == targetOffset) {
return false;
}
float diff = Mathf.Abs(ScrollOffset - targetScrollOffset);
float threshold = pageProvider.GetSpacing() * kSnapScrollOffsetThresholdCoeff;
if (diff < threshold) {
ScrollOffset = targetScrollOffset;
} else {
ScrollOffset = Mathf.Lerp(ScrollOffset, targetOffset, SnapSpeed * Time.deltaTime);
}
ScrollOffset = Mathf.Lerp(ScrollOffset, targetOffset, SnapSpeed * Time.deltaTime);
return true;
}
private float OffsetFromIndex(int index) {
return index * pageProvider.GetSpacing();
}
private int IndexFromOffset(float offset, out bool didClamp) {
int index = Mathf.RoundToInt(offset / pageProvider.GetSpacing());
didClamp = false;
if (!loop) {
int clampedIndex = Mathf.Clamp(index, 0, pageProvider.GetNumberOfPages() - 1);
didClamp = clampedIndex != index;
return clampedIndex;
}
return index;
}
private int PageIndexFromRealIndex(int index) {
int loopAmount = Mathf.FloorToInt((float)index / (float)PageCount);
index = index - (loopAmount * PageCount);
return index;
}
private bool ShouldShowIndexForOffset(float offset, int index) {
float indexOffset = OffsetFromIndex(index);
float diff = Mathf.RoundToInt(indexOffset - offset);
float absoluteDiff = Mathf.Abs(diff);
int pagesShown = 1 + numExtraPagesShown;
if (showNextPagesAtRest) {
return absoluteDiff <= pageProvider.GetSpacing() * pagesShown;
} else {
return absoluteDiff < pageProvider.GetSpacing() * pagesShown;
}
}
private bool IsPageVisible(int index) {
return indexToVisiblePage.ContainsKey(index);
}
private bool AddPageIfNecessary(int index) {
if (ShouldShowIndexForOffset(scrollOffset, index)) {
AddPage(index);
return true;
}
return false;
}
private void AddPage(int index, bool isActivePage=false) {
int pageIndex = PageIndexFromRealIndex(index);
RectTransform page = pageProvider.ProvidePage(pageIndex);
page.SetParent(transform, false);
indexToVisiblePage[index] = page;
visiblePageToIndex[page] = index;
visiblePages.Add(page);
if (isActivePage) {
ActivePage = page;
}
ApplyScrollEffects(page);
if (activePage) {
activePage.SetAsLastSibling();
}
}
private void RemovePage(RectTransform page) {
int index = visiblePageToIndex[page];
int pageIndex = PageIndexFromRealIndex(index);
visiblePageToIndex.Remove(page);
indexToVisiblePage.Remove(index);
// This could be slow if numExtraPagesShown is set to a large number.
// Considering having numExtraPagesShown set above 1 is against UX recommendations,
// this should be all right.
visiblePages.Remove(page);
pageProvider.RemovePage(pageIndex, page);
}
private void ApplyScrollEffects(RectTransform page) {
int index = visiblePageToIndex[page];
float offset = OffsetFromIndex(index);
bool isActivePage = page == activePage;
bool isInteractable = shouldTilesAlwaysBeInteractable || isActivePage;
BaseScrollEffect.UpdateData updateData = new BaseScrollEffect.UpdateData();
updateData.page = page;
updateData.pageIndex = index;
updateData.pageCount = PageCount;
updateData.pageOffset = offset;
updateData.scrollOffset = ScrollOffset;
updateData.spacing = pageProvider.GetSpacing();
updateData.looping = loop;
updateData.isInteractable = isInteractable;
updateData.moveDistance = CurrentMoveDistance;
for (int i = 0; i < scrollEffects.Length; i++) {
BaseScrollEffect scrollEffect = scrollEffects[i];
if (scrollEffect.enabled) {
scrollEffect.ApplyEffect(updateData);
}
}
}
private float GetMovingThreshold() {
return pageProvider.GetSpacing() * kIsMovingThresholdCoeff;
}
#endif // UNITY_HAS_GOOGLEVR &&(UNITY_ANDROID || UNITY_EDITOR
}