Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions org.mixedrealitytoolkit.uxcore/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

* Added `AlphaBlend` tint mode to `TintEffect`. [PR #1131](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1131)

### Changed

* Removed redundant `Update()` from `CanvasSliderVisuals` in favor of event-driven updates and `OnValidate()`. [PR #1144](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1144)
* Eliminated heap allocations across `Slider` and `SliderSounds` event delivery hot paths. [PR #1144](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1144)

### Fixed

* Fixed "leaked managed shell" issue in `UGUIInputAdapter`. [PR #1096](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1096)
* Fixed "Attribute 'SerializeField' is not valid on this declaration type. It is only valid on 'field' declarations" error on `DialogButton` in Unity 6.3. [PR #1108](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1108)
* Fixed `SeeItSayItLabelEnablerTests` failure when running on platforms without speech recognition support. [PR #1141](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1141)
* Fixed slider handle visuals not updating when slider value is modified from Inspector in play mode. [PR #1144](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1144)
* Guarded against division by zero and zero-length tracks in `Slider`. [PR #1144](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1144)
* Fixed `Slider.SnapSliderToStepPositions` snapping relative to 0 instead of `MinValue` when using custom ranges. [PR #1144](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1144)
* Fixed `SliderSounds` pitch calculation to normalize against slider range instead of passing raw unnormalized values. [PR #1144](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1144)
* Guarded against division by zero and null references in `SliderSounds`. [PR #1144](https://github.com/MixedRealityToolkit/MixedRealityToolkit-Unity/pull/1144)

## [3.3.0] - 2025-11-12

Expand Down
148 changes: 101 additions & 47 deletions org.mixedrealitytoolkit.uxcore/Slider/Slider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,8 @@ public Collider TrackCollider
/// </summary>
public float MinValue
{
get
{
return minValue;
}
set
{
minValue = Mathf.Min(value, maxValue);
}
get => minValue;
set => minValue = Mathf.Min(value, maxValue);
}

[SerializeField]
Expand All @@ -122,14 +116,8 @@ public float MinValue
/// </summary>
public float MaxValue
{
get
{
return maxValue;
}
set
{
maxValue = Mathf.Max(minValue, value);
}
get => maxValue;
set => maxValue = Mathf.Max(minValue, value);
}

[VariableRange("minValue", "maxValue")]
Expand All @@ -149,22 +137,20 @@ public float MaxValue
public float Value
{
get => value;
set
{
if (this.value != value)
{
value = Mathf.Clamp(value, minValue, maxValue);
var oldSliderValue = this.value;
this.value = value;
OnValueUpdated.Invoke(new SliderEventData(oldSliderValue, value));
}
}
set => UpdateValue(value);
}

/// <summary>
/// The slider's normalized value calculated using the <see cref="MinValue"/> and <see cref="MaxValue"/> properties.
/// </summary>
public float NormalizedValue => (MaxValue - MinValue) != 0 ? (value - MinValue) / (MaxValue - MinValue) : 0;
public float NormalizedValue
{
get
{
float range = MaxValue - MinValue;
return range > ZeroTolerance ? (value - MinValue) / range : 0f;
}
}

[SerializeField]
[Tooltip("Controls whether this slider is increments in steps or continuously.")]
Expand All @@ -176,7 +162,14 @@ public float Value
public bool UseSliderStepDivisions
{
get => useSliderStepDivisions;
set => useSliderStepDivisions = value;
set
{
useSliderStepDivisions = value;
if (hasStarted && useSliderStepDivisions)
{
UpdateValue(this.value);
}
}
}

[SerializeField]
Expand All @@ -190,7 +183,14 @@ public bool UseSliderStepDivisions
public int SliderStepDivisions
{
get => sliderStepDivisions;
set => sliderStepDivisions = value;
set
{
sliderStepDivisions = value;
if (hasStarted && useSliderStepDivisions)
{
UpdateValue(this.value);
}
}
}

[Header("Layout")]
Expand Down Expand Up @@ -240,14 +240,27 @@ public Transform SliderEnd
/// A Unity event that is invoked when <see cref="Value"/> changes.
/// </summary>
public SliderEvent OnValueUpdated => onValueUpdated;

#endregion

#region Private Fields

private const float ZeroTolerance = 1e-6f;
private readonly SliderEventData sliderEventData = new SliderEventData(0f, 0f);
private bool hasStarted;

/// <summary>
/// Private member used to adjust slider values
/// </summary>
private float SliderStepVal => (MaxValue - MinValue) / sliderStepDivisions;
private float SliderStepVal
{
get
{
int divisions = Mathf.Max(1, sliderStepDivisions);
float range = MaxValue - MinValue;
return range > ZeroTolerance ? range / divisions : 0f;
}
}

#endregion

Expand Down Expand Up @@ -285,9 +298,11 @@ protected override void Reset()

/// <summary>
/// A Unity event function that is called on the frame when a script is enabled just before any of the update methods are called the first time.
/// </summary>
/// </summary>
protected virtual void Start()
{
hasStarted = true;

// Turn on/off colliders at Start() to avoid bugs with
// colliders not calling OnTriggerExit on interactors
SnapToPosition = snapToPosition;
Expand All @@ -311,7 +326,8 @@ protected virtual void Start()
InitializeStepDivisions();
}

OnValueUpdated.Invoke(new SliderEventData(value, value));
sliderEventData.Initialize(value, value);
OnValueUpdated.Invoke(sliderEventData);
}

/// <summary>
Expand All @@ -324,14 +340,15 @@ private void OnValidate()
// Ensure that the proper constraints are applied to the possible values of the slider
MinValue = minValue;
MaxValue = maxValue;
Value = value;
UpdateValue(value, forceUpdate: true);
}

#endregion

#region Protected Methods

/// <summary>
/// Invoked on <see cref="Start"/>, <see cref="Awake"/>, and <see cref="Reset"/> to apply required
/// Invoked on <see cref="Start"/>, <see cref="Awake"/>, and <see cref="Reset"/> to apply required
/// settings to this <see cref="Slider"/> instance.
/// </summary>
/// <remarks>
Expand All @@ -344,37 +361,74 @@ protected virtual void ApplyRequiredSettings()
// been acquired.
selectMode = InteractableSelectMode.Single;
}

#endregion Protected Methods

#region Private Methods
/// <summary>

/// <summary>
/// Private method used to adjust initial slider value to stepwise values
/// </summary>
private void InitializeStepDivisions()
{
Value = SnapSliderToStepPositions(Value);
UpdateValue(value);
}

private float SnapSliderToStepPositions(float value)
private float ClampAndSnapValue(float rawValue)
{
var stepCount = value / SliderStepVal;
var snappedValue = SliderStepVal * Mathf.RoundToInt(stepCount);
Mathf.Clamp(snappedValue, 0f, 1.0f);
return snappedValue;
float clamped = Mathf.Clamp(rawValue, minValue, maxValue);
if (useSliderStepDivisions)
{
clamped = SnapSliderToStepPositions(clamped);
}
return Mathf.Clamp(clamped, minValue, maxValue);
}

private void UpdateSliderValue()
private void UpdateValue(float newValue, bool forceUpdate = false)
{
float clampedValue = ClampAndSnapValue(newValue);

if (forceUpdate || this.value != clampedValue)
{
var oldSliderValue = this.value;
this.value = clampedValue;
sliderEventData.Initialize(oldSliderValue, clampedValue);
OnValueUpdated.Invoke(sliderEventData);
}
}

internal float SnapSliderToStepPositions(float value)
{
float stepVal = SliderStepVal;
if (stepVal <= ZeroTolerance)
{
return value;
}

var stepCount = (value - MinValue) / stepVal;
var snappedValue = MinValue + stepVal * Mathf.Round(stepCount);
return Mathf.Clamp(snappedValue, MinValue, MaxValue);
}

internal void UpdateSliderValue()
{
if (interactorsSelecting.Count == 0)
{
return;
}

Vector3 interactionPoint = interactorsSelecting[0].GetAttachTransform(this).position;
Vector3 interactorDelta = interactionPoint - StartInteractionPoint;
Vector3 trackDir = SliderTrackDirection;
float sqrMag = trackDir.sqrMagnitude;

var handDelta = Vector3.Dot(SliderTrackDirection.normalized, interactorDelta);
float handDelta = sqrMag > ZeroTolerance ? Vector3.Dot(trackDir, interactorDelta) / sqrMag : 0f;

var normalizedStartValue = (StartSliderValue - MinValue) / (MaxValue - MinValue);
float normalizedValue = Mathf.Clamp(normalizedStartValue + handDelta / SliderTrackDirection.magnitude, 0f, 1.0f);
float range = MaxValue - MinValue;
float normalizedStartValue = range > ZeroTolerance ? (StartSliderValue - MinValue) / range : 0f;
float normalizedValue = Mathf.Clamp01(normalizedStartValue + handDelta);

var unsnappedValue = normalizedValue * (MaxValue - MinValue) + MinValue;
Value = useSliderStepDivisions ? SnapSliderToStepPositions(unsnappedValue) : unsnappedValue;
UpdateValue(normalizedValue * range + MinValue);
}

#endregion
Expand Down
9 changes: 9 additions & 0 deletions org.mixedrealitytoolkit.uxcore/Slider/SliderEventData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,14 @@ public SliderEventData(float oldValue, float newValue)
/// The current value of the slider.
/// </summary>
public float NewValue { get; private set; }

/// <summary>
/// Reinitializes this instance with updated values to avoid GC allocations in hot interaction paths.
/// </summary>
internal void Initialize(float oldValue, float newValue)
{
OldValue = oldValue;
NewValue = newValue;
}
}
}
42 changes: 29 additions & 13 deletions org.mixedrealitytoolkit.uxcore/Slider/SliderSounds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ public class SliderSounds : MonoBehaviour
/// </summary>
public bool PlaySoundsOnlyOnInteract { get => playSoundsOnlyOnInteract; set => playSoundsOnlyOnInteract = value; }


[Header("Audio Clips")]
[SerializeField]
[Tooltip("Sound to play when interaction with slider starts")]
Expand Down Expand Up @@ -117,6 +116,7 @@ public class SliderSounds : MonoBehaviour
public AudioSource AudioSource { get => audioSource; set => audioSource = value; }

#region Private members

private Slider slider;

// Check to see if the slider is being interacted with
Expand All @@ -125,11 +125,20 @@ public class SliderSounds : MonoBehaviour
// Play sound when passing through slider notches
private float accumulatedDeltaSliderValue = 0;
private float lastSoundPlayTime;

// Cached delegates to prevent GC allocations during enable/disable cycles
private UnityEngine.Events.UnityAction<SelectEnterEventArgs> onInteractionStartedAction;
private UnityEngine.Events.UnityAction<SelectExitEventArgs> onInteractionEndedAction;
private UnityEngine.Events.UnityAction<SliderEventData> onValueUpdatedAction;

#endregion

private void Awake()
{
slider = GetComponent<Slider>();
onInteractionStartedAction = OnInteractionStarted;
onInteractionEndedAction = OnInteractionEnded;
onValueUpdatedAction = OnValueUpdated;
}

private void Start()
Expand All @@ -140,39 +149,46 @@ private void Start()
audioSource = gameObject.EnsureComponent<AudioSource>();
}

if (alignWithStepSlider && slider.UseSliderStepDivisions)
if (alignWithStepSlider && slider != null && slider.UseSliderStepDivisions)
{
tickEvery = 1.0f / slider.SliderStepDivisions;
int divisions = Mathf.Max(1, slider.SliderStepDivisions);
tickEvery = 1.0f / divisions;
}
}

private void OnEnable()
{
slider.firstSelectEntered.AddListener(OnInteractionStarted);
slider.lastSelectExited.AddListener(OnInteractionEnded);
slider.OnValueUpdated.AddListener(OnValueUpdated);
if (slider != null)
{
slider.firstSelectEntered.AddListener(onInteractionStartedAction);
slider.lastSelectExited.AddListener(onInteractionEndedAction);
slider.OnValueUpdated.AddListener(onValueUpdatedAction);
}
}

private void OnDisable()
{
slider.firstSelectEntered.RemoveListener(OnInteractionStarted);
slider.lastSelectExited.RemoveListener(OnInteractionEnded);
slider.OnValueUpdated.RemoveListener(OnValueUpdated);
if (slider != null)
{
slider.firstSelectEntered.RemoveListener(onInteractionStartedAction);
slider.lastSelectExited.RemoveListener(onInteractionEndedAction);
slider.OnValueUpdated.RemoveListener(onValueUpdatedAction);
}
}

private const float NeutralPitchShift = 1f;

private void OnValueUpdated(SliderEventData eventData)
{

internal void OnValueUpdated(SliderEventData eventData)
{
if (!(playSoundsOnlyOnInteract && !isInteracting) && playTickSounds && AudioSource != null && passNotchSound != null)
{
float delta = eventData.NewValue - eventData.OldValue;
accumulatedDeltaSliderValue += Mathf.Abs(delta);
var now = Time.timeSinceLevelLoad;
if (accumulatedDeltaSliderValue >= tickEvery && now - lastSoundPlayTime > minSecondsBetweenTicks)
{
AudioSource.pitch = Mathf.Lerp(startPitch, endPitch, eventData.NewValue);
float normalizedProgress = slider != null ? slider.NormalizedValue : eventData.NewValue;
AudioSource.pitch = Mathf.Lerp(startPitch, endPitch, normalizedProgress);
if (AudioSource.isActiveAndEnabled)
{
AudioSource.PlayOneShot(passNotchSound);
Expand Down
Loading
Loading