Skip to content

Instantly share code, notes, and snippets.

@Invertex
Last active February 23, 2026 07:49
Show Gist options
  • Select an option

  • Save Invertex/db99b1b16ca53805ae02697b1a51ea77 to your computer and use it in GitHub Desktop.

Select an option

Save Invertex/db99b1b16ca53805ae02697b1a51ea77 to your computer and use it in GitHub Desktop.
Unity New Input System custom Hold "Interaction" where the .performed callback is constantly triggered while input is held.
using UnityEngine;
using UnityEngine.InputSystem;
//!!>> This script should NOT be placed in an "Editor" folder. Ideally placed in a "Plugins" folder.
namespace Invertex.UnityInputExtensions.Interactions
{
using System;
//https://gist.github.com/Invertex
/// <summary>
/// Custom Hold interaction for New Input System.
/// With this, the .performed callback will be called everytime the Input System updates.
/// Allowing a purely callback based approach to a button hold instead of polling it in an Update() loop or creating specific logic for it
/// .started will be called when the 'pressPoint' threshold has been met and held for the 'duration' (unless 'Trigger .started on Press Point' is checked).
/// .performed will continue to be called each frame after `.started` has triggered (or every amount of time set for "Performed Interval")
/// .cancelled will be called when no-longer actuated (but only if the input has actually 'started' triggering
/// </summary>
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine.UIElements;
//Allow for the interaction to be utilized outside of Play Mode and so that it will actually show up as an option in the Input Manager
[UnityEditor.InitializeOnLoad]
#endif
[UnityEngine.Scripting.Preserve, System.ComponentModel.DisplayName("Holding"), System.Serializable]
public class CustomHoldingInteraction : IInputInteraction
{
/// <summary>
/// Amount of actuation required before a control is considered pressed.
/// </summary>
/// <remarks>
/// If zero (default), defaults to <see cref="InputSettings.defaultButtonPressPoint"/>.
/// </remarks>
[Tooltip("The amount of actuation a control requires before being considered pressed. If not set, default to "
+ "'Default Press Point' in the global input settings.")]
public float delayBetweenPerformed = 0f;
public bool triggerStartedOnPressPoint = false;
public bool useDefaultSettingsPressPoint = true;
public float pressPoint = InputSystem.settings.defaultButtonPressPoint;
public bool useDefaultSettingsDuration = true;
public float duration = InputSystem.settings.defaultHoldTime;
private float _heldTime = 0f;
private float pressPointOrDefault => useDefaultSettingsPressPoint || pressPoint <= 0 ? InputSystem.settings.defaultButtonPressPoint : pressPoint;
private float durationOrDefault => useDefaultSettingsDuration || duration < 0 ? InputSystem.settings.defaultHoldTime : duration;
private InputInteractionContext ctx;
private void OnUpdate()
{
var isActuated = ctx.ControlIsActuated(pressPointOrDefault);
var phase = ctx.phase;
//Cancel and cleanup our action if it's no-longer actuated or been externally changed to a stopped state.
if (phase == InputActionPhase.Canceled || phase == InputActionPhase.Disabled || !ctx.action.actionMap.enabled || !isActuated)
{
Cancel(ref ctx);
return;
}
_heldTime += Time.deltaTime;
bool holdDurationElapsed = _heldTime >= durationOrDefault;
if (!holdDurationElapsed && !triggerStartedOnPressPoint) { return; }
if (phase == InputActionPhase.Waiting){ ctx.Started(); return; }
if (!holdDurationElapsed) { return; }
if (phase == InputActionPhase.Started) { ctx.PerformedAndStayPerformed(); return; }
float heldMinusDelay = _heldTime - delayBetweenPerformed;
//Held time has exceed our minimum hold time, plus any delay we've set,
//so perform it and assign back the hold time without the delay time to let increment back up again
if (heldMinusDelay >= durationOrDefault)
{
_heldTime = heldMinusDelay;
ctx.PerformedAndStayPerformed();
}
}
public void Process(ref InputInteractionContext context)
{
ctx = context; //Ensure our Update always has access to the most recently updated context
if (!ctx.ControlIsActuated(pressPointOrDefault)) { Cancel(ref context); return; } //Actuation changed and thus no longer performed, cancel it all.
if (ctx.phase != InputActionPhase.Performed && ctx.phase != InputActionPhase.Started)
{
EnableInputHooks();
}
}
private void Cleanup()
{
DisableInputHooks();
_heldTime = 0f;
}
private void Cancel(ref InputInteractionContext context)
{
Cleanup();
if (context.phase == InputActionPhase.Performed || context.phase == InputActionPhase.Started)
{ //Input was being held when this call was made. Trigger the .cancelled event.
context.Canceled();
}
}
public void Reset() => Cleanup();
private void OnLayoutChange(string layoutName, InputControlLayoutChange change) => Reset();
private void OnDeviceChange(InputDevice device, InputDeviceChange change) => Reset();
#if UNITY_EDITOR
private void PlayModeStateChange(UnityEditor.PlayModeStateChange state) => Reset();
#endif
private void EnableInputHooks()
{
InputSystem.onAfterUpdate -= OnUpdate; //Safeguard for duplicate registrations
InputSystem.onAfterUpdate += OnUpdate;
//In case layout or device changes, we'll want to trigger a cancelling of the current input action subscription to avoid errors.
InputSystem.onLayoutChange -= OnLayoutChange;
InputSystem.onLayoutChange += OnLayoutChange;
InputSystem.onDeviceChange -= OnDeviceChange;
InputSystem.onDeviceChange += OnDeviceChange;
//Prevent the update hook from persisting across a play mode change to avoid errors.
#if UNITY_EDITOR
UnityEditor.EditorApplication.playModeStateChanged -= PlayModeStateChange;
UnityEditor.EditorApplication.playModeStateChanged += PlayModeStateChange;
#endif
}
private void DisableInputHooks()
{
InputSystem.onAfterUpdate -= OnUpdate;
InputSystem.onLayoutChange -= OnLayoutChange;
InputSystem.onDeviceChange -= OnDeviceChange;
#if UNITY_EDITOR
UnityEditor.EditorApplication.playModeStateChanged -= PlayModeStateChange;
#endif
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void RegisterInteraction()
{
if (InputSystem.TryGetInteraction("CustomHolding") == null)
{ //For some reason if this is called again when it already exists, it permanently removees it from the drop-down options... So have to check first
InputSystem.RegisterInteraction<CustomHoldingInteraction>("CustomHolding");
}
}
//Constructor will be called by our Editor [InitializeOnLoad] attribute when outside Play Mode
static CustomHoldingInteraction() => RegisterInteraction();
}
#if UNITY_EDITOR
internal class CustomHoldInteractionEditor : UnityEngine.InputSystem.Editor.InputParameterEditor<CustomHoldingInteraction>
{
private static GUIContent pressPointWarning, holdTimeWarning, pressPointLabel, holdTimeLabel, startedTriggerOnPPLabel, startedTriggerOnPPToggleLabel, delayPerformedLabel;
#if UNITY_2021_3_OR_NEWER
public override void OnDrawVisualElements(VisualElement root, Action onChangedCallback)
{
var container = new VisualElement();
var settingsContainer = new VisualElement { style = { flexDirection = FlexDirection.Column, marginRight = 6 } };
var delayBetweenPerformedFloat = new FloatField(delayPerformedLabel.text)
{
tooltip = delayPerformedLabel.tooltip,
value = target.delayBetweenPerformed
};
var startTriggerOnPressPointToggle = new Toggle(startedTriggerOnPPLabel.text)
{
tooltip = startedTriggerOnPPLabel.tooltip,
value = target.triggerStartedOnPressPoint
};
//// PRESS POINT SETTING
var pressPointContainer = new VisualElement { style = { flexDirection = FlexDirection.Row } };
var pressPointFloat = new FloatField(pressPointLabel.text)
{
tooltip = pressPointLabel.tooltip,
value = target.pressPoint
};
var pressPointUseDefaultToggle = new Toggle("Default") { value = target.useDefaultSettingsPressPoint };
var usingDefaultPressPointTip = new HelpBox(pressPointWarning.text, HelpBoxMessageType.None);
var openSettingsBtnPressPoint = new UnityEngine.UIElements.Button(() => { SettingsService.OpenProjectSettings("Project/Input System Package"); }) { text = "Open Input Settings" };
pressPointFloat.style.flexShrink = 0.2f;
pressPointFloat.style.flexBasis = new StyleLength(200);
pressPointFloat.SetEnabled(!target.useDefaultSettingsPressPoint);
pressPointUseDefaultToggle.style.flexDirection = FlexDirection.RowReverse;
usingDefaultPressPointTip.style.display = target.useDefaultSettingsPressPoint ? DisplayStyle.Flex : DisplayStyle.None;
openSettingsBtnPressPoint.style.display = target.useDefaultSettingsPressPoint ? DisplayStyle.Flex : DisplayStyle.None;
openSettingsBtnPressPoint.style.alignSelf = Align.FlexEnd;
///// Hold Duration Setting
var holdDurationContainer = new VisualElement { style = { flexDirection = FlexDirection.Row } };
var holdDurationMinFloat = new FloatField(holdTimeLabel.text)
{
tooltip = holdTimeLabel.tooltip,
value = target.duration
};
var holdDurationDefaultToggle = new Toggle("Default") { value = target.useDefaultSettingsDuration };
var usingDefaultHoldDurationTip = new HelpBox(holdTimeWarning.text, HelpBoxMessageType.None);
var openSettingsBtnHoldDuration = new UnityEngine.UIElements.Button(() => { SettingsService.OpenProjectSettings("Project/Input System Package"); }) { text = "Open Input Settings" };
holdDurationMinFloat.style.flexShrink = 0.2f;
holdDurationMinFloat.style.flexBasis = new StyleLength(200);
holdDurationMinFloat?.SetEnabled(!target.useDefaultSettingsDuration);
holdDurationDefaultToggle.style.flexDirection = FlexDirection.RowReverse;
usingDefaultHoldDurationTip.style.display = target.useDefaultSettingsDuration ? DisplayStyle.Flex : DisplayStyle.None;
openSettingsBtnHoldDuration.style.display = target.useDefaultSettingsDuration ? DisplayStyle.Flex : DisplayStyle.None;
openSettingsBtnHoldDuration.style.alignSelf = Align.FlexEnd;
// Setup Events
delayBetweenPerformedFloat.RegisterValueChangedCallback(evt => { target.delayBetweenPerformed = evt.newValue; });
delayBetweenPerformedFloat.RegisterCallback<BlurEvent>(_ => { onChangedCallback?.Invoke(); });
startTriggerOnPressPointToggle.RegisterValueChangedCallback(evt => {
target.triggerStartedOnPressPoint = evt.newValue;
onChangedCallback();
});
pressPointFloat.RegisterValueChangedCallback(evt => {
target.pressPoint = evt.newValue;
bool isDefault = evt.newValue < Mathf.Epsilon;
usingDefaultPressPointTip.style.display = isDefault || target.useDefaultSettingsPressPoint ? DisplayStyle.Flex : DisplayStyle.None;
});
// Trigger onChangedCallback here to avoid it happening with each typed digit and causing stalls
pressPointFloat.RegisterCallback<BlurEvent>(_ => { onChangedCallback?.Invoke(); });
holdDurationMinFloat.RegisterValueChangedCallback(evt => {
target.duration = evt.newValue;
bool isDefault = evt.newValue <= Mathf.Epsilon;
usingDefaultHoldDurationTip.style.display = isDefault || target.useDefaultSettingsDuration ? DisplayStyle.Flex : DisplayStyle.None;
});
holdDurationMinFloat.RegisterCallback<BlurEvent>(_ => { onChangedCallback?.Invoke(); });
pressPointUseDefaultToggle.RegisterValueChangedCallback((evt) => {
target.useDefaultSettingsPressPoint = evt.newValue;
pressPointFloat?.SetEnabled(!evt.newValue);
usingDefaultPressPointTip.style.display = evt.newValue ? DisplayStyle.Flex : DisplayStyle.None;
openSettingsBtnPressPoint.style.display = evt.newValue ? DisplayStyle.Flex : DisplayStyle.None;
onChangedCallback();
});
holdDurationDefaultToggle.RegisterValueChangedCallback((evt) => {
target.useDefaultSettingsDuration = evt.newValue;
holdDurationMinFloat?.SetEnabled(!evt.newValue);
usingDefaultHoldDurationTip.style.display = evt.newValue ? DisplayStyle.Flex : DisplayStyle.None;
openSettingsBtnHoldDuration.style.display = evt.newValue ? DisplayStyle.Flex : DisplayStyle.None;
onChangedCallback();
});
usingDefaultPressPointTip.style.display = target.pressPoint <= Mathf.Epsilon || target.useDefaultSettingsPressPoint ? DisplayStyle.Flex : DisplayStyle.None;
usingDefaultHoldDurationTip.style.display = target.duration <= Mathf.Epsilon || target.useDefaultSettingsDuration ? DisplayStyle.Flex : DisplayStyle.None;
// Begin adding elems
settingsContainer.Add(delayBetweenPerformedFloat);
settingsContainer.Add(startTriggerOnPressPointToggle);
// Press Point Elems
pressPointContainer.Add(pressPointFloat);
pressPointContainer.Add(pressPointUseDefaultToggle);
settingsContainer.Add(pressPointContainer);
settingsContainer.Add(usingDefaultPressPointTip);
settingsContainer.Add(openSettingsBtnPressPoint);
// Hold Duration Elems
holdDurationContainer.Add(holdDurationMinFloat);
holdDurationContainer.Add(holdDurationDefaultToggle);
settingsContainer.Add(holdDurationContainer);
settingsContainer.Add(usingDefaultHoldDurationTip);
settingsContainer.Add(openSettingsBtnHoldDuration);
container.Add(settingsContainer);
root.Add(container);
}
public override void OnGUI() { }
#else
public override void OnGUI()
{
target.delayBetweenPerformed = EditorGUILayout.FloatField(delayPerformedLabel, target.delayBetweenPerformed, GUILayout.ExpandWidth(false));
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(startedTriggerOnPPLabel, GUILayout.Width(205f));
target.triggerStartedOnPressPoint = GUILayout.Toggle(target.triggerStartedOnPressPoint, startedTriggerOnPPToggleLabel, GUILayout.ExpandWidth(false));
EditorGUILayout.EndHorizontal();
DrawDisableIfDefault(ref target.pressPoint, ref target.useDefaultSettingsPressPoint, pressPointLabel, pressPointWarning);
DrawDisableIfDefault(ref target.duration, ref target.useDefaultSettingsDuration, holdTimeLabel, holdTimeWarning, -Mathf.Epsilon);
}
private void DrawDisableIfDefault(ref float value, ref bool useDefault, GUIContent fieldName, GUIContent warningText, float compareOffset = 0f)
{
EditorGUILayout.BeginHorizontal();
EditorGUI.BeginDisabledGroup(useDefault);
value = EditorGUILayout.FloatField(fieldName, value, GUILayout.ExpandWidth(false));
value = Mathf.Clamp(value, 0f, float.MaxValue);
EditorGUI.EndDisabledGroup();
GUIContent content =
EditorGUIUtility.TrTextContent("Default",
$"If enabled, the default {fieldName.text.ToLower()} " +
$"configured globally in the input settings is used.{System.Environment.NewLine}" +
"See Edit >> Project Settings... >> Input System Package.");
useDefault = GUILayout.Toggle(useDefault, content, GUILayout.ExpandWidth(false));
EditorGUILayout.EndHorizontal();
if (useDefault || value <= 0 + compareOffset)
{
EditorGUILayout.HelpBox(warningText);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
GUIContent settingsLabel = EditorGUIUtility.TrTextContent("Open Input Settings");
if (GUILayout.Button(settingsLabel, EditorStyles.miniButton))
SettingsService.OpenProjectSettings("Project/Input System Package");
EditorGUILayout.EndHorizontal();
}
}
#endif
protected override void OnEnable()
{
delayPerformedLabel = new GUIContent("'Performed' interval (s)", $"Delay in seconds between each <b>.performed</b> call.{System.Environment.NewLine}" +
"At the default value of 0, it will be every frame.");
startedTriggerOnPPLabel = new GUIContent("Trigger 'Started' on Press Point", $"Trigger the <b>.started</b> event as soon as input actuated beyond \"Press Point\",{System.Environment.NewLine}" +
$"instead of waiting for the \"Min Hold Time\" as well.");
startedTriggerOnPPToggleLabel = new GUIContent("", startedTriggerOnPPLabel.tooltip);
pressPointLabel = new GUIContent("Press Point", $"The minimum amount this input's actuation value must exceed to be considered \"held\".{System.Environment.NewLine}" +
"Value less-than or equal to 0 will result in the 'Default Button Press Point' value being used from your 'Project Settings > Input System'.");
holdTimeLabel = new GUIContent("Min Hold Time", $"The minimum amount of realtime seconds before the input is considered \"held\".{System.Environment.NewLine}" +
"Value less-than or equal to 0 will result in the 'Default Hold Time' value being used from your 'Project Settings > Input System'.");
pressPointWarning = EditorGUIUtility.TrTextContent("Using \"Default Button Press Point\" set in project-wide input settings.");
holdTimeWarning = EditorGUIUtility.TrTextContent("Using \"Default Hold Time\" set in project-wide input settings.");
}
}
}
#endif
@rxra
Copy link

rxra commented Feb 23, 2026

Ok, it is what I thought (the deprecation) but regarding the doc I was not sure. Perhaps I didn't check the right doc/version.
Thanks I will try it latter today (I try to debug it/fixed/proposed something if I found issue).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment