// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Microsoft.MixedReality.Toolkit.Input;
using System;
using UnityEngine;
namespace Microsoft.MixedReality.Toolkit.UI
{
///
/// A slider that can be moved by grabbing / pinching a slider thumb
///
[HelpURL("https://docs.microsoft.com/windows/mixed-reality/mrtk-unity/features/ux-building-blocks/sliders")]
[AddComponentMenu("Scripts/MRTK/SDK/PinchSlider")]
public class PinchSlider : MonoBehaviour, IMixedRealityPointerHandler, IMixedRealityFocusHandler, IMixedRealityTouchHandler
{
#region Serialized Fields and Public Properties
[Tooltip("The gameObject that contains the slider thumb.")]
[SerializeField]
private GameObject thumbRoot = null;
public GameObject ThumbRoot
{
get
{
return thumbRoot;
}
set
{
thumbRoot = value;
InitializeSliderThumb();
}
}
[SerializeField]
[Tooltip("Whether or not this slider is controllable via touch events")]
private bool isTouchable;
///
/// Property accessor of isTouchable. Determines whether or not this slider is controllable via touch events
///
public bool IsTouchable
{
get { return isTouchable; }
set { isTouchable = value; }
}
[SerializeField]
[Tooltip("Whether or not this slider snaps to the designated position on the slider")]
private bool snapToPosition;
///
/// Property accessor of snapToPosition. Determines whether or not this slider snaps to the designated position on the slider
///
public bool SnapToPosition
{
get { return snapToPosition; }
set
{
snapToPosition = value;
if (!touchCollider.IsNull())
{
touchCollider.enabled = value;
}
if (!thumbCollider.IsNull())
{
thumbCollider.enabled = !value;
}
}
}
[SerializeField]
///
/// Used to control the slider on the track when snapToPosition is false
///
private Collider thumbCollider = null;
///
/// Property accessor of thumbCollider. Used to control the slider on the track when snapToPosition is false
///
public Collider ThumbCollider
{
get { return thumbCollider; }
set { thumbCollider = value; }
}
[SerializeField]
///
/// Used to determine the position we snap the slider do when snapToPosition is true
///
private Collider touchCollider = null;
///
/// Property accessor of touchCollider. Used to determine the position we snap the slider do when snapToPosition is true
///
public Collider TouchCollider
{
get { return touchCollider; }
set { touchCollider = value; }
}
[Range(minVal, maxVal)]
[SerializeField]
private float sliderValue = 0.5f;
public float SliderValue
{
get { return sliderValue; }
set
{
var oldSliderValue = sliderValue;
sliderValue = value;
UpdateUI();
OnValueUpdated.Invoke(new SliderEventData(oldSliderValue, value, ActivePointer, this));
}
}
[SerializeField]
[Tooltip("Controls whether this slider is increments in steps or continuously")]
private bool useSliderStepDivisions;
///
/// Property accessor of useSliderStepDivisions, it determines whether the slider steps according to subdivisions
///
public bool UseSliderStepDivisions
{
get { return useSliderStepDivisions; }
set { useSliderStepDivisions = value; }
}
[SerializeField]
[Min(1)]
[Tooltip("Number of subdivisions the slider is split into.")]
private int sliderStepDivisions = 1;
///
/// Property accessor of sliderStepDivisions, it holds the number of subdivisions the slider is split into.
///
public int SliderStepDivisions
{
get { return sliderStepDivisions; }
set { sliderStepDivisions = value; }
}
[Header("Slider Axis Visuals")]
[Tooltip("The gameObject that contains the trackVisuals. This will get rotated to match the slider axis")]
[SerializeField]
private GameObject trackVisuals = null;
///
/// Property accessor of trackVisuals, it contains the desired track Visuals. This will get rotated to match the slider axis.
///
public GameObject TrackVisuals
{
get
{
return trackVisuals;
}
set
{
if (trackVisuals != value)
{
trackVisuals = value;
UpdateTrackVisuals();
}
}
}
[Tooltip("The gameObject that contains the tickMarks. This will get rotated to match the slider axis")]
[SerializeField]
private GameObject tickMarks = null;
///
/// Property accessor of tickMarks, it contains the desired tick Marks. This will get rotated to match the slider axis.
///
public GameObject TickMarks
{
get
{
return tickMarks;
}
set
{
if (tickMarks != value)
{
tickMarks = value;
UpdateTickMarks();
}
}
}
[Tooltip("The gameObject that contains the thumb visuals. This will get rotated to match the slider axis.")]
[SerializeField]
private GameObject thumbVisuals = null;
///
/// Property accessor of thumbVisuals, it contains the desired tick marks. This will get rotated to match the slider axis.
///
public GameObject ThumbVisuals
{
get
{
return thumbVisuals;
}
set
{
if (thumbVisuals != value)
{
thumbVisuals = value;
UpdateThumbVisuals();
}
}
}
[Header("Slider Track")]
[Tooltip("The axis the slider moves along")]
[SerializeField]
private SliderAxis sliderAxis = SliderAxis.XAxis;
///
/// Property accessor of sliderAxis. The axis the slider moves along.
///
public SliderAxis CurrentSliderAxis
{
get { return sliderAxis; }
set
{
sliderAxis = value;
UpdateVisualsOrientation();
}
}
///
/// Previous value of slider axis, is used in order to detect change in current slider axis value
///
private SliderAxis? previousSliderAxis = null;
///
/// Property accessor for previousSliderAxis that is used also to initialize the property with the current value in case of null value.
///
private SliderAxis PreviousSliderAxis
{
get
{
if (previousSliderAxis == null)
{
previousSliderAxis = CurrentSliderAxis;
}
return previousSliderAxis.Value;
}
set
{
previousSliderAxis = value;
}
}
[SerializeField]
[Tooltip("Where the slider track starts, as distance from center along slider axis, in local space units.")]
private float sliderStartDistance = -.5f;
public float SliderStartDistance
{
get { return sliderStartDistance; }
set { sliderStartDistance = value; }
}
[SerializeField]
[Tooltip("Where the slider track ends, as distance from center along slider axis, in local space units.")]
private float sliderEndDistance = .5f;
public float SliderEndDistance
{
get { return sliderEndDistance; }
set { sliderEndDistance = value; }
}
///
/// Gets the start position of the slider, in world space, or zero if invalid.
/// Sets the start position of the slider, in world space, projected to the slider's axis.
///
public Vector3 SliderStartPosition
{
get { return transform.TransformPoint(GetSliderAxis() * sliderStartDistance); }
set { sliderStartDistance = Vector3.Dot(transform.InverseTransformPoint(value), GetSliderAxis()); }
}
///
/// Gets the end position of the slider, in world space, or zero if invalid.
/// Sets the end position of the slider, in world space, projected to the slider's axis.
///
public Vector3 SliderEndPosition
{
get { return transform.TransformPoint(GetSliderAxis() * sliderEndDistance); }
set { sliderEndDistance = Vector3.Dot(transform.InverseTransformPoint(value), GetSliderAxis()); }
}
///
/// Returns the vector from the slider start to end positions
///
public Vector3 SliderTrackDirection
{
get { return SliderEndPosition - SliderStartPosition; }
}
#endregion
#region Event Handlers
[Header("Events")]
public SliderEvent OnValueUpdated = new SliderEvent();
public SliderEvent OnInteractionStarted = new SliderEvent();
public SliderEvent OnInteractionEnded = new SliderEvent();
public SliderEvent OnHoverEntered = new SliderEvent();
public SliderEvent OnHoverExited = new SliderEvent();
#endregion
#region Private Fields
///
/// Position offset for slider handle in world space.
///
private Vector3 sliderThumbOffset = Vector3.zero;
///
/// Private member used to adjust slider values
///
private float sliderStepVal => (maxVal - minVal) / sliderStepDivisions;
#endregion
#region Protected Properties
///
/// Float value that holds the starting value of the slider.
///
protected float StartSliderValue { get; private set; }
///
/// Starting position of mixed reality pointer in world space
/// Used to track pointer movement
///
protected Vector3 StartPointerPosition { get; private set; }
///
/// Interface for handling pointer being used in UX interaction.
///
protected IMixedRealityPointer ActivePointer { get; private set; }
#endregion
#region Constants
///
/// Minimum distance between start and end of slider, in world space
///
private const float MinSliderLength = 0.001f;
///
/// The minimum value that the slider can take on
///
private const float minVal = 0.0f;
///
/// The maximum value that the slider can take on
///
private const float maxVal = 1.0f;
#endregion
#region Unity methods
protected virtual void Start()
{
if (useSliderStepDivisions)
{
InitializeStepDivisions();
}
if (thumbRoot == null)
{
throw new Exception($"Slider thumb on gameObject {gameObject.name} is not specified. Did you forget to set it?");
}
SnapToPosition = snapToPosition;
InitializeSliderThumb();
OnValueUpdated.Invoke(new SliderEventData(sliderValue, sliderValue, null, this));
}
private void OnDisable()
{
if (ActivePointer != null)
{
EndInteraction();
}
}
private void OnValidate()
{
CurrentSliderAxis = sliderAxis;
}
#endregion
#region Private Methods
private void InitializeSliderThumb()
{
var startToThumb = thumbRoot.transform.position - SliderStartPosition;
var thumbProjectedOnTrack = SliderStartPosition + Vector3.Project(startToThumb, SliderTrackDirection);
sliderThumbOffset = thumbRoot.transform.position - thumbProjectedOnTrack;
UpdateUI();
}
///
/// Private method used to adjust initial slider value to stepwise values
///
private void InitializeStepDivisions()
{
SliderValue = SnapSliderToStepPositions(SliderValue);
}
///
/// Update orientation of track visuals based on slider axis orientation
///
private void UpdateTrackVisuals()
{
if (TrackVisuals)
{
TrackVisuals.transform.localPosition = Vector3.zero;
switch (sliderAxis)
{
case SliderAxis.XAxis:
TrackVisuals.transform.localRotation = Quaternion.identity;
break;
case SliderAxis.YAxis:
TrackVisuals.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, 90.0f);
break;
case SliderAxis.ZAxis:
TrackVisuals.transform.localRotation = Quaternion.Euler(0.0f, 90.0f, 0.0f);
break;
}
}
}
///
/// Update orientation of tick marks based on slider axis orientation
///
private void UpdateTickMarks()
{
if (TickMarks)
{
TickMarks.transform.localPosition = Vector3.zero;
TickMarks.transform.localRotation = Quaternion.identity;
var grid = TickMarks.GetComponent();
if (grid)
{
// Update cellwidth or cellheight depending on what was the previous axis set to
var previousAxis = grid.Layout;
if (previousAxis == Utilities.LayoutOrder.Vertical)
{
grid.CellWidth = grid.CellHeight;
}
else
{
grid.CellHeight = grid.CellWidth;
}
grid.Layout = (sliderAxis == SliderAxis.YAxis) ? Utilities.LayoutOrder.Vertical : Utilities.LayoutOrder.Horizontal;
grid.UpdateCollection();
}
if (sliderAxis == SliderAxis.ZAxis)
{
TickMarks.transform.localRotation = Quaternion.Euler(0.0f, 90.0f, 0.0f);
}
}
}
///
/// Update orientation of thumb visuals based on slider axis orientation
///
private void UpdateThumbVisuals()
{
if (ThumbVisuals)
{
ThumbVisuals.transform.localPosition = Vector3.zero;
switch (sliderAxis)
{
case SliderAxis.XAxis:
ThumbVisuals.transform.localRotation = Quaternion.identity;
break;
case SliderAxis.YAxis:
ThumbVisuals.transform.localRotation = Quaternion.Euler(0.0f, 0.0f, 90.0f);
break;
case SliderAxis.ZAxis:
ThumbVisuals.transform.localRotation = Quaternion.Euler(0.0f, 90.0f, 0.0f);
break;
}
}
}
///
/// Update orientation of the visual components of pinch slider
///
private void UpdateVisualsOrientation()
{
if (PreviousSliderAxis != sliderAxis)
{
UpdateThumbVisuals();
UpdateTrackVisuals();
UpdateTickMarks();
PreviousSliderAxis = sliderAxis;
}
}
private Vector3 GetSliderAxis()
{
switch (sliderAxis)
{
case SliderAxis.XAxis:
return Vector3.right;
case SliderAxis.YAxis:
return Vector3.up;
case SliderAxis.ZAxis:
return Vector3.forward;
default:
throw new ArgumentOutOfRangeException("Invalid slider axis");
}
}
private void UpdateUI()
{
var newSliderPos = SliderStartPosition + sliderThumbOffset + SliderTrackDirection * sliderValue;
thumbRoot.transform.position = newSliderPos;
}
private void EndInteraction()
{
if (OnInteractionEnded != null)
{
OnInteractionEnded.Invoke(new SliderEventData(sliderValue, sliderValue, ActivePointer, this));
}
ActivePointer = null;
}
private float SnapSliderToStepPositions(float value)
{
var stepCount = value / sliderStepVal;
var snappedValue = sliderStepVal * Mathf.RoundToInt(stepCount);
Mathf.Clamp(snappedValue, minVal, maxVal);
return snappedValue;
}
private void CalculateSliderValueBasedOnTouchPoint(Vector3 touchPoint)
{
var sliderTouchPoint = touchPoint - SliderStartPosition;
var sliderVector = SliderEndPosition - SliderStartPosition;
// If our touch point goes off the start side of the slider, set it's value to minVal and return immediately
// Explanation of the math here: https://www.quora.com/Can-scalar-projection-be-negative
if (Vector3.Dot(sliderTouchPoint, sliderVector) < 0)
{
SliderValue = minVal;
return;
}
float sliderProgress = Vector3.Project(sliderTouchPoint, sliderVector).magnitude;
float result = sliderProgress / sliderVector.magnitude;
float clampedResult = result;
if (UseSliderStepDivisions)
{
clampedResult = SnapSliderToStepPositions(result);
}
clampedResult = Mathf.Clamp(clampedResult, minVal, maxVal);
SliderValue = clampedResult;
}
#endregion
#region IMixedRealityFocusHandler
public void OnFocusEnter(FocusEventData eventData)
{
OnHoverEntered.Invoke(new SliderEventData(sliderValue, sliderValue, eventData.Pointer, this));
}
public void OnFocusExit(FocusEventData eventData)
{
OnHoverExited.Invoke(new SliderEventData(sliderValue, sliderValue, eventData.Pointer, this));
}
#endregion
#region IMixedRealityPointerHandler
public void OnPointerUp(MixedRealityPointerEventData eventData)
{
if (eventData.Pointer == ActivePointer && !eventData.used)
{
EndInteraction();
// Mark the pointer data as used to prevent other behaviors from handling input events
eventData.Use();
}
}
public void OnPointerDown(MixedRealityPointerEventData eventData)
{
if (ActivePointer == null && !eventData.used)
{
ActivePointer = eventData.Pointer;
StartPointerPosition = ActivePointer.Position;
if (SnapToPosition)
{
CalculateSliderValueBasedOnTouchPoint(ActivePointer.Result.Details.Point);
}
if (OnInteractionStarted != null)
{
OnInteractionStarted.Invoke(new SliderEventData(sliderValue, sliderValue, ActivePointer, this));
}
StartSliderValue = sliderValue;
// Mark the pointer data as used to prevent other behaviors from handling input events
eventData.Use();
}
}
public virtual void OnPointerDragged(MixedRealityPointerEventData eventData)
{
if (eventData.Pointer == ActivePointer && !eventData.used)
{
var delta = ActivePointer.Position - StartPointerPosition;
var handDelta = Vector3.Dot(SliderTrackDirection.normalized, delta);
if (useSliderStepDivisions)
{
var stepVal = (handDelta / SliderTrackDirection.magnitude > 0) ? sliderStepVal : (sliderStepVal * -1);
var stepMag = Mathf.Floor(Mathf.Abs(handDelta / SliderTrackDirection.magnitude) / sliderStepVal);
SliderValue = Mathf.Clamp(StartSliderValue + (stepVal * stepMag), 0, 1);
}
else
{
SliderValue = Mathf.Clamp(StartSliderValue + handDelta / SliderTrackDirection.magnitude, 0, 1);
}
// Mark the pointer data as used to prevent other behaviors from handling input events
eventData.Use();
}
}
public void OnPointerClicked(MixedRealityPointerEventData eventData) { }
#endregion
#region IMixedRealityTouchHandler
public void OnTouchStarted(HandTrackingInputEventData eventData)
{
if (isTouchable)
{
if (OnInteractionStarted != null)
{
OnInteractionStarted.Invoke(new SliderEventData(sliderValue, sliderValue, ActivePointer, this));
}
eventData.Use();
}
}
public void OnTouchCompleted(HandTrackingInputEventData eventData)
{
if (isTouchable)
{
if (!eventData.used)
{
EndInteraction();
// Mark the pointer data as used to prevent other behaviors from handling input events
eventData.Use();
}
}
}
/// b
/// When the collider is touched, use the touch point to Calculate the Slider value
///
public void OnTouchUpdated(HandTrackingInputEventData eventData)
{
if (isTouchable)
{
CalculateSliderValueBasedOnTouchPoint(eventData.InputData);
}
}
#endregion IMixedRealityTouchHandler
}
}