Wait for thread to finish without blocking the UI thread

2.4k views Asked by At

I'm trying to create a message box class in unity and I want it to work the same way as the message box in windows forms which waits for a button to be pressed and than executes the code after that.

        var mbox = MessageBox.Show("Test", "test", MessageBoxButtons.YesNo);
        var test = mbox == DialogResult.Cancel; <- it will wait here

I've tried to recreate that in 2 ways

Joining 2 threads

    public void TestClick()
    {
        Thread thread1 = new Thread(TestMethod);
        thread1.Start();
        thread1.Join();
        Debug.Log("Done");
    }

    private void TestMethod()
    {
        float time = 0;
        while (time <= 20)
        {
            Thread.Sleep(100);
            time++;
            Debug.Log("Im doing heavy work");
        }
    }

This one blocks the main thread and will resume only after TestMethod is completed but I don't want that because the user wont be able to interact with the message box during that time.

Asynchronous approach

    public delegate int AsyncTask();

    public void TestClick()
    {
        RunAsyncAndWait();
        Debug.Log("Done");
    }

    public int Method1()
    {
        float time = 0;
        while (time <= 20)
        {
            Thread.Sleep(100);
            time++;
            Debug.Log("Im doing heavy work");
        }
        return 0;
    }


    public void RunAsyncAndWait()
    {
        AsyncTask ac1 = Method1;

        WaitHandle[] waits = new WaitHandle[1];
        IAsyncResult r1 = ac1.BeginInvoke(null, null);
        waits[0] = r1.AsyncWaitHandle;

        WaitHandle.WaitAll(waits);

    }

This works exactly like the first one but it behaves weirdly if we change some stuff like the size of the WaitHandle[] to WaitHandle[] waits = new WaitHandle[2];.

Now this works more like what I need as it continuously writes stuff in the Console rather than just posting 21 messages at once like the previous methods but the moment it runs it pauses the unity scene (I can manually resume it and the program will run just fine) and it keeps on printing stuff in the console and I get this error

