티스토리 뷰

반응형

2022.04.15 - [WPF .NET] - Microsoft.Toolkit.Mvvm을 이용한 간단한 프레임워크 part5 - [ObservableProperty] 속성(2/2)

2022.04.14 - [WPF .NET] - Microsoft.Toolkit.Mvvm을 이용한 간단한 프레임워크 part5 - Service 추가(1/2)

2022.03.03 - [WPF .NET] - Microsoft.Toolkit.Mvvm을 이용한 간단한 프레임워크 part4 - LayerPopup 추가

2022.03.02 - [WPF] - Microsoft.Toolkit.Mvvm을 이용한 간단한 프레임워크 part3 - Busy 화면 구현

2022.02.24 - [WPF] - Microsoft.Toolkit.Mvvm을 이용한 간단한 프레임워크 part2 - Frame Navigation

2022.02.21 - [WPF] - Microsoft.Toolkit.Mvvm을 이용한 간단한 프레임워크 part1

 

개발을 진행하다보면, 특정 CRUD화면에서 각 입력 값들에 대한 유효성 검사를 진행해야하는 경우가 존재합니다.

이 경우 일반적으로는 저장하는 단계에서 자리수를 확인하거나 숫자인지 문자인지를 확인하는 별도의 코드를 추가해서 데이터의 유효성 검사를 진행하는데..

 

사실 WPF에서는 바인딩되는 모델에 대한 유효성 검사를 하는 기능이 존재합니다. 다만, 사용하기가 불편해서 잘 사용을 않할 뿐인데...(음 저만 그런건가요? 흐흐;;) Microsoft.Toolkit.Mvvm에는 이 것도 편하게 할 수 있는 방법이 있습니다.

기본적인 바인딩 유효성 검사에 대해서는 여기를 참고하시 바랍니다.

1. Customer.cs

Customer 클래스에 유효성 검사 기능을 추가하기 위해서는 ObservableValidator를 상속 받고, 각 프로퍼티에 속성(Attribute)을 추가해서 유효성 검사를 수행하도록 작업을 할 수 있는데, 원래 유효성 검사용 속성은 여러가지 종류가 있는데, Toolkit에서 지원하는 것은 아래 정리했습니다.

자주 사용되는 것들만 확인했습니다. 더 많은 속성은 여기를 참고하시기 바랍니다.
Display 관련 속성은 지원하지 않습니다. 
  • Required : 필수값
  • MaxLength : 최대 길이
  • MinLength : 최소 길이
  • Phone : 전화번호 형식
  • EmailAddress : 이메일 형식
  • Range : 범위(숫자형)
  • StringLength : 문자길이

