// 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 indexToVisiblePage = new Dictionary(); private Dictionary visiblePageToIndex = new Dictionary(); /// 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 visiblePages = new List(); /// 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); } } /// /// Snaps the scroll rect to a particular page. /// /// the index of the page to snap to. /// If set to true then snapping happens instantly, /// otherwise it is animated. 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(); } } } /// /// Snaps the scroll rect to a particular page. Only works for pages that are /// currently visible. /// /// The page to snap to. /// If set to true then snapping happens instantly, /// otherwise it is animated. 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); } /// /// 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. /// /// The scroll offset to set. /// If set to true then the scroll offset is set instantly, /// otherwise it is animated. 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 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(); if (pageProvider == null) { throw new System.NullReferenceException( "PagedScrollRect is missing an IPageProvider. " + "Please look at IPageProvider.cs for details."); } scrollEffects = GetComponents(); if (scrollEffects.Length == 0) { Debug.LogWarning( "PagedScrollRect does not have any BaseScrollEffects. " + "Adding defaults."); gameObject.AddComponent(); gameObject.AddComponent(); scrollEffects = GetComponents(); } // 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 }