698 lines
23 KiB
C#
698 lines
23 KiB
C#
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT License.
|
|
|
|
using Microsoft.MixedReality.Toolkit.Input;
|
|
using System;
|
|
using UnityEngine;
|
|
|
|
namespace Microsoft.MixedReality.Toolkit.UI
|
|
{
|
|
/// <summary>
|
|
/// A slider that can be moved by grabbing / pinching a slider thumb
|
|
/// </summary>
|
|
[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;
|
|
|
|
/// <summary>
|
|
/// Property accessor of isTouchable. Determines whether or not this slider is controllable via touch events
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Property accessor of snapToPosition. Determines whether or not this slider snaps to the designated position on the slider
|
|
/// </summary>
|
|
public bool SnapToPosition
|
|
{
|
|
get { return snapToPosition; }
|
|
set
|
|
{
|
|
snapToPosition = value;
|
|
if (!touchCollider.IsNull())
|
|
{
|
|
touchCollider.enabled = value;
|
|
}
|
|
if (!thumbCollider.IsNull())
|
|
{
|
|
thumbCollider.enabled = !value;
|
|
}
|
|
}
|
|
}
|
|
|
|
[SerializeField]
|
|
/// <summary>
|
|
/// Used to control the slider on the track when snapToPosition is false
|
|
/// </summary>
|
|
private Collider thumbCollider = null;
|
|
|
|
/// <summary>
|
|
/// Property accessor of thumbCollider. Used to control the slider on the track when snapToPosition is false
|
|
/// </summary>
|
|
public Collider ThumbCollider
|
|
{
|
|
get { return thumbCollider; }
|
|
set { thumbCollider = value; }
|
|
}
|
|
|
|
[SerializeField]
|
|
/// <summary>
|
|
/// Used to determine the position we snap the slider do when snapToPosition is true
|
|
/// </summary>
|
|
private Collider touchCollider = null;
|
|
|
|
/// <summary>
|
|
/// Property accessor of touchCollider. Used to determine the position we snap the slider do when snapToPosition is true
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Property accessor of useSliderStepDivisions, it determines whether the slider steps according to subdivisions
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Property accessor of sliderStepDivisions, it holds the number of subdivisions the slider is split into.
|
|
/// </summary>
|
|
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;
|
|
/// <summary>
|
|
/// Property accessor of trackVisuals, it contains the desired track Visuals. This will get rotated to match the slider axis.
|
|
/// </summary>
|
|
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;
|
|
/// <summary>
|
|
/// Property accessor of tickMarks, it contains the desired tick Marks. This will get rotated to match the slider axis.
|
|
/// </summary>
|
|
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;
|
|
/// <summary>
|
|
/// Property accessor of thumbVisuals, it contains the desired tick marks. This will get rotated to match the slider axis.
|
|
/// </summary>
|
|
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;
|
|
/// <summary>
|
|
/// Property accessor of sliderAxis. The axis the slider moves along.
|
|
/// </summary>
|
|
public SliderAxis CurrentSliderAxis
|
|
{
|
|
get { return sliderAxis; }
|
|
set
|
|
{
|
|
sliderAxis = value;
|
|
UpdateVisualsOrientation();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Previous value of slider axis, is used in order to detect change in current slider axis value
|
|
/// </summary>
|
|
private SliderAxis? previousSliderAxis = null;
|
|
/// <summary>
|
|
/// Property accessor for previousSliderAxis that is used also to initialize the property with the current value in case of null value.
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public Vector3 SliderStartPosition
|
|
{
|
|
get { return transform.TransformPoint(GetSliderAxis() * sliderStartDistance); }
|
|
set { sliderStartDistance = Vector3.Dot(transform.InverseTransformPoint(value), GetSliderAxis()); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public Vector3 SliderEndPosition
|
|
{
|
|
get { return transform.TransformPoint(GetSliderAxis() * sliderEndDistance); }
|
|
set { sliderEndDistance = Vector3.Dot(transform.InverseTransformPoint(value), GetSliderAxis()); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the vector from the slider start to end positions
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Position offset for slider handle in world space.
|
|
/// </summary>
|
|
private Vector3 sliderThumbOffset = Vector3.zero;
|
|
|
|
|
|
/// <summary>
|
|
/// Private member used to adjust slider values
|
|
/// </summary>
|
|
private float sliderStepVal => (maxVal - minVal) / sliderStepDivisions;
|
|
|
|
#endregion
|
|
|
|
#region Protected Properties
|
|
|
|
/// <summary>
|
|
/// Float value that holds the starting value of the slider.
|
|
/// </summary>
|
|
protected float StartSliderValue { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Starting position of mixed reality pointer in world space
|
|
/// Used to track pointer movement
|
|
/// </summary>
|
|
protected Vector3 StartPointerPosition { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Interface for handling pointer being used in UX interaction.
|
|
/// </summary>
|
|
protected IMixedRealityPointer ActivePointer { get; private set; }
|
|
|
|
#endregion
|
|
|
|
#region Constants
|
|
/// <summary>
|
|
/// Minimum distance between start and end of slider, in world space
|
|
/// </summary>
|
|
private const float MinSliderLength = 0.001f;
|
|
|
|
/// <summary>
|
|
/// The minimum value that the slider can take on
|
|
/// </summary>
|
|
private const float minVal = 0.0f;
|
|
|
|
/// <summary>
|
|
/// The maximum value that the slider can take on
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Private method used to adjust initial slider value to stepwise values
|
|
/// </summary>
|
|
private void InitializeStepDivisions()
|
|
{
|
|
SliderValue = SnapSliderToStepPositions(SliderValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update orientation of track visuals based on slider axis orientation
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update orientation of tick marks based on slider axis orientation
|
|
/// </summary>
|
|
private void UpdateTickMarks()
|
|
{
|
|
if (TickMarks)
|
|
{
|
|
TickMarks.transform.localPosition = Vector3.zero;
|
|
TickMarks.transform.localRotation = Quaternion.identity;
|
|
|
|
var grid = TickMarks.GetComponent<Utilities.GridObjectCollection>();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update orientation of thumb visuals based on slider axis orientation
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update orientation of the visual components of pinch slider
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>b
|
|
/// When the collider is touched, use the touch point to Calculate the Slider value
|
|
/// </summary>
|
|
public void OnTouchUpdated(HandTrackingInputEventData eventData)
|
|
{
|
|
if (isTouchable)
|
|
{
|
|
CalculateSliderValueBasedOnTouchPoint(eventData.InputData);
|
|
}
|
|
}
|
|
|
|
#endregion IMixedRealityTouchHandler
|
|
}
|
|
}
|