ArgumentNullException: null handle Parameter name: waitHandles System.Threading.WaitHandle.CheckArray (System.Threading.WaitHandle[] handles, Boolean waitAll) (at /Users/builduser/buildslave/mono/build/mcs/class/corlib/System.Threading/WaitHandle.cs:77) System.Threading.WaitHandle.WaitAll (System.Threading.WaitHandle[] waitHandles) (at /Users/builduser/buildslave/mono/build/mcs/class/corlib/System.Threading/WaitHandle.cs:109) Assets.Scripts.Test.RunAsyncAndWait () (at Assets/Scripts/Test.cs:40) Assets.Scripts.Test.TestClick () (at Assets/Scripts/Test.cs:16) UnityEngine.Events.InvokableCall.Invoke (System.Object[] args) (at C:/buildslave/unity/build/Runtime/Export/UnityEvent.cs:153) UnityEngine.Events.InvokableCallList.Invoke (System.Object[] parameters) (at C:/buildslave/unity/build/Runtime/Export/UnityEvent.cs:630) UnityEngine.Events.UnityEventBase.Invoke (System.Object[] parameters) (at C:/buildslave/unity/build/Runtime/Export/UnityEvent.cs:765) UnityEngine.Events.UnityEvent.Invoke () (at C:/buildslave/unity/build/Runtime/Export/UnityEvent_0.cs:53) UnityEngine.UI.Button.Press () (at C:/buildslave/unity/build/Extensions/guisystem/UnityEngine.UI/UI/Core/Button.cs:35) UnityEngine.UI.Button.OnPointerClick (UnityEngine.EventSystems.PointerEventData eventData) (at C:/buildslave/unity/build/Extensions/guisystem/UnityEngine.UI/UI/Core/Button.cs:44) UnityEngine.EventSystems.ExecuteEvents.Execute (IPointerClickHandler handler, UnityEngine.EventSystems.BaseEventData eventData) (at C:/buildslave/unity/build/Extensions/guisystem/UnityEngine.UI/EventSystem/ExecuteEvents.cs:52) UnityEngine.EventSystems.ExecuteEvents.Execute[IPointerClickHandler] (UnityEngine.GameObject target, UnityEngine.EventSystems.BaseEventData eventData, UnityEngine.EventSystems.EventFunction`1 functor) (at C:/buildslave/unity/build/Extensions/guisystem/UnityEngine.UI/EventSystem/ExecuteEvents.cs:269) UnityEngine.EventSystems.EventSystem:Update()

The first line sounded to me like I might need a callback function here so I quickly added something just to test it out

IAsyncResult r1 = ac1.BeginInvoke(ar => Debug.Log("Done"), null);

But with no luck nothing changed.

Any tips how can I approach this problem as a whole (making a message box, blocking the thread until a button is pressed), or maybe some more info on how Microsoft has implemented that in windows forms ?

2

There are 2 answers

1
Bizhan On BEST ANSWER

There is a big difference between WinForms and Unity. In WinForms you have one thread for UI which could be blocked by a modal form. In Unity you have multiple objects with multiple methods where script execution order and some engine mechanisms decide how they should be executed in each frame.

However if you want to have a modal message box in Unity, you can simply block the execution of the Update or FixedUpdate of a specific script by adding a boolean check to it or by disabling the script. First way provides more options but second one is easier. However be aware that disabling a script stops everything in it except Invoke and Coroutine.

You can block user's interactions with underlying objects by putting a simple SpriteRenderer or Image over them. This mask can have zero transparency, should be full screen size and must have Raycast Target toggled on.

I would prefer a message box with a fullscreen mask behind it which has a simple black sprite with alpha = .1

public GameObject ModalMessageBox;//game object of message box with a mask

public void TestClick()
{
    StartCoroutine(TestMethod);
    ModalMessageBox.setActive(true);
}

IEnumerator TestMethod()
{
    float time = 0;
    while (time <= 20)
    {
        yield return new WaitForSeconds(.1f);
        time++;
        Debug.Log("Im doing heavy work");
    }
    ModalMessageBox.setActive(false);
}

void Update()
{
    if(ModalMessageBox.activeSelf)
    {
        //handle message box
    }
    else
    {
        //handle normal update stuff
    }
}

Note that all other scripts will run nevertheless. If you have to block the execution of other scripts as well, then you need to do it one by one.

Note:

Since disabling a script does not stop the coroutines it started, you might as well disable the script itself

public Script1 script1;
public Script2 script2;
public Script3 script3;

void BlockScripts(bool block)
{
    //for singleton scripts:
    Script1.Instance.enabled = !block;
    Script2.Instance.enabled = !block;
    Script3.Instance.enabled = !block;
    //for referenced scripts:
    script1.enabled = !block;
    script2.enabled = !block;
    script3.enabled = !block;

    //self
    enabled = !block;
}

public void TestClick()
{
    StartCoroutine(TestMethod);
    ModalMessageBox.setActive(true);

    BlockScripts(true);
}

IEnumerator TestMethod()
{
    float time = 0;
    while (time <= 20)
    {
        yield return new WaitForSeconds(.1f);
        time++;
        Debug.Log("Im doing heavy work");
    }

    ModalMessageBox.setActive(false);

    BlockScripts(false);
}

void Update()
{
}

where Script1,2,3 are singleton classes and script1,2,3 are references of scripts you want to block.

5
Scott Chamberlain On

Stop trying to use threads and other .NET async concepts and use Unity the way it wants to be used. Create a custom CustomYieldInstruction that sees if your popup window is shown.

class WaitWhile: CustomYieldInstruction {
    Func<bool> m_Predicate;

    public override bool keepWaiting { get { return m_Predicate(); } }

    public WaitWhile(Func<bool> predicate) { m_Predicate = predicate; }
}

It is used like

public GameObject window; //the window that will be shown.

IEnumerator DialogExample()
{
    window.SetActive(true);
    yield return new WaitWhile(() => window.activeInHierarchy);

    //Code here does not run till after the window is deactivated.
}

You start the DialogExample() via a StartCoroutine or performing a yield retrun on it from another coroutine.