/// Credit Beka Westberg /// Sourced from - https://bitbucket.org/UnityUIExtensions/unity-ui-extensions/pull-requests/28 /// Updated by SimonDarksideJ - Added some exception management and a SetNewItems to replace the content programmatically using System.Collections; using System.Collections.Generic; using UnityEngine.EventSystems; using UnityEngine.Events; using System; namespace UnityEngine.UI.Extensions { [ExecuteInEditMode] [RequireComponent(typeof(ScrollRect))] [AddComponentMenu("UI/Extensions/ContentSnapScrollHorizontal")] public class ContentScrollSnapHorizontal : MonoBehaviour, IBeginDragHandler, IEndDragHandler { [Serializable] public class StartMovementEvent : UnityEvent { } [Serializable] public class CurrentItemChangeEvent : UnityEvent<int> { } [Serializable] public class FoundItemToSnapToEvent : UnityEvent<int> { } [Serializable] public class SnappedToItemEvent : UnityEvent<int> { } public bool ignoreInactiveItems = true; public MoveInfo startInfo = new MoveInfo(MoveInfo.IndexType.positionIndex, 0); public GameObject prevButton; public GameObject nextButton; public GameObject pagination; [Tooltip("The velocity below which the scroll rect will start to snap")] public int snappingVelocityThreshold = 50; [Header("Paging Info")] [Tooltip("Should the pagination & buttons jump or lerp to the items")] public bool jumpToItem = false; [Tooltip("The time it will take for the pagination or buttons to move between items")] public float lerpTime = .3f; [Header("Events")] [SerializeField] [Tooltip("Event is triggered whenever the scroll rect starts to move, even when triggered programmatically")] private StartMovementEvent m_StartMovementEvent = new StartMovementEvent(); public StartMovementEvent MovementStarted { get { return m_StartMovementEvent; } set { m_StartMovementEvent = value; } } [SerializeField] [Tooltip("Event is triggered whenever the closest item to the center of the scrollrect changes")] private CurrentItemChangeEvent m_CurrentItemChangeEvent = new CurrentItemChangeEvent(); public CurrentItemChangeEvent CurrentItemChanged { get { return m_CurrentItemChangeEvent; } set { m_CurrentItemChangeEvent = value; } } [SerializeField] [Tooltip("Event is triggered when the ContentSnapScroll decides which item it is going to snap to. Returns the index of the closest position.")] private FoundItemToSnapToEvent m_FoundItemToSnapToEvent = new FoundItemToSnapToEvent(); public FoundItemToSnapToEvent ItemFoundToSnap { get { return m_FoundItemToSnapToEvent; } set { m_FoundItemToSnapToEvent = value; } } [SerializeField] [Tooltip("Event is triggered when we finally settle on an element. Returns the index of the item's position.")] private SnappedToItemEvent m_SnappedToItemEvent = new SnappedToItemEvent(); public SnappedToItemEvent ItemSnappedTo { get { return m_SnappedToItemEvent; } set { m_SnappedToItemEvent = value; } } private ScrollRect scrollRect = null; private RectTransform scrollRectTransform = null; private RectTransform contentTransform = null; private List<Vector3> contentPositions = new List<Vector3>(); private Vector3 lerpTarget = Vector3.zero; private float totalScrollableWidth = 0; private DrivenRectTransformTracker tracker ; private float mLerpTime = 0; private int _closestItem = 0; private bool mSliding = false; private bool mLerping = false; private bool ContentIsHorizonalLayoutGroup { get { return contentTransform.GetComponent<HorizontalLayoutGroup>() != null; } } #region Public Info /// <summary> /// Returns if the SnapScroll is moving /// </summary> public bool Moving { get { return Sliding || Lerping; } } /// <summary> /// Returns if the SnapScroll is moving because of a touch /// </summary> public bool Sliding { get { return mSliding; } } /// <summary> /// Returns if the SnapScroll is moving programmatically /// </summary> public bool Lerping { get { return mLerping; } } /// <summary> /// Returns the closest item's index /// *Note this is zero based, and based on position not child order /// </summary> public int ClosestItemIndex { get { return contentPositions.IndexOf(FindClosestFrom(contentTransform.localPosition)); } } /// <summary> /// Returns the lerpTarget's index /// *Note this is zero-based, and based on position not child order /// </summary> public int LerpTargetIndex { get { return contentPositions.IndexOf(lerpTarget); } } #endregion #region Setup private void Awake() { scrollRect = GetComponent<ScrollRect>(); scrollRectTransform = (RectTransform) scrollRect.transform; contentTransform = scrollRect.content; if (nextButton) nextButton.GetComponent<Button>().onClick.AddListener(() => { NextItem(); }); if (prevButton) prevButton.GetComponent<Button>().onClick.AddListener(() => { PreviousItem(); }); if (IsScrollRectAvailable) { SetupDrivenTransforms(); SetupSnapScroll(); scrollRect.horizontalNormalizedPosition = 0; _closestItem = 0; GoTo(startInfo); } } public void SetNewItems(ref List<Transform> newItems) { if (scrollRect && contentTransform) { for (int i = scrollRect.content.childCount - 1; i >= 0; i--) { Transform child = contentTransform.GetChild(i); child.SetParent(null); GameObject.DestroyImmediate(child.gameObject); } foreach (Transform item in newItems) { GameObject newItem = item.gameObject; if (newItem.IsPrefab()) { newItem = Instantiate(item.gameObject, contentTransform); } else { newItem.transform.SetParent(contentTransform); } } SetupDrivenTransforms(); SetupSnapScroll(); scrollRect.horizontalNormalizedPosition = 0; _closestItem = 0; GoTo(startInfo); } } private bool IsScrollRectAvailable { get { if (scrollRect && contentTransform && contentTransform.childCount > 0) { return true; } return false; } } private void OnDisable() { tracker.Clear(); } private void SetupDrivenTransforms() { tracker = new DrivenRectTransformTracker(); tracker.Clear(); //So that we can calculate everything correctly foreach (RectTransform child in contentTransform) { tracker.Add(this, child, DrivenTransformProperties.Anchors); child.anchorMax = new Vector2(0, 1); child.anchorMin = new Vector2(0, 1); } } private void SetupSnapScroll() { if (ContentIsHorizonalLayoutGroup) { //because you can't get the anchored positions of UI elements //when they are in a layout group (as far as I could tell) SetupWithHorizontalLayoutGroup(); } else { SetupWithCalculatedSpacing(); } } private void SetupWithHorizontalLayoutGroup() { HorizontalLayoutGroup horizLayoutGroup = contentTransform.GetComponent<HorizontalLayoutGroup>(); float childTotalWidths = 0; int activeChildren = 0; for (int i = 0; i < contentTransform.childCount; i++) { if (!ignoreInactiveItems || contentTransform.GetChild(i).gameObject.activeInHierarchy) { childTotalWidths += ((RectTransform)contentTransform.GetChild(i)).sizeDelta.x; activeChildren++; } } float spacingTotal = (activeChildren - 1) * horizLayoutGroup.spacing; float totalWidth = childTotalWidths + spacingTotal + horizLayoutGroup.padding.left + horizLayoutGroup.padding.right; contentTransform.sizeDelta = new Vector2(totalWidth, contentTransform.sizeDelta.y); float scrollRectWidth = Mathf.Min(((RectTransform)contentTransform.GetChild(0)).sizeDelta.x, ((RectTransform)contentTransform.GetChild(contentTransform.childCount - 1)).sizeDelta.x); /*---If the scroll view is set to stretch width this breaks stuff---*/ scrollRectTransform.sizeDelta = new Vector2(scrollRectWidth, scrollRectTransform.sizeDelta.y); contentPositions = new List<Vector3>(); float widthOfScrollRect = scrollRectTransform.sizeDelta.x; totalScrollableWidth = totalWidth - widthOfScrollRect; float checkedChildrenTotalWidths = horizLayoutGroup.padding.left; int activeChildrenBeforeSelf = 0; for (int i = 0; i < contentTransform.childCount; i++) { if (!ignoreInactiveItems || contentTransform.GetChild(i).gameObject.activeInHierarchy) { float widthOfSelf = ((RectTransform)contentTransform.GetChild(i)).sizeDelta.x; float offset = checkedChildrenTotalWidths + (horizLayoutGroup.spacing * activeChildrenBeforeSelf) + ((widthOfSelf - widthOfScrollRect) / 2); scrollRect.horizontalNormalizedPosition = offset / totalScrollableWidth; contentPositions.Add(contentTransform.localPosition); checkedChildrenTotalWidths += widthOfSelf; activeChildrenBeforeSelf++; } } } private void SetupWithCalculatedSpacing() { //we need them in order from left to right for pagination & buttons & our scrollRectWidth List<RectTransform> childrenFromLeftToRight = new List<RectTransform>(); for (int i = 0; i < contentTransform.childCount; i++) { if (!ignoreInactiveItems || contentTransform.GetChild(i).gameObject.activeInHierarchy) { RectTransform childBeingSorted = ((RectTransform)contentTransform.GetChild(i)); int insertIndex = childrenFromLeftToRight.Count; for (int j = 0; j < childrenFromLeftToRight.Count; j++) { if (DstFromTopLeftOfTransformToTopLeftOfParent(childBeingSorted).x < DstFromTopLeftOfTransformToTopLeftOfParent(childrenFromLeftToRight[j]).x) { insertIndex = j; break; } } childrenFromLeftToRight.Insert(insertIndex, childBeingSorted); } } RectTransform childFurthestToTheRight = childrenFromLeftToRight[childrenFromLeftToRight.Count - 1]; float totalWidth = DstFromTopLeftOfTransformToTopLeftOfParent(childFurthestToTheRight).x + childFurthestToTheRight.sizeDelta.x; contentTransform.sizeDelta = new Vector2(totalWidth, contentTransform.sizeDelta.y); float scrollRectWidth = Mathf.Min(childrenFromLeftToRight[0].sizeDelta.x, childrenFromLeftToRight[childrenFromLeftToRight.Count - 1].sizeDelta.x); // Note: sizeDelta will not be calculated properly if the scroll view is set to stretch width. scrollRectTransform.sizeDelta = new Vector2(scrollRectWidth, scrollRectTransform.sizeDelta.y); contentPositions = new List<Vector3>(); float widthOfScrollRect = scrollRectTransform.sizeDelta.x; totalScrollableWidth = totalWidth - widthOfScrollRect; for (int i = 0; i < childrenFromLeftToRight.Count; i++) { float offset = DstFromTopLeftOfTransformToTopLeftOfParent(childrenFromLeftToRight[i]).x + ((childrenFromLeftToRight[i].sizeDelta.x - widthOfScrollRect) / 2); scrollRect.horizontalNormalizedPosition = offset / totalScrollableWidth; contentPositions.Add(contentTransform.localPosition); } } #endregion #region Public Movement Functions /// <summary> /// Function for going to a specific screen. /// *Note the index is based on a zero-starting index. /// </summary> /// <param name="info">All of the info about how you want it to move</param> public void GoTo(MoveInfo info) { if (!Moving && info.index != ClosestItemIndex) { MovementStarted.Invoke(); } if (info.indexType == MoveInfo.IndexType.childIndex) { mLerpTime = info.duration; GoToChild(info.index, info.jump); } else if (info.indexType == MoveInfo.IndexType.positionIndex) { mLerpTime = info.duration; GoToContentPos(info.index, info.jump); } } private void GoToChild(int index, bool jump) { int clampedIndex = Mathf.Clamp(index, 0, contentPositions.Count - 1); //contentPositions amount == the amount of available children if (ContentIsHorizonalLayoutGroup) //the contentPositions are in child order { lerpTarget = contentPositions[clampedIndex]; if (jump) { contentTransform.localPosition = lerpTarget; } else { StopMovement(); StartCoroutine("LerpToContent"); } } else //the contentPositions are in order from left -> right; { int availableChildIndex = 0; //an available child is one we can snap to Vector3 previousContentTransformPos = contentTransform.localPosition; for (int i = 0; i < contentTransform.childCount; i++) { if (!ignoreInactiveItems || contentTransform.GetChild(i).gameObject.activeInHierarchy) { if (availableChildIndex == clampedIndex) { RectTransform startChild = (RectTransform) contentTransform.GetChild(i); float offset = DstFromTopLeftOfTransformToTopLeftOfParent(startChild).x + ((startChild.sizeDelta.x - scrollRectTransform.sizeDelta.x) / 2); scrollRect.horizontalNormalizedPosition = offset / totalScrollableWidth; lerpTarget = contentTransform.localPosition; if (!jump) { contentTransform.localPosition = previousContentTransformPos; StopMovement(); StartCoroutine("LerpToContent"); } return; } availableChildIndex++; } } } } private void GoToContentPos(int index, bool jump) { int clampedIndex = Mathf.Clamp(index, 0, contentPositions.Count - 1); //contentPositions amount == the amount of available children //the content positions are all in order from left -> right //which is what we want so there's no need to check lerpTarget = contentPositions[clampedIndex]; if (jump) { contentTransform.localPosition = lerpTarget; } else { StopMovement(); StartCoroutine("LerpToContent"); } } /// <summary> /// Function for going to the next item /// *Note the next item is the item to the right of the current item, this is not based on child order /// </summary> public void NextItem() { int index; if (Sliding) { index = ClosestItemIndex + 1; } else { index = LerpTargetIndex + 1; } MoveInfo info = new MoveInfo(MoveInfo.IndexType.positionIndex, index, jumpToItem, lerpTime); GoTo(info); } /// <summary> /// Function for going to the previous item /// *Note the next item is the item to the left of the current item, this is not based on child order /// </summary> public void PreviousItem() { int index; if (Sliding) { index = ClosestItemIndex - 1; } else { index = LerpTargetIndex - 1; } MoveInfo info = new MoveInfo(MoveInfo.IndexType.positionIndex, index, jumpToItem, lerpTime); GoTo(info); } /// <summary> /// Function for recalculating the size of the content & the snap positions, such as when you remove or add a child /// </summary> public void UpdateLayout() { SetupDrivenTransforms(); SetupSnapScroll(); } /// <summary> /// Recalculates the size of the content & snap positions, and moves to a new item afterwards. /// </summary> /// <param name="info">All of the info about how you want it to move</param> public void UpdateLayoutAndMoveTo(MoveInfo info) { SetupDrivenTransforms(); SetupSnapScroll(); GoTo(info); } #endregion #region Behind the Scenes Movement stuff public void OnBeginDrag(PointerEventData ped) { if (contentPositions.Count < 2) { return; } StopMovement(); if (!Moving) { MovementStarted.Invoke(); } } public void OnEndDrag(PointerEventData ped) { if (contentPositions.Count <= 1) { return; } if (IsScrollRectAvailable) { StartCoroutine("SlideAndLerp"); } } private void Update() { if (IsScrollRectAvailable) { if (_closestItem != ClosestItemIndex) { CurrentItemChanged.Invoke(ClosestItemIndex); ChangePaginationInfo(ClosestItemIndex); _closestItem = ClosestItemIndex; } } } private IEnumerator SlideAndLerp() { mSliding = true; while (Mathf.Abs(scrollRect.velocity.x) > snappingVelocityThreshold) { yield return null; } lerpTarget = FindClosestFrom(contentTransform.localPosition); ItemFoundToSnap.Invoke(LerpTargetIndex); while (Vector3.Distance(contentTransform.localPosition, lerpTarget) > 1) { contentTransform.localPosition = Vector3.Lerp(scrollRect.content.localPosition, lerpTarget, 7.5f * Time.deltaTime); yield return null; } mSliding = false; scrollRect.velocity = Vector2.zero; contentTransform.localPosition = lerpTarget; ItemSnappedTo.Invoke(LerpTargetIndex); } private IEnumerator LerpToContent() { ItemFoundToSnap.Invoke(LerpTargetIndex); mLerping = true; Vector3 originalContentPos = contentTransform.localPosition; float elapsedTime = 0; while (elapsedTime < mLerpTime) { elapsedTime += Time.deltaTime; contentTransform.localPosition = Vector3.Lerp(originalContentPos, lerpTarget, (elapsedTime / mLerpTime)); yield return null; } ItemSnappedTo.Invoke(LerpTargetIndex); mLerping = false; } #endregion private void StopMovement() { scrollRect.velocity = Vector2.zero; StopCoroutine("SlideAndLerp"); StopCoroutine("LerpToContent"); } private void ChangePaginationInfo(int targetScreen) { if (pagination) for (int i = 0; i < pagination.transform.childCount; i++) { pagination.transform.GetChild(i).GetComponent<Toggle>().isOn = (targetScreen == i); } } private Vector2 DstFromTopLeftOfTransformToTopLeftOfParent(RectTransform rt) { //gets rid of any pivot weirdness return new Vector2(rt.anchoredPosition.x - (rt.sizeDelta.x * rt.pivot.x), rt.anchoredPosition.y + (rt.sizeDelta.y * (1 - rt.pivot.y))); } private Vector3 FindClosestFrom(Vector3 start) { Vector3 closest = Vector3.zero; float distance = Mathf.Infinity; foreach (Vector3 position in contentPositions) { if (Vector3.Distance(start, position) < distance) { distance = Vector3.Distance(start, position); closest = position; } } return closest; } [System.Serializable] public struct MoveInfo { public enum IndexType { childIndex, positionIndex } [Tooltip("Child Index means the Index corresponds to the content item at that index in the hierarchy.\n" + "Position Index means the Index corresponds to the content item in that snap position.\n" + "A higher Position Index in a Horizontal Scroll Snap means it would be further to the right.")] public IndexType indexType; [Tooltip("Zero based")] public int index; [Tooltip("If this is true the snap scroll will jump to the index, otherwise it will lerp there.")] public bool jump; [Tooltip("If jump is false this is the time it will take to lerp to the index")] public float duration; /// <summary> /// Creates a MoveInfo that jumps to the index /// </summary> /// <param name="_indexType">Whether you want to get the child at the index or the snap position at the index</param> /// <param name="_index">Where you want it to jump</param> public MoveInfo(IndexType _indexType, int _index) { indexType = _indexType; index = _index; jump = true; duration = 0; } /// <summary> /// Creates a MoveInfo /// </summary> /// <param name="_indexType">Whether you want to get the child at the index or the snap position at the index</param> /// <param name="_index">Where you want it to jump</param> /// <param name="_jump">Whether you want it to jump or lerp to the index</param> /// <param name="_duration">How long it takes to lerp to the index</param> public MoveInfo(IndexType _indexType, int _index, bool _jump, float _duration) { indexType = _indexType; index = _index; jump = _jump; duration = _duration; } } } }