CustomerID의 경우는 아래와 코드처럼 생성됩니다.

        [ObservableProperty]
        [Required]
        [MaxLength(5)]
        private string _customerID;
        
        //생성된 코드
        [global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Toolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "7.1.0.0")]
        [global::System.Diagnostics.DebuggerNonUserCode]
        [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
        [global::System.ComponentModel.DataAnnotations.RequiredAttribute()]
        [global::System.ComponentModel.DataAnnotations.MaxLengthAttribute(5)]
        public string CustomerID
        {
            get => _customerID;
            set
            {
                if (!global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(_customerID, value))
                {
                    OnPropertyChanging(global::Microsoft.Toolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedOrChangingArgs.CustomerIDPropertyChangingEventArgs);
                    _customerID = value;
                    OnPropertyChanged(global::Microsoft.Toolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedOrChangingArgs.CustomerIDPropertyChangedEventArgs);
                    ValidateProperty(value, "CustomerID");
                }
            }
        }

Customer 테이블의 제약 조건들을 확인 한 후에 아래와 같이 수정했습니다.

 

using Microsoft.Toolkit.Mvvm.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace WpfFramework.Models
{
    /// <summary>
    /// Customer
    /// </summary>
    public partial class Customer : ObservableValidator
    {
        [ObservableProperty]
        [Required]
        [MaxLength(5)]
        private string _customerID;
        [ObservableProperty]
        [Required]
        [MaxLength(40)]
        private string _companyName;
        [ObservableProperty]
        [MaxLength(30)]
        private string _contactName;
        [ObservableProperty]
        [MaxLength(30)]
        private string _contactTitle;
        [ObservableProperty]
        [MaxLength(60)]
        private string _address;
        [ObservableProperty]
        [MaxLength(15)]
        private string _city;
        [ObservableProperty]
        [MaxLength(15)]
        private string _region;
        [ObservableProperty]
        [MaxLength(10)]
        private string _postalCode;
        [ObservableProperty]
        [MaxLength(15)]
        private string _country;
        [ObservableProperty]
        [MaxLength(24)]
        [Phone]
        private string _phone;
        [ObservableProperty]
        [MaxLength(24)]
        [Phone]
        private string _fax;
    }
}

2. CustomerPage.xaml

모델을 수정했으니, 뷰를 수정해서 유효성 검사를 해서 오류가 발생하면 어떻게되는지 확인하도록 하겠습니다.

CustomerPage에 CRUD가 가능하도록 아이템 상세를 편집할 수 있도록 TextBox를 배치하고, Add, Save 버튼을 추가했습니다.

<Page
    x:Class="WpfFramework.Views.CustomerPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace:WpfFramework.Views"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    Title="CustomerPage"
    d:DesignHeight="450"
    d:DesignWidth="800"
    mc:Ignorable="d">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <StackPanel Orientation="Horizontal">
            <Button
                Width="50"
                Command="{Binding BackCommand}"
                Content="Back" />
        </StackPanel>
        <StackPanel
            Grid.Row="0"
            Grid.ColumnSpan="2"
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Orientation="Horizontal">
            <TextBlock FontSize="20" Text="{Binding Title}" />
            <TextBlock
                Margin="10,0"
                VerticalAlignment="Bottom"
                Text="{Binding Message}" />
        </StackPanel>
        <DataGrid
            Grid.Row="1"
            Margin="2"
            CanUserAddRows="False"
            IsReadOnly="True"
            ItemsSource="{Binding Customers}"
            SelectedItem="{Binding SelectedCustomer, Mode=TwoWay}"
            SelectionMode="Single" />
        <StackPanel
            Grid.Column="1"
            HorizontalAlignment="Right"
            Orientation="Horizontal">
            <Button
                Width="80"
                Command="{Binding AddCommand}"
                Content="Add" />
            <Button
                Width="80"
                Command="{Binding SaveCommand}"
                Content="Save" />
        </StackPanel>
        <Border
            Grid.Row="1"
            Grid.Column="1"
            Background="Beige"
            BorderBrush="Blue"
            BorderThickness="1"
            CornerRadius="5">
            <Grid Margin="5">
                <Grid.Resources>
                    <Style TargetType="TextBox">
                        <Setter Property="Margin" Value="0,2.5,0,2.5" />
                    </Style>
                    <Style TargetType="TextBlock">
                        <Setter Property="VerticalAlignment" Value="Center" />
                    </Style>
                </Grid.Resources>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="100" />
                    <ColumnDefinition />
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition />
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" Text="CustomerID" />
                <TextBox
                    Grid.Row="0"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.CustomerID, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="1" Text="Company Name" />
                <TextBox
                    Grid.Row="1"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.CompanyName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="2" Text="Contact Name" />
                <TextBox
                    Grid.Row="2"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.ContactName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="3" Text="Contact Title" />
                <TextBox
                    Grid.Row="3"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.ContactTitle, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="4" Text="Address" />
                <TextBox
                    Grid.Row="4"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.Address, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="5" Text="City" />
                <TextBox
                    Grid.Row="5"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.City, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="6" Text="Region" />
                <TextBox
                    Grid.Row="6"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.Region, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="7" Text="Postal Code" />
                <TextBox
                    Grid.Row="7"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.PostalCode, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="8" Text="Country" />
                <TextBox
                    Grid.Row="8"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.Country, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="9" Text="Phone" />
                <TextBox
                    Grid.Row="9"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.Phone, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock Grid.Row="10" Text="Fax" />
                <TextBox
                    Grid.Row="10"
                    Grid.Column="1"
                    Text="{Binding SelectedCustomer.Fax, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                <TextBlock
                    Grid.Row="11"
                    Grid.ColumnSpan="2"
                    Margin="5"
                    VerticalAlignment="Top"
                    Foreground="Red"
                    Text="{Binding ErrorMessage}"
                    TextWrapping="Wrap" />
            </Grid>
        </Border>
    </Grid>
</Page>

3. CustomerViewModel.cs

마지막으로 뷰모델도 수정을 해줍니다.

DataGrid에서 선택된 아이템을 뷰모델에 TwoWay 바인딩을 해줘야하니 SelectedCustomer 프로퍼티를 추가합니다.

        [ObservableProperty]
        private Customer _selectedCustomer;

뷰모델에도 모델에서처럼 [ObservableProperty] 속성을 이용해서 작업을 합니다. 이 방법을 사용하려면, 뷰모델을 partial class로 만들어야 하는것은 이제 설명 드리지 않아도 아시겠죠?

다음에는 AddCommandSaveCommand가 필요한데..여기도 Toolkit의 기능을 사용합니다.

 

AddCommand

일반 메서드를 생성하고 [ICommand]라는 속성을 추가해주면 AddCommand라는 커맨드가 뷰모델에 추가됩니다. 이 방법을 사용하면 여러개의 커맨드를 각각 프로퍼티로 추가하고 메소드로 연결하는 작업을 할 필요가 없어지겠죠?

        /// <summary>
        /// AddCommand
        /// </summary>
        [ICommand]
        private void Add()
        {
            var newCustomer = new Customer();
            Customers.Insert(0, newCustomer);
            SelectedCustomer = newCustomer;
            SaveCommand.NotifyCanExecuteChanged();
        }

SaveCommand

그러나.. 위에서 사용한 방법이 그동안 사용한 모든 방법을 대체할 수는 없습니다. 특히, 커맨드 사용 가능 여부를 확인하는 조건이 들어가있는 경우에는 기존 방식을 사용해야합니다.

 

아래 코드에서는 Customers 프로퍼티에 새로 추가된 데이터가 있을 때만, Save 버튼이 활성화되도록 만들어져있습니다.

        public IRelayCommand SaveCommand { get; set; }

        //Init()
            SaveCommand = new RelayCommand(Save, 
                            () => Customers != null 
                            && Customers.Any(c => string.IsNullOrWhiteSpace(c?.CustomerID)));
        //...
        private void Save()
        {
            MessageBox.Show("Save");
            SaveCommand.NotifyCanExecuteChanged();
        }

4. 실행 화면

DataGrid에 데이터를 선택하면 오른쪽에 상세 정보가 출력됩니다.

Company Name의 데이터를 삭제하면 빨간색 테두리가 추가되는데, 이유는 [Required] 속성이 적용된 프로퍼티이기 때문입니다.

Phone에 hello를 추가했더니 빨간색 테두리가 추가되는데, 이유는 [Phone] 속성이 적용된 프로퍼티이기 때문입니다.

그리고, DataGrid에도 빨간색 테두리가 추가됩니다.

이제 유효성 검사기능이 어떻게 동작하는지 아시겠죠? 

5. 에러 메시지 출력

WPF가 제공하는 기본 컨트롤들은 빨간색 테두리를 보여주는 것으로 유효성 검사에서 오류가 발생되었음을 알려줍니다. 하지만, 3rd party 컨트롤(Telerik, Dev Express, C1 등등)을 사용하면 에러메시지를 바로 확인할 수 있습니다.

아래 이미지는 Telerik Wpf 문서에서 가져온 것으로, 빨간색 테두리 안쪽 오른쪽 상단에 커서를 가져다 대면 자세한 에러 메시지가 출력됩니다.

3rd party 컨트롤처럼 깔끔하게 에러메시지를 확인할 수 있으면 좋겠지만, 이 포스트에서는 화면에 오류 메시지를 출력하는 기능만 추가하도록 하겠습니다.

 

에러 메시지를 출력하기 위해서는 모델의 ErrorsChanged 이벤트를 이용해야 합니다.

아래와 같은 코드를 이용하면되겠죠? 

                        SelectedCustomer.ErrorsChanged += SelectedCustomer_ErrorsChanged;

그런데 문제는 SelectedCustomer는 DataGrid에서 선택한 아이템이 변경되면 계속 변경이 된다는 것입니다. 그렇기 때문에 선택이 변경이될 때마다 이벤트를 연결해 주어야하고, 그렇게 연결만 시켜주면 이벤트 핸들러가 중복으로 연결될 수도 있고, 메모리 누수가 발생할 수도 있겠죠?

그래서, 뷰모델에서 PropertyChangingPropertyChanged 이벤트 핸들러를 이용해서 문제를 해결하면서, 에러 메시지를 출력할 수 있습니다.

  • PropertyChanging : 프로퍼티가 변경되기 전에 발생되는 이벤트
  • PropertyChanged : 프로퍼티가 변경된 후 발생되는 이벤트

SelectedCustomer가 변경되기 전에 ErrorsChanged 이벤트와 연결된 이벤트 핸들러를 제거하고, 변경된 후에는 이벤트 핸들러를 추가해주는 코드를 작성하면 됩니다.

CustomerViewModel.cs

            //Init()
            PropertyChanging += CustomerViewModel_PropertyChanging;
            PropertyChanged += CustomerViewModel_PropertyChanged;

        private void CustomerViewModel_PropertyChanging(object sender, System.ComponentModel.PropertyChangingEventArgs e)
        {
            switch (e.PropertyName)
            {
                case nameof(SelectedCustomer):
                    if(SelectedCustomer != null)
                    {
                        SelectedCustomer.ErrorsChanged -= SelectedCustomer_ErrorsChanged;
                        ErrorMessage = string.Empty;
                    }
                    break;
            }
        }

        private void CustomerViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            switch(e.PropertyName)
            {
                case nameof(SelectedCustomer):
                    if (SelectedCustomer != null)
                    {
                        SelectedCustomer.ErrorsChanged += SelectedCustomer_ErrorsChanged;
                        SetErrorMessage(SelectedCustomer);
                    }
                    break;
            }
        }

        private void SelectedCustomer_ErrorsChanged(object sender, System.ComponentModel.DataErrorsChangedEventArgs e)
        {
            SetErrorMessage(sender as Customer);
        }

        private void SetErrorMessage(Customer customer)
        {
            if(customer == null)
            {
                return;
            }
            var errors = customer.GetErrors();
            ErrorMessage = string.Join("\n", errors.Select(e => e.ErrorMessage));
        }

6. 에러 메시지가 출력되는 실행 화면

이번에는 정확한 오류 메시지가 화면에 출력됩니다. 

7. 소스

전체 소스는 아래 링크를 참고하시기 바랍니다.

추가 안내

Windows Community Toolkit으로 이동하면서 벨리데이션 부분에 변동사항이 있습니다.

자세한 사항은 Microsoft.Toolkit.MVVM을 이용한 간단한 프레임워크 part7 - Windows Community Toolkit Upgrade (tistory.com) 를 참고하시기 바랍니다.

 

kaki104/WpfFramework at part7/add-validation-data (github.com)

 

GitHub - kaki104/WpfFramework

Contribute to kaki104/WpfFramework development by creating an account on GitHub.

github.com

 

반응형
댓글