// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Microsoft.MixedReality.Toolkit.Input;
using Microsoft.MixedReality.Toolkit.Utilities;
using Microsoft.MixedReality.Toolkit.Utilities.Solvers;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Serialization;
namespace Microsoft.MixedReality.Toolkit.UI
{
///
/// A scrollable frame where content scroll is triggered by manual controller click and drag or according to pagination settings.
///
/// Executing also in edit mode to properly catch and mask any new content added to scroll container.
[ExecuteAlways]
[AddComponentMenu("Scripts/MRTK/SDK/ScrollingObjectCollection")]
public class ScrollingObjectCollection : MonoBehaviour,
IMixedRealityPointerHandler,
IMixedRealitySourceStateHandler,
IMixedRealityTouchHandler
{
///
/// How velocity is applied to a when a scroll is released.
///
public enum VelocityType
{
FalloffPerFrame = 0,
FalloffPerItem,
NoVelocitySnapToItem,
None
}
///
/// The direction in which a can scroll.
///
public enum ScrollDirectionType
{
UpAndDown = 0,
LeftAndRight,
}
[SerializeField]
[Tooltip("Enables/disables scrolling with near/far interaction.")]
private bool canScroll = true;
///
/// Enables/disables scrolling with near/far interaction.
///
/// Helpful for controls where you may want pagination or list movement without freeform scrolling.
public bool CanScroll
{
get { return canScroll; }
set { canScroll = value; }
}
///
/// Edit modes for defining scroll viewable area and scroll interaction boundaries.
///
public enum EditMode
{
Auto = 0, // Use pagination values
Manual, // Use direct manipulation of the object
}
[SerializeField]
[Tooltip("Edit modes for defining the clipping box masking boundaries. Choose 'Auto' to automatically use pagination values. Choose 'Manual' for enabling direct manipulation of the clipping box object.")]
private EditMode maskEditMode;
///
/// Edit modes for defining the clipping box masking boundaries. Choose 'Auto' to automatically use pagination values. Choose 'Manual' for enabling direct manipulation of the clipping box object.
///
public EditMode MaskEditMode
{
get { return maskEditMode; }
set { maskEditMode = value; }
}
[SerializeField]
[Tooltip("Edit modes for defining the scroll interaction collider boundaries. Choose 'Auto' to automatically use pagination values. Choose 'Manual' for enabling direct manipulation of the collider.")]
private EditMode colliderEditMode;
///
/// Edit modes for defining the scroll interaction collider boundaries. Choose 'Auto' to automatically use pagination values. Choose 'Manual' for enabling direct manipulation of the collider.
///
public EditMode ColliderEditMode
{
get { return colliderEditMode; }
set { colliderEditMode = value; }
}
[SerializeField]
private bool maskEnabled = true;
///
/// Visibility mode of scroll content. Default value will mask all objects outside of the scroll viewable area.
///
public bool MaskEnabled
{
get { return maskEnabled; }
set
{
if (!value && value != wasMaskEnabled)
{
RestoreContentVisibility();
}
wasMaskEnabled = value;
maskEnabled = value;
}
}
// Helps catching any changes on the mask enabled value made from the inspector.
// With the custom editor, the mask enabled field is changed before mask enabled setter is called.
private bool wasMaskEnabled = true;
[SerializeField]
[Tooltip("The distance, in meters, the current pointer can travel along the scroll direction before triggering a scroll drag.")]
[Range(0.0f, 0.2f)]
private float handDeltaScrollThreshold = 0.02f;
///
/// The distance, in meters, the current pointer can travel along the scroll direction before triggering a scroll drag.
///
public float HandDeltaScrollThreshold
{
get { return handDeltaScrollThreshold; }
set { handDeltaScrollThreshold = value; }
}
[SerializeField]
[Tooltip("Withdraw amount, in meters, from the front of the scroll boundary needed to transition from touch engaged to released.")]
private float releaseThresholdFront = 0.03f;
///
/// Withdraw amount, in meters, from the front of the scroll boundary needed to transition from touch engaged to released.
///
public float ReleaseThresholdFront
{
get { return releaseThresholdFront; }
set { releaseThresholdFront = value; }
}
[SerializeField]
[Tooltip("Withdraw amount, in meters, from the back of the scroll boundary needed to transition from touch engaged to released.")]
private float releaseThresholdBack = 0.20f;
///
/// Withdraw amount, in meters, from the back of the scroll boundary needed to transition from touch engaged to released.
///
public float ReleaseThresholdBack
{
get { return releaseThresholdBack; }
set { releaseThresholdBack = value; }
}
[SerializeField]
[Tooltip("Withdraw amount, in meters, from the right or left of the scroll boundary needed to transition from touch engaged to released.")]
private float releaseThresholdLeftRight = 0.20f;
///
/// Withdraw amount, in meters, from the right or left of the scroll boundary needed to transition from touch engaged to released.
///
public float ReleaseThresholdLeftRight
{
get { return releaseThresholdLeftRight; }
set { releaseThresholdLeftRight = value; }
}
[SerializeField]
[Tooltip("Withdraw amount, in meters, from the top or bottom of the scroll boundary needed to transition from touch engaged to released.")]
private float releaseThresholdTopBottom = 0.20f;
///
/// Withdraw amount, in meters, from the top or bottom of the scroll boundary needed to transition from touch engaged to released.
///
public float ReleaseThresholdTopBottom
{
get { return releaseThresholdTopBottom; }
set { releaseThresholdTopBottom = value; }
}
[SerializeField]
[Tooltip("Distance, in meters, to position a local xy plane used to verify if a touch interaction started in the front of the scroll view.")]
[Range(0.0f, 0.05f)]
private float frontTouchDistance = 0.005f;
///
/// Distance, in meters, to position a local xy plane used to verify if a touch interaction started in the front of the scroll view.
///
public float FrontTouchDistance
{
get { return frontTouchDistance; }
set { frontTouchDistance = value; }
}
[SerializeField]
[Tooltip("The direction in which content should scroll.")]
private ScrollDirectionType scrollDirection;
///
/// The direction in which content should scroll.
///
public ScrollDirectionType ScrollDirection
{
get { return scrollDirection; }
set { scrollDirection = value; }
}
[SerializeField]
[Tooltip("Toggles whether the scrollingObjectCollection will use the Camera OnPreRender event to manage content visibility.")]
private bool useOnPreRender;
///
/// Toggles whether Camera OnPreRender callback will be used to manage content visibility.
/// The fallback is MonoBehaviour.LateUpdate().
///
///
/// This is especially helpful if you're trying to scroll dynamically created objects that may be added to the list after LateUpdate,
///
public bool UseOnPreRender
{
get { return useOnPreRender; }
set
{
if (useOnPreRender == value) { return; }
if (cameraMethods == null)
{
cameraMethods = CameraCache.Main.gameObject.EnsureComponent();
}
ClipBox.UseOnPreRender = true;
if (value)
{
cameraMethods.OnCameraPreRender += OnCameraPreRender;
}
else
{
cameraMethods.OnCameraPreRender -= OnCameraPreRender;
}
useOnPreRender = value;
ClipBox.UseOnPreRender = useOnPreRender;
}
}
[SerializeField]
[Tooltip("Amount of (extra) velocity to be applied to scroller")]
[Range(0.0f, 0.02f)]
private float velocityMultiplier = 0.008f;
///
/// Amount of (extra) velocity to be applied to scroller.
///
/// Helpful if you want a small movement to fling the list.
public float VelocityMultiplier
{
get { return velocityMultiplier; }
set { velocityMultiplier = value; }
}
[SerializeField]
[Tooltip("Amount of falloff applied to velocity")]
[Range(0.0001f, 0.9999f)]
private float velocityDampen = 0.90f;
///
/// Amount of drag applied to velocity.
///
/// This can't be 0.0f since that won't allow ANY velocity - set to . It can't be 1.0f since that won't allow ANY drag.
public float VelocityDampen
{
get { return velocityDampen; }
set { velocityDampen = value; }
}
[SerializeField]
[Tooltip("The desired type of velocity for the scroller.")]
private VelocityType typeOfVelocity;
///
/// The desired type of velocity for the scroller.
///
public VelocityType TypeOfVelocity
{
get { return typeOfVelocity; }
set { typeOfVelocity = value; }
}
[SerializeField]
[Tooltip("Animation curve for pagination.")]
private AnimationCurve paginationCurve = new AnimationCurve(
new Keyframe(0, 0),
new Keyframe(1, 1));
///
/// Animation curve used to interpolate the pagination and movement methods.
///
public AnimationCurve PaginationCurve
{
get { return paginationCurve; }
set { paginationCurve = value; }
}
[SerializeField]
[Tooltip("The amount of time (in seconds) the PaginationCurve will take to evaluate.")]
private float animationLength = 0.25f;
///
/// The amount of time (in seconds) the will take to evaluate.
///
public float AnimationLength
{
get { return (animationLength < 0) ? 0 : animationLength; }
set { animationLength = value; }
}
[Tooltip("Number of cells in a row on up-down scroll view or number of cells in a column on left-right scroll view.")]
[SerializeField]
[FormerlySerializedAs("tiers")]
[Min(1)]
private int cellsPerTier = 1;
///
/// Number of cells in a row on up-down scroll or number of cells in a column on left-right scroll.
///
public int CellsPerTier
{
get
{
return cellsPerTier;
}
set
{
Debug.Assert(value > 0, "Cells per tier should have a positive non zero value");
cellsPerTier = Mathf.Max(1, value);
}
}
[SerializeField]
[Tooltip("Number of visible tiers in the scrolling area.")]
[FormerlySerializedAs("viewableArea")]
[Min(1)]
private int tiersPerPage = 2;
///
/// Number of visible tiers in the scrolling area.
///
public int TiersPerPage
{
get
{
return tiersPerPage;
}
set
{
Debug.Assert(value > 0, "Tiers per page should have a positive non zero value");
tiersPerPage = Mathf.Max(1, value);
}
}
[Tooltip("Width of the pagination cell.")]
[SerializeField]
[Min(0.001f)]
private float cellWidth = 0.25f;
///
/// Width of the pagination cell.
///
public float CellWidth
{
get
{
return cellWidth;
}
set
{
Debug.Assert(value > 0, "Cell width should have a positive non zero value");
cellWidth = Mathf.Max(0.001f, value);
}
}
[Tooltip("Height of the pagination cell.")]
[SerializeField]
[Min(0.001f)]
private float cellHeight = 0.25f;
///
/// Height of the pagination cell.Hhide
///
public float CellHeight
{
get
{
return cellHeight;
}
set
{
Debug.Assert(cellHeight > 0, "Cell height should have a positive non zero value");
cellHeight = Mathf.Max(0.001f, value);
}
}
[Tooltip("Depth of cell used for masking out content renderers that are out of bounds.")]
[SerializeField]
[Min(0.001f)]
private float cellDepth = 0.25f;
///
/// Depth of cell used for masking out content renderers that are out of bounds.
///
public float CellDepth
{
get
{
return cellDepth;
}
set
{
Debug.Assert(value > 0, "Cell depth should have a positive non zero value");
cellDepth = Mathf.Max(0.001f, value);
}
}
[SerializeField]
[Tooltip("Multiplier to add more bounce to the overscroll of a list when using VelocityType.FalloffPerFrame or VelocityType.FalloffPerItem.")]
private float bounceMultiplier = 0.1f;
///
/// Multiplier to add more bounce to the overscroll of a list when using or .
///
public float BounceMultiplier
{
get { return bounceMultiplier; }
set { bounceMultiplier = value; }
}
// Lerping time interval used for smoothing between positions during scroll drag. Number was empirically defined.
private const float DragLerpInterval = 0.5f;
// Lerping time interval used for smoothing between positions during scroll drag passed max and min scroll positions. Number was empirically defined.
private const float OverDampLerpInterval = 0.9f;
// Lerping time interval used for smoothing between positions during bouncing. Number was empirically defined.
private const float BounceLerpInterval = 0.2f;
///
/// The UnityEvent type the ScrollingObjectCollection sends.
/// GameObject is the object the fired the scroll.
///
[System.Serializable]
public class ScrollEvent : UnityEvent { }
///
/// Event that is fired on the target object when the ScrollingObjectCollection deems event as a Click.
///
[Tooltip("Event that is fired on the target object when the ScrollingObjectCollection deems event as a Click.")]
public ScrollEvent OnClick = new ScrollEvent();
///
/// Event that is fired on the target object when the ScrollingObjectCollection is touched.
///
[Tooltip("Event that is fired on the target object when the ScrollingObjectCollection is touched.")]
public ScrollEvent OnTouchStarted = new ScrollEvent();
///
/// Event that is fired on the target object when the ScrollingObjectCollection is no longer touched.
///
[Tooltip("Event that is fired on the target object when the ScrollingObjectCollection is no longer touched.")]
public ScrollEvent OnTouchEnded = new ScrollEvent();
///
/// Event that is fired on the target object when the ScrollingObjectCollection is no longer in motion from velocity
///
[Tooltip("Event that is fired on the target object when the ScrollingObjectCollection is no longer in motion from velocity.")]
public UnityEvent OnMomentumEnded = new UnityEvent();
///
/// Event that is fired on the target object when the ScrollingObjectCollection is starting motion with velocity.
///
[Tooltip("Event that is fired on the target object when the ScrollingObjectCollection is starting motion with velocity.")]
public UnityEvent OnMomentumStarted = new UnityEvent();
[SerializeField]
[HideInInspector]
private CameraEventRouter cameraMethods;
// Maximum amount the scroller can travel (vertically)
private float MaxY
{
get
{
var max = (contentBounds == null || contentBounds.size.y <= 0) ? 0 :
Mathf.Max(0, contentBounds.size.y - TiersPerPage * CellHeight);
if (maskEditMode == EditMode.Auto)
{
// Making it a multiple of cell height
max = Mathf.Round(SafeDivisionFloat(max, CellHeight)) * CellHeight;
}
return max;
}
}
// Minimum amount the scroller can travel (vertically) - this will always be zero. Here for readability
private readonly float minY = 0.0f;
// Maximum amount the scroller can travel (horizontally) - this will always be zero. Here for readability
private readonly float maxX = 0.0f;
// Minimum amount the scroller can travel (horizontally)
private float MinX
{
get
{
var max = (contentBounds == null || contentBounds.size.x <= 0) ? 0 :
Mathf.Max(0, contentBounds.size.x - TiersPerPage * CellWidth);
if (maskEditMode == EditMode.Auto)
{
// Making it a multiple of cell width
max = Mathf.Round(SafeDivisionFloat(max, CellWidth)) * CellWidth;
}
return max * -1.0f;
}
}
// Bounds that wrap all scroll container content. Used for calculating MinX and MaxY.
private Bounds contentBounds;
///
/// Index of the first visible cell.
///
public int FirstVisibleCellIndex
{
get
{
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
return (int)Mathf.Ceil(ScrollContainer.transform.localPosition.y / CellHeight) * CellsPerTier;
}
else
{
// Scroll container most to the right local position has x component equals to zero. This value goes negative as scroll container moves to the left.
return ((int)Mathf.Ceil(Mathf.Abs(ScrollContainer.transform.localPosition.x / CellWidth)) * CellsPerTier);
}
}
}
///
/// Index of the first hidden cell.
///
public int FirstHiddenCellIndex
{
get
{
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
return ((int)Mathf.Floor(ScrollContainer.transform.localPosition.y / CellHeight) * CellsPerTier) + (TiersPerPage * CellsPerTier);
}
else
{
return ((int)Mathf.Floor(-ScrollContainer.transform.localPosition.x / CellWidth) * CellsPerTier) + (TiersPerPage * CellsPerTier);
}
}
}
private BoxCollider scrollingCollider;
///
/// Scrolling interaction collider used to catch pointer and touch events on empty spaces.
///
public BoxCollider ScrollingCollider
{
get
{
if (scrollingCollider == null)
{
scrollingCollider = gameObject.EnsureComponent();
}
return scrollingCollider;
}
}
// Depth of the scrolling interaction collider. Used for defining a plane depth if 'Auto' collider edit mode is selected.
private const float ScrollingColliderDepth = 0.001f;
private NearInteractionTouchable scrollingTouchable;
///
/// Scrolling interaction touchable used to catch touch events on empty spaces.
///
public NearInteractionTouchable ScrollingTouchable
{
get
{
if (scrollingTouchable == null)
{
scrollingTouchable = gameObject.EnsureComponent();
}
return scrollingTouchable;
}
}
///
/// The local position of the moving scroll container. Can be used to represent the container drag displacement.
///
public Vector3 ScrollContainerPosition => ScrollContainer.transform.localPosition;
// The empty game object that contains our nodes and be scrolled
[SerializeField]
[HideInInspector]
private GameObject scrollContainer;
private GameObject ScrollContainer
{
get
{
if (scrollContainer == null)
{
Transform oldContainer = transform.Find("Container");
if (oldContainer != null)
{
scrollContainer = oldContainer.gameObject;
Debug.LogWarning(name + " ScrollingObjectCollection found an existing Container object, using it for the list");
}
else
{
scrollContainer = new GameObject();
scrollContainer.name = "Container";
scrollContainer.transform.parent = transform;
scrollContainer.transform.localPosition = Vector3.zero;
scrollContainer.transform.localRotation = Quaternion.identity;
}
}
return scrollContainer;
}
}
// The empty game object that contains the ClipppingBox
[SerializeField]
[HideInInspector]
private GameObject clippingObject;
///
/// The empty GameObject containing the ScrollingObjectCollection's .
///
public GameObject ClippingObject
{
get
{
if (clippingObject == null)
{
Transform oldClippingObj = transform.Find("Clipping Bounds");
if (oldClippingObj != null)
{
clippingObject = oldClippingObj.gameObject;
Debug.LogWarning(name + " ScrollingObjectCollection found an existing Clipping object, using it for the list");
}
else
{
clippingObject = new GameObject();
}
clippingObject.name = "Clipping Bounds";
clippingObject.transform.parent = transform;
clippingObject.transform.localRotation = Quaternion.identity;
clippingObject.transform.localPosition = Vector3.zero;
}
return clippingObject;
}
}
[SerializeField]
[HideInInspector]
private ClippingBox clipBox;
///
/// The ScrollingObjectCollection's
/// that is used for clipping items in and out of the list.
///
public ClippingBox ClipBox
{
get
{
if (clipBox == null)
{
clipBox = ClippingObject.EnsureComponent();
clipBox.ClippingSide = ClippingPrimitive.Side.Outside;
}
return clipBox;
}
}
// This collider will be used for checking intersection of the scroll visible area with any content collider or renderer bounds.
private Collider clippingBoundsCollider;
private Collider ClippingBoundsCollider
{
get
{
if (clippingBoundsCollider == null)
{
clippingBoundsCollider = ClippingObject.EnsureComponent();
clippingBoundsCollider.enabled = false;
}
return clippingBoundsCollider;
}
}
// Ratio that defines the outer clipping bounds size relative to the actual clipping bounds.
// The outer clipping bounds is used for ensuring that content collider that are mostly visible can still stay interactable.
private readonly float contentVisibilityThresholdRatio = 1.025f;
private bool oldIsTargetPositionLockedOnFocusLock;
private readonly HashSet clippedRenderers = new HashSet();
#region scroll state variables
///
/// Tracks whether content or scroll background is being interacted with.
///
public bool IsEngaged { get; private set; } = false;
///
/// Tracks whether the scroll is being dragged due to a controller movement.
///
public bool IsDragging { get; private set; } = false;
///
/// Tracks whether the scroll content or background is touched by a near pointer.
/// Remains true while the same near pointer does not cross the scrolling release boundaries.
///
public bool IsTouched { get; private set; } = false;
///
/// Tracks whether the scroll has any kind of momentum.
/// True if scroll is being dragged by a controller, the velocity is falling off after a drag release or during pagination movement.
///
public bool HasMomentum { get; private set; } = false;
// The position of the scollContainer before we do any updating to it
private Vector3 initialScrollerPos;
// The new of the scollContainer before we've set the position / finished the updateloop
private Vector3 workingScrollerPos;
// A list of content renderers that need to be added to the clippingBox
private List renderersToClip = new List();
// A list of content renderers that need to be removed from the clippingBox
private List renderersToUnclip = new List();
private IMixedRealityPointer currentPointer;
// The initial focused object from scroll content. This may not always be currentPointer.Result.CurrentPointerTarget
private GameObject initialFocusedObject;
#endregion scroll state variables
#region drag position calculation variables
// Hand position when starting a motion
private Vector3 initialPointerPos;
// Hand position previous frame
private Vector3 lastPointerPos;
#endregion drag position calculation variables
#region velocity calculation variables
// Simple velocity of the scroller: current - last / timeDelta
private float scrollVelocity = 0.0f;
// Filtered weight of scroll velocity
private float avgVelocity = 0.0f;
// How much we should filter the velocity - yes this is a magic number. Its been tuned so lets leave it.
private readonly float velocityFilterWeight = 0.97f;
// Simple state enum to handle velocity falloff logic
private enum VelocityState
{
None = 0,
Resolving,
Calculating,
Bouncing,
Dragging,
Animating,
}
// Internal enum for tracking the velocity state of the list
private VelocityState currentVelocityState;
private VelocityState CurrentVelocityState
{
get => currentVelocityState;
set
{
if (value != currentVelocityState)
{
if (value == VelocityState.None)
{
OnMomentumEnded.Invoke();
}
else if (currentVelocityState == VelocityState.None)
{
OnMomentumStarted.Invoke();
}
previousVelocityState = currentVelocityState;
currentVelocityState = value;
}
}
}
private VelocityState previousVelocityState;
// Pre calculated destination with velocity and falloff when using per item snapping
private Vector3 velocityDestinationPos;
// Velocity container for storing previous filtered velocity
private float velocitySnapshot;
#endregion velocity calculation variables
// The Animation CoRoutine
private IEnumerator animateScroller;
///
/// Scroll pagination modes.
///
public enum PaginationMode
{
ByTier = 0, // By number of tiers
ByPage, // By number of pages
ToCellIndex // To selected cell
}
#region performance variables
[SerializeField]
[Tooltip("Disables Gameobjects with Renderer components which are clipped by the clipping box.")]
private bool disableClippedGameObjects = true;
///
/// Disables GameObjects with Renderer components which are clipped by the clipping box.
/// Improves performance significantly by reducing the number of GameObjects that need to be managed in engine.
///
public bool DisableClippedGameObjects
{
get { return disableClippedGameObjects; }
set { disableClippedGameObjects = value; }
}
[SerializeField]
[Tooltip("Disables the Renderer components of Gameobjects which are clipped by the clipping box.")]
private bool disableClippedRenderers = false;
///
/// Disables the Renderer components of Gameobjects which are clipped by the clipping box.
/// Improves performance by reducing the number of renderers that need to be tracked, while still allowing the
/// GameObjects associated with those renders to continue updating. Less performant compared to using DisableClippedGameObjects
///
public bool DisableClippedRenderers
{
get { return disableClippedRenderers; }
set { disableClippedRenderers = value; }
}
#endregion performance variables
#region Setup methods
///
/// Sets up the scroll clipping object and the interactable components according to the scroll content and chosen settings.
///
public void UpdateContent()
{
UpdateContentBounds();
SetupScrollingInteractionCollider();
SetupClippingObject();
ManageVisibility();
}
private void UpdateContentBounds()
{
var originalRotation = transform.rotation;
transform.rotation = Quaternion.identity;
var childrenRenderers = ScrollContainer.GetComponentsInChildren(true);
if (childrenRenderers != null)
{
contentBounds = new Bounds
{
size = Vector3.zero,
center = ClipBox.transform.position
};
foreach (var renderer in childrenRenderers)
{
contentBounds.Encapsulate(renderer.bounds);
}
Vector3 localSize;
localSize.y = SafeDivisionFloat(contentBounds.size.y, transform.lossyScale.y);
localSize.x = SafeDivisionFloat(contentBounds.size.x, transform.lossyScale.x);
localSize.z = SafeDivisionFloat(contentBounds.size.z, transform.lossyScale.z);
contentBounds.size = localSize;
}
transform.rotation = originalRotation;
}
// Setting up the initial transform values for the scrolling interaction collider and near touchable.
private void SetupScrollingInteractionCollider()
{
// Boundaries will be defined by direct manipulation of the scroll interaction components
if (colliderEditMode == EditMode.Manual)
{
return;
}
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
ScrollingCollider.size = new Vector3(CellWidth * CellsPerTier, CellHeight * TiersPerPage, ScrollingColliderDepth);
}
else
{
ScrollingCollider.size = new Vector3(CellWidth * TiersPerPage, CellHeight * CellsPerTier, ScrollingColliderDepth);
}
Vector3 colliderPosition;
colliderPosition.x = ScrollingCollider.size.x / 2;
colliderPosition.y = -ScrollingCollider.size.y / 2;
colliderPosition.z = cellDepth / 2 + ScrollingColliderDepth;
ScrollingCollider.center = colliderPosition;
Vector2 size = new Vector2(
Math.Abs(Vector3.Dot(ScrollingCollider.size, ScrollingTouchable.LocalRight)),
Math.Abs(Vector3.Dot(ScrollingCollider.size, ScrollingTouchable.LocalUp)));
Vector3 touchablePosition = colliderPosition;
touchablePosition.z = -cellDepth / 2;
ScrollingTouchable.SetBounds(size);
ScrollingTouchable.SetLocalCenter(touchablePosition);
}
///
/// Setting up the initial transform values for the clippingBox.
///
private void SetupClippingObject()
{
// Boundaries will be defined by direct manipulation of the clipping object
if (maskEditMode == EditMode.Manual)
{
return;
}
// The bounds of the clipping object, this is to make helper math easier later, it doesn't matter that its AABB since we're really not using it for bounds operations
Bounds clippingBounds = new Bounds();
clippingBounds.size = Vector3.one;
Vector3 viewableCenter = new Vector3();
// Adjust scale and position of clipping box
switch (scrollDirection)
{
case ScrollDirectionType.UpAndDown:
default:
// Apply the viewable area and column/row multiplier
// Use a dummy bounds of one to get the local scale to match;
clippingBounds.size = new Vector3((CellWidth * CellsPerTier), (CellHeight * TiersPerPage), CellDepth);
ClipBox.transform.localScale = new Bounds(Vector3.zero, Vector3.one).GetScaleToMatchBounds(clippingBounds);
break;
case ScrollDirectionType.LeftAndRight:
// Same as above for L <-> R
clippingBounds.size = new Vector3(CellWidth * TiersPerPage, CellHeight * CellsPerTier, CellDepth);
ClipBox.transform.localScale = new Bounds(Vector3.zero, Vector3.one).GetScaleToMatchBounds(clippingBounds);
break;
}
// Adjust where the center of the clipping box is
viewableCenter.x = ClipBox.transform.localScale.x * 0.5f;
viewableCenter.y = ClipBox.transform.localScale.y * -0.5f;
viewableCenter.z = 0;
// Apply new values
ClipBox.transform.localPosition = viewableCenter;
}
#endregion Setup methods
#region MonoBehaviour Implementation
private void OnEnable()
{
// Register for global input events
CoreServices.InputSystem?.RegisterHandler(this);
CoreServices.InputSystem?.RegisterHandler(this);
CoreServices.InputSystem?.RegisterHandler(this);
if (useOnPreRender)
{
ClipBox.UseOnPreRender = true;
// Subscribe to the preRender callback on the main camera so we can intercept it and make sure we catch
// any dynamically added content
if (cameraMethods == null)
{
cameraMethods = CameraCache.Main.gameObject.EnsureComponent();
}
cameraMethods.OnCameraPreRender += OnCameraPreRender;
}
}
private void Start()
{
UpdateContent();
}
private void Update()
{
if (!Application.isPlaying)
{
return;
}
// Force the scroll container position if no content
if (ScrollContainer.GetComponentInChildren(true) == null)
{
workingScrollerPos = Vector3.zero;
ApplyPosition(workingScrollerPos);
return;
}
// The scroller has detected input and has a valid pointer
if (IsEngaged && TryGetPointerPositionOnPlane(out Vector3 currentPointerPos))
{
Vector3 handDelta = initialPointerPos - currentPointerPos;
handDelta = transform.InverseTransformDirection(handDelta);
if (IsDragging && currentPointer != null) // Changing lock after drag started frame to allow for focus provider to move pointer focus to scroll background before locking
{
currentPointer.IsFocusLocked = true;
}
// Lets see if this is gonna be a click or a drag
// Check the scroller's length state to prevent resetting calculation
if (!IsDragging)
{
// Grab the delta value we care about
float absAxisHandDelta = (scrollDirection == ScrollDirectionType.UpAndDown) ? Mathf.Abs(handDelta.y) : Mathf.Abs(handDelta.x);
// Catch an intentional finger in scroller to stop momentum, this isn't a drag its definitely a stop
if (absAxisHandDelta > handDeltaScrollThreshold)
{
scrollVelocity = 0.0f;
avgVelocity = 0.0f;
IsDragging = true;
handDelta = Vector3.zero;
CurrentVelocityState = VelocityState.Dragging;
// Reset initialHandPos to prevent the scroller from jumping
initialScrollerPos = workingScrollerPos = ScrollContainer.transform.localPosition;
initialPointerPos = currentPointerPos;
}
}
if (IsTouched && DetectScrollRelease(currentPointerPos))
{
// We're on the other side of the original touch position. This is a release.
if (IsDragging)
{
// Its a drag release
initialScrollerPos = workingScrollerPos;
CurrentVelocityState = VelocityState.Calculating;
}
else
{
// Its a click release
OnClick?.Invoke(initialFocusedObject);
}
ResetInteraction();
}
else if (IsDragging && canScroll)
{
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
// Lock X, clamp Y
float handLocalDelta = SafeDivisionFloat(handDelta.y, transform.lossyScale.y);
// Over damp if scroll position out of bounds
if (workingScrollerPos.y > MaxY || workingScrollerPos.y < minY)
{
workingScrollerPos.y = MathUtilities.CLampLerp(initialScrollerPos.y - handLocalDelta, minY, MaxY, OverDampLerpInterval);
}
else
{
workingScrollerPos.y = MathUtilities.CLampLerp(initialScrollerPos.y - handLocalDelta, minY, MaxY, DragLerpInterval);
}
workingScrollerPos.x = 0.0f;
}
else
{
// Lock Y, clamp X
float handLocalDelta = SafeDivisionFloat(handDelta.x, transform.lossyScale.x);
// Over damp if scroll position out of bounds
if (workingScrollerPos.x > maxX || workingScrollerPos.x < MinX)
{
workingScrollerPos.x = MathUtilities.CLampLerp(initialScrollerPos.x - handLocalDelta, MinX, maxX, OverDampLerpInterval);
}
else
{
workingScrollerPos.x = MathUtilities.CLampLerp(initialScrollerPos.x - handLocalDelta, MinX, maxX, DragLerpInterval);
}
workingScrollerPos.y = 0.0f;
}
// Update the scrollContainer Position
ApplyPosition(workingScrollerPos);
CalculateVelocity();
// Update the prev val for velocity
lastPointerPos = currentPointerPos;
}
}
else if ((CurrentVelocityState != VelocityState.None
|| previousVelocityState != VelocityState.None)
&& CurrentVelocityState != VelocityState.Animating) // Prevent the Animation coroutine from being overridden
{
// We're not engaged, so handle any not touching behavior
HandleVelocityFalloff();
// Apply our position
ApplyPosition(workingScrollerPos);
}
// Setting HasMomentum to true if scroll velocity state has changed or any movement happened during this update
if (CurrentVelocityState != VelocityState.None || previousVelocityState != VelocityState.None)
{
HasMomentum = true;
}
else
{
HasMomentum = false;
}
previousVelocityState = CurrentVelocityState;
}
private void LateUpdate()
{
if (!UseOnPreRender)
{
ManageVisibility();
}
}
private void OnDisable()
{
// Unregister global input events
CoreServices.InputSystem?.UnregisterHandler(this);
CoreServices.InputSystem?.UnregisterHandler(this);
CoreServices.InputSystem?.UnregisterHandler(this);
// Currently in editor duplicating prefab GameObject containing both TMP and non-TMP children inside the Scrolling Object Collection container causes material life cycle management issues
// https://github.com/microsoft/MixedRealityToolkit-Unity/issues/9481
// Thus we do not automatically destroy material controlled by Material Instance if the OnDisable comes from pasting in editor
#if UNITY_EDITOR
if (!Application.isPlaying)
{
bool? isCalledFromPastingGameObject = new System.Diagnostics.StackFrame(1)?.GetMethod()?.Name?.Contains("Paste");
RestoreContentVisibility(!isCalledFromPastingGameObject.GetValueOrDefault());
}
else
{
RestoreContentVisibility();
}
#else
RestoreContentVisibility();
#endif
if (useOnPreRender && cameraMethods != null)
{
CameraEventRouter cameraMethods = CameraCache.Main.gameObject.EnsureComponent();
cameraMethods.OnCameraPreRender -= OnCameraPreRender;
}
}
#endregion MonoBehaviour Implementation
#region private methods
///
/// When , the subscribes to the call back for OnCameraPreRender
///
/// The active on the camera.
private void OnCameraPreRender(CameraEventRouter router)
{
ManageVisibility();
}
// Add or remove renderers from clipping primitive
private void ReconcileClippingContent()
{
if (renderersToClip.Count > 0)
{
AddRenderersToClippingObject(renderersToClip);
renderersToClip.Clear();
}
if (renderersToUnclip.Count > 0)
{
RemoveRenderersFromClippingObject(renderersToUnclip);
renderersToUnclip.Clear();
}
}
///
/// Gets the cursor position (pointer end point) on the scrollable plane,
/// projected onto the direction being scrolled if far pointer.
/// Returns false if the pointer is null.
///
private bool TryGetPointerPositionOnPlane(out Vector3 result)
{
result = Vector3.zero;
if (((MonoBehaviour)currentPointer) == null)
{
return false;
}
if (currentPointer.GetType() == typeof(PokePointer))
{
result = currentPointer.Position;
return true;
}
var scrollVector = (scrollDirection == ScrollDirectionType.UpAndDown) ? transform.up : transform.right;
result = transform.position + Vector3.Project(currentPointer.Position - transform.position, scrollVector);
return true;
}
///
/// Calculates our falloff
///
private void HandleVelocityFalloff()
{
switch (typeOfVelocity)
{
case VelocityType.FalloffPerFrame:
HandleFalloffPerFrame();
break;
case VelocityType.FalloffPerItem:
default:
HandleFalloffPerItem();
break;
case VelocityType.NoVelocitySnapToItem:
CurrentVelocityState = VelocityState.None;
avgVelocity = 0.0f;
// Round to the nearest cell
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
workingScrollerPos.y = Mathf.Round(ScrollContainer.transform.localPosition.y / CellHeight) * CellHeight;
}
else
{
workingScrollerPos.x = Mathf.Round(ScrollContainer.transform.localPosition.x / CellWidth) * CellWidth;
}
initialScrollerPos = workingScrollerPos;
break;
case VelocityType.None:
CurrentVelocityState = VelocityState.None;
avgVelocity = 0.0f;
break;
}
if (CurrentVelocityState == VelocityState.None)
{
workingScrollerPos.y = Mathf.Clamp(workingScrollerPos.y, minY, MaxY);
workingScrollerPos.x = Mathf.Clamp(workingScrollerPos.x, MinX, maxX);
}
}
///
/// Handles drag release behavior when is set to
///
private void HandleFalloffPerItem()
{
switch (CurrentVelocityState)
{
case VelocityState.Calculating:
int numSteps;
float newPosAfterVelocity;
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
if (avgVelocity == 0.0f)
{
// Velocity was cleared out so we should just snap
newPosAfterVelocity = ScrollContainer.transform.localPosition.y;
}
else
{
// Precalculate where the velocity falloff would land our scrollContainer, then round it to the nearest cell so it feels natural
velocitySnapshot = IterateFalloff(avgVelocity, out numSteps);
newPosAfterVelocity = initialScrollerPos.y - velocitySnapshot;
}
velocityDestinationPos.y = (Mathf.Round(newPosAfterVelocity / CellHeight)) * CellHeight;
CurrentVelocityState = VelocityState.Resolving;
}
else
{
if (avgVelocity == 0.0f)
{
// Velocity was cleared out so we should just snap
newPosAfterVelocity = ScrollContainer.transform.localPosition.x;
}
else
{
// Precalculate where the velocity falloff would land our scrollContainer, then round it to the nearest cell so it feels natural
velocitySnapshot = IterateFalloff(avgVelocity, out numSteps);
newPosAfterVelocity = initialScrollerPos.x + velocitySnapshot;
}
velocityDestinationPos.x = (Mathf.Round(newPosAfterVelocity / CellWidth)) * CellWidth;
CurrentVelocityState = VelocityState.Resolving;
}
workingScrollerPos = Solver.SmoothTo(scrollContainer.transform.localPosition, velocityDestinationPos, Time.deltaTime, BounceLerpInterval);
// Clear the velocity now that we've applied a new position
avgVelocity = 0.0f;
break;
case VelocityState.Resolving:
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
if (ScrollContainer.transform.localPosition.y > MaxY
|| ScrollContainer.transform.localPosition.y < minY)
{
CurrentVelocityState = VelocityState.Bouncing;
velocitySnapshot = 0.0f;
break;
}
else
{
workingScrollerPos = Solver.SmoothTo(ScrollContainer.transform.localPosition, velocityDestinationPos, Time.deltaTime, BounceLerpInterval);
SnapVelocityFinish();
}
}
else
{
if (ScrollContainer.transform.localPosition.x > maxX + (FrontTouchDistance * bounceMultiplier)
|| ScrollContainer.transform.localPosition.x < MinX - (FrontTouchDistance * bounceMultiplier))
{
CurrentVelocityState = VelocityState.Bouncing;
velocitySnapshot = 0.0f;
break;
}
else
{
workingScrollerPos = Solver.SmoothTo(ScrollContainer.transform.localPosition, velocityDestinationPos, Time.deltaTime, BounceLerpInterval);
SnapVelocityFinish();
}
}
break;
case VelocityState.Bouncing:
HandleBounceState();
break;
case VelocityState.None:
default:
// clean up our position for next frame
initialScrollerPos = workingScrollerPos;
break;
}
}
///
/// Handles drag release behavior when is set to
///
private void HandleFalloffPerFrame()
{
switch (CurrentVelocityState)
{
case VelocityState.Calculating:
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
workingScrollerPos.y = initialScrollerPos.y + avgVelocity;
}
else
{
workingScrollerPos.x = initialScrollerPos.x + avgVelocity;
}
CurrentVelocityState = VelocityState.Resolving;
// clean up our position for next frame
initialScrollerPos = workingScrollerPos;
break;
case VelocityState.Resolving:
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
if (ScrollContainer.transform.localPosition.y > MaxY + (FrontTouchDistance * bounceMultiplier)
|| ScrollContainer.transform.localPosition.y < minY - (FrontTouchDistance * bounceMultiplier))
{
CurrentVelocityState = VelocityState.Bouncing;
avgVelocity = 0.0f;
break;
}
else
{
avgVelocity *= velocityDampen;
workingScrollerPos.y = initialScrollerPos.y + avgVelocity;
SnapVelocityFinish();
}
}
else
{
if (ScrollContainer.transform.localPosition.x > maxX + (FrontTouchDistance * bounceMultiplier)
|| ScrollContainer.transform.localPosition.x < MinX - (FrontTouchDistance * bounceMultiplier))
{
CurrentVelocityState = VelocityState.Bouncing;
avgVelocity = 0.0f;
break;
}
else
{
avgVelocity *= velocityDampen;
workingScrollerPos.x = initialScrollerPos.x + avgVelocity;
SnapVelocityFinish();
}
}
// clean up our position for next frame
initialScrollerPos = workingScrollerPos;
break;
case VelocityState.Bouncing:
HandleBounceState();
break;
}
}
///
/// Smooths 's position to the proper clamped edge
/// while is .
///
private void HandleBounceState()
{
Vector3 clampedDest = new Vector3(Mathf.Clamp(ScrollContainer.transform.localPosition.x, MinX, maxX), Mathf.Clamp(ScrollContainer.transform.localPosition.y, minY, MaxY), 0.0f);
if ((scrollDirection == ScrollDirectionType.UpAndDown && Mathf.Approximately(ScrollContainer.transform.localPosition.y, clampedDest.y))
|| (scrollDirection == ScrollDirectionType.LeftAndRight && Mathf.Approximately(ScrollContainer.transform.localPosition.x, clampedDest.x)))
{
CurrentVelocityState = VelocityState.None;
// clean up our position for next frame
initialScrollerPos = workingScrollerPos = clampedDest;
return;
}
workingScrollerPos.y = Solver.SmoothTo(ScrollContainer.transform.localPosition, clampedDest, Time.deltaTime, BounceLerpInterval).y;
workingScrollerPos.x = Solver.SmoothTo(ScrollContainer.transform.localPosition, clampedDest, Time.deltaTime, BounceLerpInterval).x;
}
///
/// Snaps to the final position of the once velocity as resolved.
///
private void SnapVelocityFinish()
{
if (Vector3.Distance(ScrollContainer.transform.localPosition, workingScrollerPos) > Mathf.Epsilon)
{
return;
}
if (typeOfVelocity == VelocityType.FalloffPerItem)
{
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
// Ensure we've actually snapped the position to prevent an extreme in-between state
workingScrollerPos.y = (Mathf.Round(ScrollContainer.transform.localPosition.y / CellHeight)) * CellHeight;
}
else
{
workingScrollerPos.x = (Mathf.Round(ScrollContainer.transform.localPosition.x / CellWidth)) * CellWidth;
}
}
CurrentVelocityState = VelocityState.None;
avgVelocity = 0.0f;
// clean up our position for next frame
initialScrollerPos = workingScrollerPos;
}
///
/// Wrapper for per frame velocity calculation and filtering.
///
private void CalculateVelocity()
{
// Update simple velocity
TryGetPointerPositionOnPlane(out Vector3 newPos);
scrollVelocity = (scrollDirection == ScrollDirectionType.UpAndDown)
? (newPos.y - lastPointerPos.y) / Time.deltaTime * velocityMultiplier
: (newPos.x - lastPointerPos.x) / Time.deltaTime * velocityMultiplier;
// And filter it...
avgVelocity = (avgVelocity * (1.0f - velocityFilterWeight)) + (scrollVelocity * velocityFilterWeight);
}
///
/// The Animation Override to position our scroller based on manual movement , ,
///
/// The start position of the scrollContainer
/// Where we want the scrollContainer to end up, typically this should be
/// representing the easing desired
/// Time for animation, in seconds
/// Optional callback action to be invoked after animation coroutine has finished
private IEnumerator AnimateTo(Vector3 initialPos, Vector3 finalPos, AnimationCurve curve = null, float? time = null, System.Action callback = null)
{
if (curve == null)
{
curve = paginationCurve;
}
if (time == null)
{
time = animationLength;
}
float counter = 0.0f;
while (counter <= time)
{
workingScrollerPos = Vector3.Lerp(initialPos, finalPos, curve.Evaluate(counter / (float)time));
ScrollContainer.transform.localPosition = workingScrollerPos;
counter += Time.deltaTime;
yield return null;
}
// Update our values so they stick
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
workingScrollerPos.y = initialScrollerPos.y = finalPos.y;
}
else
{
workingScrollerPos.x = initialScrollerPos.x = finalPos.x;
}
if (callback != null)
{
callback?.Invoke();
}
CurrentVelocityState = VelocityState.None;
animateScroller = null;
}
///
/// Checks if the engaged joint has released the scrollable list
///
private bool DetectScrollRelease(Vector3 pointerPos)
{
Vector3 scrollToPointerVector = pointerPos - ClipBox.transform.position;
// Projecting vector onto every clip box space coordinate and using clip box lossy scale as reference to dimensions to scroll view visible bounds
// Using dot product to check if pointer is in front or behind the scroll view plane
bool isScrollRelease = Vector3.Magnitude(Vector3.Project(scrollToPointerVector, ClipBox.transform.up)) > ClipBox.transform.lossyScale.y / 2 + releaseThresholdTopBottom
|| Vector3.Magnitude(Vector3.Project(scrollToPointerVector, ClipBox.transform.right)) > ClipBox.transform.lossyScale.x / 2 + releaseThresholdLeftRight
|| (Vector3.Dot(scrollToPointerVector, transform.forward) > 0 ?
Vector3.Magnitude(Vector3.Project(scrollToPointerVector, ClipBox.transform.forward)) > ClipBox.transform.lossyScale.z / 2 + releaseThresholdBack :
Vector3.Magnitude(Vector3.Project(scrollToPointerVector, ClipBox.transform.forward)) > ClipBox.transform.lossyScale.z / 2 + releaseThresholdFront);
return isScrollRelease;
}
private bool HasPassedThroughFrontPlane(PokePointer pokePointer)
{
var p = transform.InverseTransformPoint(pokePointer.PreviousPosition);
return p.z <= -FrontTouchDistance;
}
///
/// Adds list of renderers to the ClippingBox
///
private void AddRenderersToClippingObject(List renderers)
{
foreach (var renderer in renderers)
{
ClipBox.AddRenderer(renderer);
}
}
///
/// Removes list of renderers from the ClippingBox
///
private void RemoveRenderersFromClippingObject(List renderers)
{
foreach (var renderer in renderers)
{
ClipBox.RemoveRenderer(renderer);
}
}
///
/// Removes all renderers currently being clipped by the clipping box
///
private void ClearClippingBox(bool autoDestroyMaterial = true)
{
ClipBox.ClearRenderers(autoDestroyMaterial);
}
///
/// Helper to perform division operations and prevent division by 0.
///
private static int SafeDivisionInt(int numerator, int denominator)
{
return (denominator != 0) ? numerator / denominator : 0;
}
private float SafeDivisionFloat(float numerator, float denominator)
{
return (denominator != 0) ? numerator / denominator : 0;
}
///
/// Checks visibility of scroll content by iterating through all content renderers and colliders.
/// All inactive content objects and colliders are reactivated during visibility restoration.
///
private void ManageVisibility(bool isRestoringVisibility = false)
{
if (!MaskEnabled && !isRestoringVisibility)
{
return;
}
ClippingBoundsCollider.enabled = true;
Bounds clippingThresholdBounds = ClippingBoundsCollider.bounds;
Renderer[] contentRenderers = ScrollContainer.GetComponentsInChildren(true);
clippedRenderers.Clear();
clippedRenderers.UnionWith(ClipBox.GetRenderersCopy());
// Remove all renderers from clipping primitive that are not part of scroll content
foreach (var clippedRenderer in clippedRenderers)
{
if (clippedRenderer != null && !clippedRenderer.transform.IsChildOf(ScrollContainer.transform))
{
if (disableClippedGameObjects)
{
if (!clippedRenderer.gameObject.activeSelf)
{
clippedRenderer.gameObject.SetActive(true);
}
}
if (disableClippedRenderers)
{
if (!clippedRenderer.enabled)
{
clippedRenderer.enabled = true;
}
}
renderersToUnclip.Add(clippedRenderer);
}
}
// Check render visibility
foreach (var renderer in contentRenderers)
{
// All content renderers should be added to clipping primitive
if (!isRestoringVisibility && MaskEnabled && !clippedRenderers.Contains(renderer))
{
renderersToClip.Add(renderer);
}
// Complete or partially visible renders should be clipped and its game object should be active
if (isRestoringVisibility
|| clippingThresholdBounds.ContainsBounds(renderer.bounds)
|| clippingThresholdBounds.Intersects(renderer.bounds))
{
if (disableClippedGameObjects)
{
if (!renderer.gameObject.activeSelf)
{
renderer.gameObject.SetActive(true);
}
}
if (disableClippedRenderers)
{
if (!renderer.enabled)
{
renderer.enabled = true;
}
}
}
// Hidden renderer game objects should be inactive
else
{
if (disableClippedGameObjects)
{
if (renderer.gameObject.activeSelf)
{
renderer.gameObject.SetActive(false);
}
}
if (disableClippedRenderers)
{
if (renderer.enabled)
{
renderer.enabled = false;
}
}
}
}
// Check collider visibility
if (Application.isPlaying)
{
// Outer clipping bounds is used to ensure collider has minimum visibility to stay enabled
Bounds outerClippingThresholdBounds = ClippingBoundsCollider.bounds;
outerClippingThresholdBounds.size *= contentVisibilityThresholdRatio;
var colliders = ScrollContainer.GetComponentsInChildren(true);
foreach (var collider in colliders)
{
// Disabling content colliders during drag to stop interaction even if game object is inactive
if (!isRestoringVisibility && IsDragging)
{
if (collider.enabled)
{
collider.enabled = false;
}
continue;
}
// No need to manage collider visibility in case game object is inactive and no pointer is dragging the scroll
if (!isRestoringVisibility && !collider.gameObject.activeSelf)
{
continue;
}
// Temporary activating for getting bounds
var wasColliderEnabled = collider.enabled;
if (!wasColliderEnabled)
{
collider.enabled = true;
}
// Completely or partially visible colliders should be enabled if scroll is not drag engaged
if (isRestoringVisibility || outerClippingThresholdBounds.ContainsBounds(collider.bounds))
{
if (!wasColliderEnabled)
{
wasColliderEnabled = true;
}
}
// Hidden colliders should be disabled
else
{
if (wasColliderEnabled)
{
wasColliderEnabled = false;
}
}
// Update collider state or revert to previous state
collider.enabled = wasColliderEnabled;
}
}
ClippingBoundsCollider.enabled = false;
if (!isRestoringVisibility)
{
ReconcileClippingContent();
}
}
///
/// Precalculates the total amount of travel given the scroller's current average velocity and drag.
///
/// Number of steps to get our to effectively "zero" (0.00001).
/// The total distance the with as drag would travel.
private float IterateFalloff(float vel, out int steps)
{
// Some day this should be a falloff formula, below is the number of steps. Just can't figure out how to get the right velocity.
// float numSteps = (Mathf.Log(0.00001f) - Mathf.Log(Mathf.Abs(avgVelocity))) / Mathf.Log(velocityFalloff);
float newVal = 0.0f;
float v = vel;
steps = 0;
while (Mathf.Abs(v) > 0.00001)
{
v *= velocityDampen;
newVal += v;
steps++;
}
return newVal;
}
///
/// Applies to the of our
///
/// The new desired position for in local space
private void ApplyPosition(Vector3 workingPos)
{
Vector3 newScrollPos;
switch (scrollDirection)
{
case ScrollDirectionType.UpAndDown:
default:
newScrollPos = new Vector3(ScrollContainer.transform.localPosition.x, workingPos.y, 0.0f);
break;
case ScrollDirectionType.LeftAndRight:
newScrollPos = new Vector3(workingPos.x, ScrollContainer.transform.localPosition.y, 0.0f);
break;
}
ScrollContainer.transform.localPosition = newScrollPos;
}
///
/// Resets the interaction state of the ScrollingObjectCollection for the next scroll.
///
private void ResetInteraction()
{
OnTouchEnded?.Invoke(initialFocusedObject);
// Release the pointer
if (currentPointer != null) currentPointer.IsFocusLocked = false;
currentPointer = null;
initialFocusedObject = null;
// Clear our states
IsTouched = false;
IsEngaged = false;
IsDragging = false;
}
///
/// Resets the scroll offset state of the ScrollingObjectCollection.
///
private void ResetScrollOffset()
{
MoveToIndex(0, false);
workingScrollerPos = Vector3.zero;
ApplyPosition(workingScrollerPos);
}
///
/// All inactive content objects and colliders are reactivated and renderers are unclipped.
///
private void RestoreContentVisibility(bool autoDestroyMaterial = true)
{
ClearClippingBox(autoDestroyMaterial);
ManageVisibility(true);
}
///
/// Moves the scroll container to the position that makes the tier with the tierIndex the first in the viewable area
///
private void MoveToTier(int tierIndex, bool animateToPosition = true, System.Action callback = null)
{
if (animateScroller != null)
{
CurrentVelocityState = VelocityState.None;
StopAllCoroutines();
}
if (scrollDirection == ScrollDirectionType.UpAndDown)
{
workingScrollerPos.y = tierIndex * CellHeight;
// Clamp the working pos since we already have calculated it
workingScrollerPos.y = Mathf.Clamp(workingScrollerPos.y, minY, MaxY);
// Zero out the other axes
workingScrollerPos = workingScrollerPos.Mul(Vector3.up);
}
else
{
workingScrollerPos.x = tierIndex * CellWidth * -1.0f;
// Clamp the working pos since we already have calculated it
workingScrollerPos.x = Mathf.Clamp(workingScrollerPos.x, MinX, maxX);
// Zero out the other axes
workingScrollerPos = workingScrollerPos.Mul(Vector3.right);
}
if (initialScrollerPos != workingScrollerPos)
{
CurrentVelocityState = VelocityState.Animating;
if (animateToPosition)
{
animateScroller = AnimateTo(ScrollContainer.transform.localPosition, workingScrollerPos, paginationCurve, animationLength, callback);
StartCoroutine(animateScroller);
}
else
{
CurrentVelocityState = VelocityState.None; // Flagging the instant position change to trigger momentum events
initialScrollerPos = workingScrollerPos;
}
if (callback != null)
{
callback?.Invoke();
}
}
}
#endregion private methods
#region public methods
///
/// Resets the ScrollingObjectCollection
///
public void Reset()
{
ResetInteraction();
UpdateContent();
ResetScrollOffset();
}
///
/// Safely adds a child game object to scroll collection.
///
public void AddContent(GameObject content)
{
content.transform.parent = ScrollContainer.transform;
Reset();
}
///
/// Safely removes a child game object from scroll content and clipping box.
///
public void RemoveItem(GameObject item)
{
if (item == null)
{
return;
}
var itemRenderers = item.GetComponentsInChildren();
if (itemRenderers != null)
{
foreach (var renderer in itemRenderers)
{
renderersToUnclip.Add(renderer);
}
}
item.transform.parent = null;
Reset();
}
///
/// Checks whether the given cell is visible relative to viewable area or page.
///
/// the index of the pagination cell
/// true when cell is visible
public bool IsCellVisible(int cellIndex)
{
bool isCellVisible = true;
if (cellIndex < FirstVisibleCellIndex)
{
// It's above the visible area
isCellVisible = false;
}
else if (cellIndex >= FirstHiddenCellIndex)
{
// It's below the visible area
isCellVisible = false;
}
return isCellVisible;
}
///
/// Moves scroller container by a multiplier of the number of tiers in the viewable area.
///
/// Amount of pages to move by
/// If true, scroller will animate to new position
/// An optional action to pass in to get notified that the is finished moving
public void MoveByPages(int numberOfPages, bool animate = true, System.Action callback = null)
{
int tierIndex = SafeDivisionInt(FirstVisibleCellIndex, CellsPerTier) + (numberOfPages * TiersPerPage);
MoveToTier(tierIndex, animate, callback);
}
///
/// Moves scroller container a relative number of tiers of cells.
///
/// Amount of tiers to move by
/// if true, scroller will animate to new position
/// An optional action to pass in to get notified that the is finished moving
public void MoveByTiers(int numberOfTiers, bool animate = true, System.Action callback = null)
{
int tierIndex = SafeDivisionInt(FirstVisibleCellIndex, CellsPerTier) + numberOfTiers;
MoveToTier(tierIndex, animate, callback);
}
///
/// Moves scroller container to a position where the selected cell is in the first tier of the viewable area.
///
/// Index of the cell to move to
/// if true, scroller will animate to new position
/// An optional action to pass in to get notified that the is finished moving
public void MoveToIndex(int cellIndex, bool animateToPosition = true, System.Action callback = null)
{
cellIndex = (cellIndex < 0) ? 0 : cellIndex;
int tierIndex = SafeDivisionInt(cellIndex, CellsPerTier);
MoveToTier(tierIndex, animateToPosition, callback);
}
#endregion public methods
#region IMixedRealityPointerHandler implementation
///
void IMixedRealityPointerHandler.OnPointerUp(MixedRealityPointerEventData eventData)
{
if (currentPointer == null || eventData.Pointer.PointerId != currentPointer.PointerId)
{
return;
}
// Release the pointer
currentPointer.IsTargetPositionLockedOnFocusLock = oldIsTargetPositionLockedOnFocusLock;
if (!IsTouched && IsEngaged && animateScroller == null)
{
if (IsDragging)
{
// Its a drag release
initialScrollerPos = workingScrollerPos;
CurrentVelocityState = VelocityState.Calculating;
}
ResetInteraction();
}
}
///
void IMixedRealityPointerHandler.OnPointerDown(MixedRealityPointerEventData eventData)
{
// Current pointer owns scroll interaction until scroll release happens. Ignoring any interaction with other pointers.
if (currentPointer != null)
{
return;
}
var selectedObject = eventData.Pointer.Result?.CurrentPointerTarget;
if (selectedObject == null || !selectedObject.transform.IsChildOf(transform))
{
return;
}
currentPointer = eventData.Pointer;
oldIsTargetPositionLockedOnFocusLock = currentPointer.IsTargetPositionLockedOnFocusLock;
if (!(currentPointer is IMixedRealityNearPointer) && currentPointer.Controller.IsRotationAvailable)
{
currentPointer.IsTargetPositionLockedOnFocusLock = false;
}
initialFocusedObject = selectedObject;
currentPointer.IsFocusLocked = false; // Unwanted focus locked on children items
// Reset the scroll state
scrollVelocity = 0.0f;
if (TryGetPointerPositionOnPlane(out initialPointerPos))
{
initialScrollerPos = ScrollContainer.transform.localPosition;
CurrentVelocityState = VelocityState.None;
IsTouched = false;
IsEngaged = true;
IsDragging = false;
OnTouchStarted?.Invoke(initialFocusedObject);
}
}
///
/// Pointer Click handled during Update.
void IMixedRealityPointerHandler.OnPointerClicked(MixedRealityPointerEventData eventData) { }
///
void IMixedRealityPointerHandler.OnPointerDragged(MixedRealityPointerEventData eventData) { }
#endregion IMixedRealityPointerHandler implementation
#region IMixedRealityTouchHandler implementation
///
void IMixedRealityTouchHandler.OnTouchStarted(HandTrackingInputEventData eventData)
{
// Current pointer owns scroll interaction until scroll release happens. Ignoring any interaction with other pointers.
if (currentPointer != null)
{
return;
}
PokePointer pokePointer = PointerUtils.GetPointer(eventData.Handedness);
var selectedObject = pokePointer.Result?.CurrentPointerTarget;
if (selectedObject == null || !selectedObject.transform.IsChildOf(transform))
{
return;
}
if (!HasPassedThroughFrontPlane(pokePointer))
{
return;
}
currentPointer = pokePointer;
StopAllCoroutines();
CurrentVelocityState = VelocityState.None;
animateScroller = null;
if (!IsTouched && !IsEngaged)
{
initialPointerPos = currentPointer.Position;
initialFocusedObject = selectedObject;
initialScrollerPos = ScrollContainer.transform.localPosition;
IsTouched = true;
IsEngaged = true;
IsDragging = false;
OnTouchStarted?.Invoke(initialFocusedObject);
}
}
///
/// Touch release handled during Update.
void IMixedRealityTouchHandler.OnTouchCompleted(HandTrackingInputEventData eventData) { }
///
void IMixedRealityTouchHandler.OnTouchUpdated(HandTrackingInputEventData eventData)
{
if (currentPointer == null || eventData.SourceId != currentPointer.InputSourceParent.SourceId)
{
return;
}
if (IsDragging)
{
eventData.Use();
}
}
#endregion IMixedRealityTouchHandler implementation
#region IMixedRealitySourceStateHandler implementation
void IMixedRealitySourceStateHandler.OnSourceDetected(SourceStateEventData eventData) { }
void IMixedRealitySourceStateHandler.OnSourceLost(SourceStateEventData eventData)
{
if (currentPointer == null || eventData.SourceId != currentPointer.InputSourceParent.SourceId)
{
return;
}
// We'll consider this a drag release
if (IsEngaged && animateScroller == null)
{
if (IsTouched || IsDragging)
{
// Its a drag release
initialScrollerPos = workingScrollerPos;
}
ResetInteraction();
CurrentVelocityState = VelocityState.Calculating;
}
}
#endregion IMixedRealitySourceStateHandler implementation
}
}