Post

C#, Button UI 이벤트 제어하기

C#, Button UI 이벤트 제어하기

버튼을 클릭하고 이벤트 핸들러에서 내용을 처리 중일 때는 버튼을 잠시 잠그고 처리 완료 후 버튼의 잠금을 해제하여 이중 클릭 방지와 처리 과정이 동작 중임을 가시적으로 표현하곤 한다. 이 때에 필요한 방법을 4가지 정도로 소개할까 한다. 테스트 환경은 .NET 8.0 WPF 이다.

일반적으로 간단하게 처리하고자 한다면 아래와 같이 코드를 작성할 것이다.

개선 전 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static void ButtonOnTest(object sender, RoutedEventArgs e)  
{  
    if (sender is not Button btn)  
    {  
        return;  
    }  
  
    string? orgContent = btn.Content?.ToString();  
  
    try  
    {  
        btn.Content = "처리중...";  
        btn.IsEnabled = false;  
  
        // 해당 작업이 있다고 가정  
        Thread.Sleep(5000);  
    }  
    catch (Exception ex)  
    {  
        Console.WriteLine(ex.Message); // Log처리 가정  
    }  
    finally  
    {  
        btn.Content = orgContent;  
        btn.IsEnabled = true;  
    }  
}

위의 코드는 문제가 2가지 정도 있는데 하나는 동작 중에 화면이 잠기는 것이고 두 번째는 이중 클릭을 방지할 수 없다는 것이다. 아래의 코드는 비동기 방식으로 처리하여 간단하게 개선한 것이다.

일반적인 비동기로 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static async Task ButtonOnTest(object sender, RoutedEventArgs e)  
{  
    if (sender is not Button btn)  
    {  
        return;  
    }  
  
    string? orgContent = btn.Content?.ToString();  
  
    try  
    {  
        btn.Content = "처리중...";  
        btn.IsEnabled = false;  
  
        // 해당 작업이 있다고 가정  
        await Task.Delay(5000);  
    }  
    catch (Exception ex)  
    {  
        Console.WriteLine(ex.Message); // Log처리 가정  
    }  
    finally  
    {  
        btn.Content = orgContent;  
        btn.IsEnabled = true;  
    }  
}

문제는 해결된 듯 보이지만 여기에 진행 과정이라든지 finally 부분을 필수적으로 사용해야 한다든지 등의 불편한 점을 해결해 보고 굳이 아래의 4가지 정도의 방법은 사용하지 않더라도 학습에 도움이 될 만한 것들이다.

IDisposable 활용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public sealed class DisposeButton : IDisposable  
{  
    private readonly Button mButton;  
    private readonly string mOrgContent;  
  
    public DisposeButton(Button? button, string? spinText = null)  
    {  
        ArgumentNullException.ThrowIfNull(button);  
        mButton = button;  
        mOrgContent = button.Content?.ToString() ?? string.Empty;  
        mButton.Content = string.IsNullOrWhiteSpace(spinText) ? "처리중..." : spinText;  
        mButton.IsEnabled = false;  
    }  
  
    ~DisposeButton() => Dispose(false);  
  
    private void Dispose(bool disposing)  
    {  
        if (disposing)  
        {  
            mButton.Dispatcher.Invoke(() =>  
            {  
                mButton.Content = mOrgContent;  
                mButton.IsEnabled = true;  
            });  
        }  
    }  
  
    public void Dispose()  
    {  
        Dispose(true);  
        GC.SuppressFinalize(this);  
    }  
}

클릭 후 작업이 완료되면 자동으로 잠금 해제

1
2
3
4
5
6
public static async Task ButtonOnTest(object sender, RoutedEventArgs e)  
{  
    using DisposeButton _ = new(sender as Button, "동작중...");  
    // 해당 작업이 있다고 가정  
    await Task.Delay(5000);  
}
IAsyncDisposable 활용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class DisposeButtonAsync : IAsyncDisposable  
{  
    private readonly Button mButton;  
    private readonly Func<Task>? mFunc;  
    private readonly string mOrgContent;  
  
    public DisposeButtonAsync(Button? button, Func<Task>? fn = null)  
    {  
        ArgumentNullException.ThrowIfNull(button);  
        mButton = button;  
        mFunc = fn;  
        mOrgContent = button.Content?.ToString() ?? string.Empty;  
        mButton.Content = "처리중...";  
        mButton.IsEnabled = false;  
    }  
  
#pragma warning disable CA1816  
    public async ValueTask DisposeAsync()  
#pragma warning restore CA1816  
    {  
        try  
        {  
            await (mFunc?.Invoke() ?? Task.CompletedTask).ConfigureAwait(false);  
        }  
        catch (Exception ex)  
        {  
            LogHelper.Logger.Error($"DisposeButtonAsync(DisposeAsync) : ERROR : {ex.Message}");  
        }  
        finally  
        {  
            try  
            {  
                await mButton.Dispatcher.InvokeAsync(() =>  
                {  
                    mButton.Content = mOrgContent;  
                    mButton.IsEnabled = true;  
                });  
            }  
            catch (Exception ex)  
            {  
                LogHelper.Logger.Error($"DisposeButtonAsync(Dispatcher.InvokeAsync) : ERROR : {ex.Message}");  
            }  
        }  
    }  
}

