Composites + Hold Interaction not working properly

I stumbled upon this pretty annoying bug today, when using 1+ more buttons Composites with the Hold interaction.

Tested it with 1, 2 and even 3 buttons composites (for the latter I wrote a custom class in order to add the option in the Inspector), and it shows up regardless of the devices used (pad/keyboard/mouse/mix of the formers).

My setup is this -

First, a simple script to be attached to any game object:

using UnityEngine;
using UnityEngine.InputSystem;

public class InputTest : MonoBehaviour {
    [SerializeField]
    private InputAction TestAction;

    private void Awake() {
        TestAction.started += context => Debug.Log($"Started {context.action.name} at {context.time}");
        TestAction.performed += context => Debug.Log($"Performed {context.action.name} at {context.time}");
        TestAction.canceled += context => Debug.Log($"Canceled {context.action.name} at {context.time}");
    }

    private void OnEnable() {
        TestAction.Enable();
    }

    private void OnDisable() {
        TestAction.Disable();
    }
}

Second, the bindings I created on the component:

Third, the action itself is set to Button and the Hold interaction added, with a Hold Time of 2 seconds.

With all the setup ready, when you enter play mode, if you press one of the buttons of the composite and then the other one (while holding the first one pressed), everything works fine, the started callback is called first, then the performed one (if you keep them pressed over 2 secs) and finally the canceled one.

But, if you press both buttons simultaneously (at least, as far as a human can do that) and release them before the hold time is passed, this is what happens:

The first two callbacks are correct, they get called when I press and then release both buttons simultaneously.
Then, after an amount of time equal to start_time + hold_time, and without me using any device at all, the started callback is called again, immediately followed by the performed callback, and the last canceled callback is called only if and when I press one of the buttons of the composite.

I tried a lot of different setups (using an Action Map from an Input Asset, creating the binding via code, setting the Hold interaction in the Bindings instead of the parent Action, etc.), and nothing seemed to fix this.

Edit: forgot to say that this happens on 2019.3.13f1 and .14f1, with the 1.0.0 package.

Hi, could you file a ticket with the Unity bug reporter? (in the editor, it’s found under “Help >> Report a Bug…” in the main menu) Would like to have a closer look.

Done, I used the same title as the forum thread.

Please let us know if/when you discover anything about it, thank you. :slight_smile:

1 Like

Any news? A month has passed…

I’m having a similar issue.

I have a custom composite that returns 1 if user pressed within a specific region of the screen and 0 otherwise.
When using a Hold interaction with this composite, the “performed” event fires after the duration of the hold even if the mouse is no longer pressed.

in other words. I press and release in the desired screen region, then 1 second later “performed” is incorrectly called.

Issue doesn’t happen when not using Composites :frowning:

here’s the composite code:

using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Layouts;
using UnityEngine.InputSystem.Utilities;
using UnityEngine.Scripting;

namespace MyInput {

    [Preserve]
    [DisplayStringFormat("{position}/{button}")]
    public class ScreenInputRegionComposite : InputBindingComposite<float> {

        #region Fields

        [InputControl(layout = "Axis")]
        public int position;

        [InputControl(layout = "Button")]
        public int button;

        public TextAnchor region = TextAnchor.LowerLeft;
        public float regionWidth = 100;
        public float regionHeight = 100;

        #endregion

        #region Public Methods

        public override float ReadValue(ref InputBindingCompositeContext context) {

            if (context.ReadValueAsButton(button)) {

                var positionValue = context.ReadValue<Vector2, Vector2MagnitudeComparer>(position);

                Rect regionRect = GetScreenRegionRect(region, new Vector2(regionWidth, regionHeight));

                if (regionRect.Contains(positionValue)) {

                    return 1;
                }
            }

            return 0;
        }

        public override float EvaluateMagnitude(ref InputBindingCompositeContext context) {

            return ReadValue(ref context);
        }

        #endregion

        #region Private Methods

#if UNITY_EDITOR
        [UnityEditor.InitializeOnLoadMethod]
        static void RegisterEditor() {

            Register();
        }
#endif

        [RuntimeInitializeOnLoadMethod]
        private static void Register() {

            InputSystem.RegisterBindingComposite<ScreenInputRegionComposite>();
        }

        private static Rect GetScreenRegionRect(TextAnchor region, Vector2 size) {

            float sw = Screen.width;
            float sh = Screen.height;

            float shw = sw / 2;
            float shh = sh / 2;

            switch (region) {

                case TextAnchor.UpperLeft:
                    return new Rect(0, sh - size.y, size.x, size.y);

                case TextAnchor.UpperCenter:
                    return new Rect(shw - size.x / 2, sh - size.y, size.x, size.y);

                case TextAnchor.UpperRight:
                    return new Rect(sw - size.x, sh - size.y, size.x, size.y);

                case TextAnchor.MiddleLeft:
                    return new Rect(0, shh - size.y / 2, size.x, size.y);

                case TextAnchor.MiddleCenter:
                    return new Rect(shw - size.x / 2, shh - size.y / 2, size.x, size.y);

                case TextAnchor.MiddleRight:
                    return new Rect(sw - size.x, shh - size.y / 2, size.x, size.y);

                case TextAnchor.LowerLeft:
                    return new Rect(0, 0, size.x, size.y);

                case TextAnchor.LowerCenter:
                    return new Rect(shw - size.x / 2, 0, size.x, size.y);

                case TextAnchor.LowerRight:
                    return new Rect(sw - size.x, 0, size.x, size.y);
            }

            return new Rect(0, 0, sw, sh);
        }

        #endregion
    }
}

here’s the action map:

So, I copied the default “HoldInteraction” script and modified it a little. Now it works fine :slight_smile:
The issue was calling “PerformedAndStayPerformed” when context.timerHasExpired. If I don’t do this, works as expected for me.

public void Process(ref InputInteractionContext context) {

    if (context.timerHasExpired) {

        Debug.Log("Timer expired! " + context.phase);

        //commenting out this fixes the issue!
        //context.PerformedAndStayPerformed();
        //return;
    }

    switch (context.phase) {

        case InputActionPhase.Waiting:

            if (context.ControlIsActuated(pressPointOrDefault)) {

                Debug.Log("Pressed " + context.time);

                timePressed = context.time;

                context.Started();
                context.SetTimeout(durationOrDefault);
            }

            break;

        case InputActionPhase.Started:

            // If we've reached our hold time threshold, perform the hold.
            // We do this regardless of what state the control changed to.

            if (context.time - timePressed >= durationOrDefault) {

                Debug.Log("Perform from start");

                context.PerformedAndStayPerformed();
            }
            else if (!context.ControlIsActuated()) {

                Debug.Log("Cancel from start. No longer pressed!");

                // Control is no longer actuated and we haven't performed a hold yet,
                // so cancel.
                context.Canceled();
            }
            break;

        case InputActionPhase.Performed:

            if (!context.ControlIsActuated(pressPointOrDefault)) {

                Debug.Log("Cancel from perform. No longer pressed!");

                context.Canceled();
            }

            break;
    }
}

public void Reset() {

    timePressed = 0;
}

Here’s the logs I get after pressing and releasing before duration:
6147854--671441--logs.png
I guess maybe in the “context.timerHasExpired” statement you should probably also check the phase?
Or maybe the bug is that the phase is still “Waiting” when context.timerHasExpired and using composites?

For anyone stumbling across this issue:

This was fixed in Input System 1.3.0 (available in 2019.4 and above).

Make sure to update your Input System Version if you encounter this bug.