// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.MixedReality.Toolkit.Input; using Microsoft.MixedReality.Toolkit.Utilities; using System.Collections.Generic; using UnityEngine; using UnityEngine.Events; using UnityEngine.Serialization; namespace Microsoft.MixedReality.Toolkit.UI { /// /// A button that can be pushed via direct touch. /// You can use to route these events to . /// [HelpURL("https://docs.microsoft.com/windows/mixed-reality/mrtk-unity/features/ux-building-blocks/button")] [AddComponentMenu("Scripts/MRTK/SDK/PressableButton")] public class PressableButton : MonoBehaviour, IMixedRealityTouchHandler { const string InitialMarkerTransformName = "Initial Marker"; bool hasStarted = false; /// /// The object that is being pushed. /// [SerializeField] [Tooltip("The object that is being pushed.")] protected GameObject movingButtonVisuals = null; /// /// Enum for defining space of plane distances. /// public enum SpaceMode { World, Local } [SerializeField] [Tooltip("Describes in which coordinate space the plane distances are stored and calculated")] private SpaceMode distanceSpaceMode = SpaceMode.Local; /// /// Describes in which coordinate space the plane distances are stored and calculated /// public SpaceMode DistanceSpaceMode { get => distanceSpaceMode; set { // Convert world to local distances and vice versa whenever we switch the mode if (value != distanceSpaceMode) { distanceSpaceMode = value; float scale = (distanceSpaceMode == SpaceMode.Local) ? WorldToLocalScale : LocalToWorldScale; startPushDistance *= scale; maxPushDistance *= scale; pressDistance *= scale; releaseDistanceDelta *= scale; } } } [SerializeField] [Tooltip("The offset at which pushing starts. Offset is relative to the pivot of either the moving visuals if there's any or the button itself. For UnityUI based PressableButtons, this cannot be a negative value.")] protected float startPushDistance = 0.0f; /// /// The offset at which pushing starts. Offset is relative to the pivot of either the moving visuals if there's any or the button itself. /// public float StartPushDistance { get => startPushDistance; set => startPushDistance = value; } [SerializeField] [Tooltip("Maximum push distance. Distance is relative to the pivot of either the moving visuals if there's any or the button itself.")] private float maxPushDistance = 0.2f; /// /// Maximum push distance. Distance is relative to the pivot of either the moving visuals if there's any or the button itself. /// public float MaxPushDistance { get => maxPushDistance; set => maxPushDistance = value; } [SerializeField] [FormerlySerializedAs("minPressDepth")] [Tooltip("Distance the button must be pushed until it is considered pressed. Distance is relative to the pivot of either the moving visuals if there's any or the button itself.")] private float pressDistance = 0.02f; /// /// Distance the button must be pushed until it is considered pressed. Distance is relative to the pivot of either the moving visuals if there's any or the button itself. /// public float PressDistance { get => pressDistance; set => pressDistance = value; } [SerializeField] [FormerlySerializedAs("withdrawActivationAmount")] [Tooltip("Withdraw amount needed to transition from Pressed to Released.")] private float releaseDistanceDelta = 0.01f; /// /// Withdraw amount needed to transition from Pressed to Released. /// public float ReleaseDistanceDelta { get => releaseDistanceDelta; set => releaseDistanceDelta = value; } /// /// Speed for retracting the moving button visuals on release. /// [SerializeField] [Tooltip("Speed for retracting the moving button visuals on release.")] [FormerlySerializedAs("returnRate")] private float returnSpeed = 25.0f; [SerializeField] [Tooltip("Button will send the release event on touch end after successful press even if release plane hasn't been passed.")] private bool releaseOnTouchEnd = true; /// /// Button will send the release event on touch end after successful press even if release plane hasn't been passed. /// public bool ReleaseOnTouchEnd { get => releaseOnTouchEnd; set => releaseOnTouchEnd = value; } [SerializeField] [Tooltip("Ensures that the button can only be pushed from the front. Touching the button from the back or side is prevented.")] private bool enforceFrontPush = true; /// /// Ensures that the button can only be pushed from the front. Touching the button from the back or side is prevented. /// public bool EnforceFrontPush { get => enforceFrontPush; private set => enforceFrontPush = value; } [Header("Events")] public UnityEvent TouchBegin = new UnityEvent(); public UnityEvent TouchEnd = new UnityEvent(); public UnityEvent ButtonPressed = new UnityEvent(); public UnityEvent ButtonReleased = new UnityEvent(); #region Private Members // The maximum distance before the button is reset to its initial position when retracting. private const float MaxRetractDistanceBeforeReset = 0.0001f; private Dictionary touchPoints = new Dictionary(); private List currentInputSources = new List(); private float currentPushDistance = 0.0f; /// /// Current push distance relative to the start push plane. /// public float CurrentPushDistance { get => currentPushDistance; protected set => currentPushDistance = value; } private bool isTouching = false; /// /// Represents the state of whether or not a finger is currently touching this button. /// public bool IsTouching { get => isTouching; private set { if (value != isTouching) { isTouching = value; if (isTouching) { TouchBegin.Invoke(); } else { // Abort press. if (!releaseOnTouchEnd) { IsPressing = false; } TouchEnd.Invoke(); } } } } /// /// Represents the state of whether the button is currently being pressed. /// public bool IsPressing { get; private set; } /// /// Transform for local to world space in the world direction of a press /// Multiply local scale positions by this value to convert to world space /// public float LocalToWorldScale => (WorldToLocalScale != 0) ? 1.0f / WorldToLocalScale : 0.0f; /// /// The press direction of the button as defined by a NearInteractionTouchableSurface. /// private Vector3 WorldSpacePressDirection { get { var nearInteractionTouchable = GetComponent(); if (nearInteractionTouchable != null) { return nearInteractionTouchable.transform.TransformDirection(nearInteractionTouchable.LocalPressDirection); } return transform.forward; } } /// /// The press direction of the button as defined by a NearInteractionTouchableSurface, in local space, /// using Vector3.forward as an optional fallback when no NearInteractionTouchableSurface is defined. /// private Vector3 LocalSpacePressDirection { get { var nearInteractionTouchable = GetComponent(); if (nearInteractionTouchable != null) { return nearInteractionTouchable.LocalPressDirection; } return Vector3.forward; } } private Transform PushSpaceSourceTransform { get => movingButtonVisuals != null ? movingButtonVisuals.transform : transform; } /// /// Transform for world to local space in the world direction of press /// Multiply world scale positions by this value to convert to local space /// private float WorldToLocalScale => transform.InverseTransformVector(WorldSpacePressDirection).magnitude; /// /// Initial offset from moving visuals to button /// private Vector3 movingVisualsInitialLocalPosition = Vector3.zero; /// /// The position from where the button starts to move. Projected into world space based on the button's current world space position. /// private Vector3 InitialWorldPosition { get { if (Application.isPlaying && movingButtonVisuals) // we're using a cached position in play mode as the moving visuals will be moved during button interaction { var parentTransform = PushSpaceSourceTransform.parent; var localPosition = (parentTransform == null) ? movingVisualsInitialLocalPosition : parentTransform.TransformVector(movingVisualsInitialLocalPosition); return PushSpaceSourceParentPosition + localPosition; } else { return PushSpaceSourceTransform.position; } } } /// /// The position from where the button starts to move. In local space, relative to button root. /// private Vector3 InitialLocalPosition { get { if (Application.isPlaying && movingButtonVisuals) // we're using a cached position in play mode as the moving visuals will be moved during button interaction { return movingVisualsInitialLocalPosition; } else { return PushSpaceSourceTransform.position; } } } #endregion private void OnEnable() { currentPushDistance = startPushDistance; } private Vector3 PushSpaceSourceParentPosition => (PushSpaceSourceTransform.parent != null) ? PushSpaceSourceTransform.parent.position : Vector3.zero; protected virtual void Start() { hasStarted = true; if (gameObject.layer == 2) { Debug.LogWarning("PressableButton will not work if game object layer is set to 'Ignore Raycast'."); } movingVisualsInitialLocalPosition = PushSpaceSourceTransform.localPosition; // Ensure everything is set to initial positions correctly. UpdateMovingVisualsPosition(); } void OnDisable() { // clear touch points in case we get disabled and can't receive the touch end event anymore touchPoints.Clear(); currentInputSources.Clear(); if (hasStarted) { // make sure button doesn't stay in a pressed state in case we disable the button while pressing it currentPushDistance = startPushDistance; UpdateMovingVisualsPosition(); } } private void Update() { if (IsTouching) { UpdateTouch(); } else if (currentPushDistance > startPushDistance) { RetractButton(); } } private void UpdateTouch() { currentPushDistance = GetFarthestDistanceAlongPressDirection(); UpdateMovingVisualsPosition(); // Hand press is only allowed to happen while touching. UpdatePressedState(currentPushDistance); } private void RetractButton() { float retractDistance = currentPushDistance - startPushDistance; retractDistance -= retractDistance * returnSpeed * Time.deltaTime; // Apply inverse scale of local z-axis. This constant should always have the same value in world units. float localMaxRetractDistanceBeforeReset = MaxRetractDistanceBeforeReset * WorldSpacePressDirection.magnitude; if (retractDistance < localMaxRetractDistanceBeforeReset) { currentPushDistance = startPushDistance; } else { currentPushDistance = startPushDistance + retractDistance; } UpdateMovingVisualsPosition(); if (releaseOnTouchEnd && IsPressing) { UpdatePressedState(currentPushDistance); } } #region IMixedRealityTouchHandler implementation private void PulseProximityLight() { // Pulse each proximity light on pointer cursors' interacting with this button. if (currentInputSources.Count != 0) { foreach (var pointer in currentInputSources[currentInputSources.Count - 1].Pointers) { if (!pointer.BaseCursor.TryGetMonoBehaviour(out MonoBehaviour baseCursor)) { return; } GameObject cursorGameObject = baseCursor.gameObject; if (cursorGameObject == null) { return; } ProximityLight[] proximityLights = cursorGameObject.GetComponentsInChildren(); if (proximityLights != null) { foreach (var proximityLight in proximityLights) { proximityLight.Pulse(); } } } } } private bool HasPassedThroughStartPlane(HandTrackingInputEventData eventData) { foreach (var pointer in eventData.InputSource.Pointers) { // In the case that the input source has multiple poke pointers, this code // will reason over the first such pointer that is actually interacting with // an object. For input sources that have a single poke pointer, this is one // and the same (i.e. this event will only fire for this object when the poke // pointer is touching this object). PokePointer poke = pointer as PokePointer; if (poke && poke.CurrentTouchableObjectDown) { // Extrapolate to get previous position. float previousDistance = GetDistanceAlongPushDirection(poke.PreviousPosition); return previousDistance <= StartPushDistance; } } return false; } void IMixedRealityTouchHandler.OnTouchStarted(HandTrackingInputEventData eventData) { if (touchPoints.ContainsKey(eventData.Controller)) { return; } // Back-Press Detection: // Accept touch only if controller pushed from the front. if (enforceFrontPush && !HasPassedThroughStartPlane(eventData)) { return; } touchPoints.Add(eventData.Controller, eventData.InputData); // Make sure only one instance of this input source exists and is at the "top of the stack." currentInputSources.Remove(eventData.InputSource); currentInputSources.Add(eventData.InputSource); IsTouching = true; eventData.Use(); } void IMixedRealityTouchHandler.OnTouchUpdated(HandTrackingInputEventData eventData) { if (touchPoints.ContainsKey(eventData.Controller)) { touchPoints[eventData.Controller] = eventData.InputData; eventData.Use(); } } void IMixedRealityTouchHandler.OnTouchCompleted(HandTrackingInputEventData eventData) { if (touchPoints.ContainsKey(eventData.Controller)) { // When focus is lost, before removing controller, update the respective touch point to give a last chance for checking if pressed occurred touchPoints[eventData.Controller] = eventData.InputData; UpdateTouch(); touchPoints.Remove(eventData.Controller); currentInputSources.Remove(eventData.InputSource); IsTouching = (touchPoints.Count > 0); eventData.Use(); } } #endregion OnTouch #region public transform utils /// /// Returns world space position along the push direction for the given local distance /// /// public Vector3 GetWorldPositionAlongPushDirection(float localDistance) { float distance = (distanceSpaceMode == SpaceMode.Local) ? localDistance * LocalToWorldScale : localDistance; return InitialWorldPosition + WorldSpacePressDirection.normalized * distance; } /// /// Returns local position along the push direction for the given local distance /// /// public Vector3 GetLocalPositionAlongPushDirection(float localDistance) { return InitialLocalPosition + LocalSpacePressDirection.normalized * localDistance; } /// /// Returns the local distance along the push direction for the passed in world position /// public float GetDistanceAlongPushDirection(Vector3 positionWorldSpace) { Vector3 localPosition = positionWorldSpace - InitialWorldPosition; float distance = Vector3.Dot(localPosition, WorldSpacePressDirection.normalized); return (distanceSpaceMode == SpaceMode.Local) ? distance * WorldToLocalScale : distance; } #endregion #region private Methods protected virtual void UpdateMovingVisualsPosition() { if (movingButtonVisuals != null) { // Always move relative to startPushDistance movingButtonVisuals.transform.localPosition = GetLocalPositionAlongPushDirection(currentPushDistance - startPushDistance); } } // This function projects the current touch positions onto the 1D press direction of the button. // It will output the farthest pushed distance from the button's initial position. private float GetFarthestDistanceAlongPressDirection() { float farthestDistance = startPushDistance; foreach (var touchEntry in touchPoints) { float testDistance = GetDistanceAlongPushDirection(touchEntry.Value); farthestDistance = Mathf.Max(testDistance, farthestDistance); } return Mathf.Clamp(farthestDistance, startPushDistance, maxPushDistance); } private void UpdatePressedState(float pushDistance) { // If we aren't in a press and can't start a simple one. if (!IsPressing) { // Compare to our previous push depth. Use previous push distance to handle back-presses. if (pushDistance >= pressDistance) { IsPressing = true; ButtonPressed.Invoke(); PulseProximityLight(); } } // If we're in a press, check if the press is released now. else { float releaseDistance = pressDistance - releaseDistanceDelta; if (pushDistance <= releaseDistance) { IsPressing = false; ButtonReleased.Invoke(); } } } #endregion } }