Func 델리게이트 사용

1
2
3
4
5
6
7
8
public static async Task ButtonOnTest(object sender, RoutedEventArgs e)
{
    await using DisposeButtonAsync _ = new(sender as Button, async () =>
    {
        // 해당 작업이 있다고 가정
        await Task.Delay(5000);
    });
}

참고로 IDisposable, IAsyncDisposable, GC.SuppressFinalize(this)의 관계를 아래에 요약(Gemini 2.0)했다. Microsoft에서 설명한 방법과 비교해 보길 바란다.1

인터페이스파이널라이저GC.SuppressFinalize(this)
IDisposable필요할 수 있음Dispose()에서 호출 필요
IAsyncDisposable거의 필요 없음사용하면 안 됨

비동기 개선 추가 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class FrameworkElementExtensions
{
    /// <summary>
    /// sender(object)가 Button일 경우 로딩 상태로 전환합니다.
    /// </summary>
    public static DisposeButtonAsync BeginLoading(this object sender, string loadingText = "처리중...")
    {
        if (sender is Button button)
        {
            return new DisposeButtonAsync(button, loadingText);
        }

        // Button이 아닐 경우 아무것도 하지 않는 'Dummy' Disposable 반환
        // (NullReferenceException 방지)
        return DisposeButtonAsync.Empty;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public sealed class DisposeButtonAsync : IAsyncDisposable
{
    private readonly Button? _button;
    private readonly object? _originalContent;
    private bool _isDisposed;

    // 빈 객체를 위한 정적 프로퍼티
    public static DisposeButtonAsync Empty => new(null, string.Empty);

    public DisposeButtonAsync(Button? button, string loadingText)
    {
        if (button == null) return; // 버튼이 없으면 아무 작업도 안 함

        _button = button;
        _originalContent = _button.Content;

        _button.IsEnabled = false;
        _button.Content = loadingText;
    }

    public async ValueTask DisposeAsync()
    {
        if (_isDisposed || _button == null) return;

        try
        {
            if (_button.Dispatcher.CheckAccess())
            {
                Restore();
            }
            else
            {
                await _button.Dispatcher.InvokeAsync(Restore);
            }
        }
        finally
        {
            _isDisposed = true;
            GC.SuppressFinalize(this);
        }
    }

    private void Restore()
    {
        if (_button == null) return;
        _button.Content = _originalContent;
        _button.IsEnabled = true;
    }
}
1
2
3
4
5
6
7
8
9
public async void OnClick(object sender, RoutedEventArgs e)
{
    // sender에서 바로 호출! (Button이 아니면 내부적으로 무시됨)
    await using (sender.BeginLoading("서버 전송 중..."))
    {
        await Task.Delay(2000);
        // 비즈니스 로직 작성...
    }
}

아래의 두 가지 방법은 Singleton으로 클래스를 만들어서 사용하고 내용 중에 프로그래스바의 형태로 표현하기 위해 [" ⦁ ", " ⦁⦁ ", " ⦁⦁⦁ ", " ⦁⦁⦁⦁ ", " ⦁⦁⦁⦁⦁"] 형태를 버튼 Content에 표현한다. 이 부분을 실제 프로그래스바로 바꾸어도 무방하다.

ProgressButtonAlone class

이 클래스를 적용한 모든 버튼은 하나의 버튼이 동작 중일 때는 다른 모든 버튼은 ‘사용중…’ 이라는 내용이 버튼의 Content 영역에 나타난다. 그리고 동작이 완료된 다음에 다른 버튼도 동일한 동작 방식으로 처리한다.

1
2
3
4
5
6
7
8
public static async Task ButtonOnTest(object sender, RoutedEventArgs e)  
{  
    await ProgressButtonAlone.Go.ExecuteAsync(sender as Button, async () =>  
    {  
        // 해당 작업이 있다고 가정  
        await Task.Delay(5000);  
    });  
}

ProgressButtonAlone.cs 소스보기

ProgressButton class

이 클래스는 위와 다르게 모든 버튼이 각각 독립적으로 동작한다.

1
2
3
4
5
6
7
8
public static async Task ButtonOnTest(object sender, RoutedEventArgs e)  
{  
    await ProgressButton.Go.ExecuteAsync(sender as Button, async () =>  
    {  
        // 해당 작업이 있다고 가정  
        await Task.Delay(5000);  
    });  
}

ProgressButton.cs 소스보기

ProgressButtonAlone, ProgressButton 소스는 Github에 올려두었으니 해당 링크를 참고하기 바란다.

Reference
  1. learn.microsoft.com, “Implement a DisposeAsync method”
This post is licensed under CC BY 4.0 by the author.