Search code examples
c#winformscontrols

Dynamically swapping out or toggling visibility of controls


I have a TreeView in a form, that is dock-filled to a groupbox. The problem to solve is, that there is an operation that is run on a Task, which loads data from a server application. When this is running, there should be a progress indicator displayed in the location of the TreeView. That is, it should be shown instead of the TreeView, and take its place fully. The following is what the code for this looks like:

private async void preload_data(object sender, System.EventArgs args)
{
    try
    {
        // the treeView should be disabled/invisible at this point
        // make the CircularProgressBar enabled/visible

        // get the information from the server
        await Configuration.network.fetch_stuff();
    }
    catch (Exception ex)
    {
        // something bad happened
    }
    finally
    {
        // whatever happened, the treeView should be back 
    }
}

The CircularProgressBar (a third-party control) should appear as in the code above, and should replace the TreeView. It should fill the exact same space as the TreeView would, which is dock-filled. Below is a screenshot of this: enter image description here

This form and all its controls have been designed in the designer, and I don't want to do it there, I want to do it programmatically. What is the best way to go about this? I have looked at examples of Controls.Remove() and Controls.Add(), but it's not clear if that fits this purpose.


Solution

  • It is quite common to change the visual output while actions are running, like you do. Think of disabling buttons, to discourage operators to press the button again, or show something visually, to inform operators about the progress.

    For simplicity, without the try-catch

    private async Task PreloadDataAsync()
    {
        this.ShowFetchingData(true);
    
        // start fetching data, do not await:
        var taskFetchData = Configuration.network.fetch_stuff();
    
        // while taskFetchData not completed, await some time
        TimeSpan updateTime = TimeSpan.FromSeconds(0.250);
        int progressCounter = 0;
        while (!taskFetchData.IsCompleted)
        {
            this.ShowProgress(progressCounter);
            var taskWait = Task.Delay(updateTime);
            await Task.WhenAny(new Task[] {taskFetchData, taskWait};
            // either taskFetchData.IsCompleted, or Delay time waited
            ++progressCounter;
        }
    
        this.ShowFetchingData(false);
    }
    
    private void ShowFetchindData(bool show)
    {
        // disable/enable certain buttons, menu items, show progressbar?
        this.ButtonFetchData.Enabled = !show;
        this.MenuFetchData.Enabled = !show;
        this.ProgressBarFetchData.Visible = show;
    }   
    
    private bool IsFetchingData => this.ProgressBarFetchData.Visible;
    
    private void ShowProgress(int progress)
    {
        this.ProgressBarFetchData.Position = progress;
    }
    

    For simplicity, I've left out checks for the position in the progress bar, but you get the gist.

    Usage:

    private async void OnButtonFetchData(object sender, EventArgs e)
    {
        await this.PreloadDataAsync();
    }
    

    Room for improvement

    The problem with this is that there is no timeout at all: if FetchStuff does not complete, you are in an endless wait. The method that microsoft proposes is the use of a CancellationToken. Almost every async method has an overload with a CancellationToken. Consider creating one yourself:

    // existing method:
    private async Task<MyData> fetch_Stuff()
    {
        await this.fetch_stuff(CancellationToken.None);
    }
    
    // added method with CancellationToken
    private async Task<MyData> fetch_Stuff(CancellationToken token)
    {
        // Call async function overloads with the token,
        // Regularly check if cancellation requested
    
        while (!token.IsCancellationRequested)
        {
            ... // fetch some more data, without waiting too long
        }
    }
    

    Instead of IsCancellationRequested, consider to throw an exception: ThrowIfCancellationRequested.

    Usage:

    private async Task PreloadDataAsync()
    {
        // preloading should be finished within 30 seconds
        // let the cancellationTokenSource request cancel after 30 seconds
        TimeSpan maxPreloadTime = TimeSpan.FromSeconds(30);
        using (var cancellationTokenSource = new CancellationTokenSource(maxPreloadTime))
        {
             await PreloadDataAsync(cancellationTokenSource.Token);
        }
    }
    

    The overload with CancellationToken:

    private async Task PreloadDataAsync(CancellationToken token)
    {
        this.ShowFetchingData(true);
    
        // execute code similar to above, use overloads that accept token:
        try
        {
            var taskFetchData = Configuration.network.fetch_stuff(token);
            TimeSpan updateTime = TimeSpan.FromSeconds(0.250);
            int progressCounter = 0;
            while (!taskFetchData.IsCompleted)
            {
                token.ThrowIfCancellationRequested();
                this.ShowProgress(progressCounter);
                var taskWait = Task.Delay(updateTime, token);
                await Task.WhenAny(new Task[] {taskFetchData, taskWait};
                // either taskFetchData.IsCompleted, or Delay time waited
                ++progressCounter;
            }
        }
        catch (TaskCancelledException exc)
        {
             this.ReportPreloadTimeout();
        }
        finally
        {
            this.ShowFetchingData(false);
        }
    }
    

    Or if you want a button that cancels the task:

    private CancellationTokenSource cancellationTokenSource = null;
    
    private book IsPreloading => this.CancellationTokenSource != null;
    
    private async Task StartStopPreload()
    {
        if (!this.IsPreloading)
           StartPreload();
        else
           CancelPreload();
    }
    
    private async Task StartPreload()
    {
        // preload not started yet; start it without timeout;
        try
            {
                this.cancellationTokenSource = new CancellationTokenSource();
                await PreloadDataAsync(this.cancellationTokenSource.Token);
            }
            catch (TaskCancelledException exc)
            {
                this.ReportPreloadCancelled();
            }
            finally
            {
                this.cancellationTokenSource.Dispose();
                this.cancellationTokenSource = null;
            }
        }
    }
    

    The method where operators can stop preloading:

    private async void StopPreload()
    {
        this.cancellationTokenSource.Cancel();
        // the method that created this source will Dispose it and assign null
    }
    

    All you have to do is create buttons / menu items to start / stop preloading