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);
});
}
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);
});
}
ProgressButtonAlone, ProgressButton 소스는 Github에 올려두었으니 해당 링크를 참고하기 바란다.