WPF MVVM패턴에서의 Checkbox 처리에 대해 문의드립니다.

ASP 개발을 주로 하다가 이번에 WPF 를 새롭게 공부하고 있습니다. 그래서 Udemy 강좌를 보고 나서 토이프로젝트로 간단한 게시판을 만들어 보려고 합니다.

현재 구상 중으로는 View에선 <ListView> <GridView><GridViewColumn> 구조로 File 관련 정보를 보여주고(이 부분은 정상으로 동작합니다.) 1) Header에 있는 Checkbox에 따라서 ‘전체선택’ 혹은 '전체해제’를 수행하고, 2) 일부 item에 대해 Checkbox Column을 선택하면 이 값의 변경을 VM에서 Property와 전체 리스트를 ObservableCollection<T>로 관리해야 할 것 같습니다. 3) 그리고 하단에 <Button>을 만들어서 Checkbox에서 checked 된 ListItem만을 출력 혹은 파일로 Export하는 기능을 수행하려고 합니다.

하지만 Checkbox 때문에 진전이 없는 상태입니다. 흔히, Checkbox에서 3가지 상태(선택 안함-null, isChecked=True, isChecked=false)로 구분해서 관리하는 것 같은데, 아직 MVVM 패턴이 익숙하지 않아서 Command와 VM에 각각 어떻게 처리해야 할지 감이 오지 않습니다. 구글링을 해봐도, 작성일에 따라 저마다 다른 방식을 보여주는데 어느 방법이 효율적인 방법인지 이렇게 글을 남기게 되었습니다.

하지만 고민이 되는 부분은 전체/일부 선택 Checkbox를 checked할 당시 해당 부분을 Command 로 binding을 하고 VM에서 전체 선택 혹은 일부 선택한 item에 대해 구분을 해야 할 것 같은데 Model(ListItem)에서 별도로 id와 isChecked 프로퍼티를 추가해야 하는게 나을지, 아니면 더 좋은 방식이 있는지 조언을 부탁드립니다.

<Window x:Class="WPF_XML.MainWindow"
        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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WPF_XML"
        xmlns:vm="clr-namespace:WPF_XML.ViewModels"
        mc:Ignorable="d"
        Title="MainWindow"
        Height="450"
        Width="800">

    <Window.Resources>
        <vm:ListVM x:Key="vm" />
    </Window.Resources>

    <DockPanel>
       <StackPanel Orientation="Vertical"
                    Margin="10"
                    DataContext="{StaticResource vm}">
            <ListView Name="listView"
                      ItemsSource="{Binding lists}"
                      SelectionMode="Multiple"
                      HorizontalAlignment="Stretch"
                      VerticalAlignment="Top">
                <ListView.View>
                    <GridView>
                        <GridViewColumn  Width="25">
                            <GridViewColumn.Header>
                                <CheckBox Name="selectAll" HorizontalAlignment="Center" VerticalAlignment="Center"  IsThreeState="False"  />
                            </GridViewColumn.Header>
                            <GridViewColumn.CellTemplate>
                                <DataTemplate>
                                    <CheckBox Name="selectOne" Command="{Binding Source={StaticResource vm}, Path=SelectCommand}" 
                                              CommandParameter="{Binding IsChecked, RelativeSource={RelativeSource Self}}" 
                                              HorizontalContentAlignment="Center"  HorizontalAlignment="Center" VerticalAlignment="Center"    IsThreeState="False"/>
                                </DataTemplate>
                            </GridViewColumn.CellTemplate>
                        </GridViewColumn>
                        <GridViewColumn Header="FileName"
                                        DisplayMemberBinding="{Binding FileName }"
                                        Width="200" />
                        <GridViewColumn Header="Modified"
                                        DisplayMemberBinding="{Binding Modified }"
                                        Width="100" />                        
                        <GridViewColumn Header="Contents"
                                        DisplayMemberBinding="{Binding Contents}"
                                        Width="100" />
                    </GridView>
                </ListView.View>
            </ListView>
        </StackPanel>
    </DockPanel>
</Window>
namespace WPF_XML.ViewModels
{
    public class ListVM: INotifyPropertyChanged
    {
        public ObservableCollection<ListItem> lists{ get; set; }
        private ListItem selectedItem;
        public ListItem SelectedItem
        {
            get { return selectedItem; }
            set { selectedItem= value; }
        }

        public SelectCommand SelectCommand;
        public ListVM()
        {
            SelectCommand = new SelectCommand(this);
            lists= new ObservableCollection<ListItem>();
            GetList();
        }

        public event PropertyChangedEventHandler PropertyChanged;
        [NotifyPropertyChangedInvocator]
        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {

            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        private void GetList()
        {
            var initialList= ListHelper.GetItemList();
            foreach (var item in initialList)
            {
                lists.Add(item);
            }
        }   
    }
}

public class SelectCommand : ICommand
{
    private ListVMvm;
    public event EventHandler CanExecuteChanged
    {
        add { CommandManager.RequerySuggested += value; }
        remove { CommandManager.RequerySuggested -= value; }
    }

    public SelectCommand(ListVM_vm)
    {
        vm = _vm;
    }
    public bool CanExecute(object parameter)
    {
        return true;
    }

    public void Execute(object parameter)
    {
        // Not implemented
    }

}
    public class ListItem
    {
        public string FileName { get; set; }

        public string Modified { get; set; } 
    
        public string Contents{ get; set; } 
    }
}

2개의 좋아요

Command로 체크를 직접하는게 아니라 ListItem와 CheckBox를 바인딩해서 CheckBox가 체크되면 ListItem의 bool값이 true가 되도록 하는 것이 좋아보입니다.

음…옛날에 만들어 둔건데

이렇게 생긴 데이터를 Set 해주는 전용 모델이 있다면 아래처럼 Set할 때 사용할 수 있습니다.

위처럼 뷰모델과 필요하다면 모델에까지 상속해서 Notify를 줄 수 있게 하고 아래와 같이 코드를 쓰면 됩니다.

물론 더 좋은 코드도 많을 것이니 찾아보고 좋은 쪽을 적용해주면 됩니다.

그래서 이런 WPF MVVM을 돕기 위한 여러 프레임워크, 라이브러리 들이 있으니, 손수 구현해보는 것을 몇번 해보시고 원리를 파악하신 뒤엔 그런 라이브러리들을 공부하시는 것도 좋을 거 같습니다.

대표적으로 Prism, MVVMLight, MVVM Toolkit, DX MVVM, Caliburn 등등이 있습니다.

4개의 좋아요

아… 먼저, CheckBox 의 IsChecked 에 Three state 라 함은…

(선택 안함-null, isChecked=True, isChecked=false)

이 아니라 indeterminate, checked, unchecked 입니다.
여기서 indeterminate 는 선택 안 함 의미하는 게 아니라 checked도 unchecked 아닌 상태를 말하는 거죠.
(선택 안 함은 unchecked 입니다.)

보통 tree 나 collection 메뉴에서 전체 체크박스일 때 많이 사용하는데

모든 체크박스가 체크되어 있으면 → 전체 체크박스 checked
모든 체크박스가 체크되어 있지 않으면 → unchecked
일부만 체크되어 있다면 → indeterminate

요런 식입니다.
(일부만 체크된 상황은 전체 체크박스 입장에서는 checked도 아니고 unchecked 도 아니니까요)

뭐 당연히 수동을 처리할 수 있구요. 언어마다 약간씩 다르지만
three state 지원하는 checkbox 에 보통 null 을 할당하면 indeterminate 상태가 됩니다.
(이 null 은 선택을 안 했다는 의미가 아니라 말 그대로 불확실하다는 의미로 사용되는 거죠.)

큼큼…

뭐 그건 그렇고

방법론면에서 더 간단하게 IsChecked 의 판단을 view 의 입력에 맡겨버리는 것도 있습니다.

checkbox 의 command 호출을 이용해 별도의 조작을 의도하는 것도 좋지만
과연 그게 command 로 커스텀하게 처리할만큼 복잡한 로직인가… 를 생각해보면

제 생각에는… 굳이 ?ㅅ?

입니닷…ㅇㅅㅇ;;

예를 들어 기존 구현에서 ListItem 에다가 CheckBox 의 IsChecked 를 binding 으로 연결하면

public class ListItem
{
    ...... // 기존 property들 포함

    // 변경 통보 생략
    public bool? IsChecked { get; set; }
}

<!-- DataContext 있다고 치고 -->
<CheckBox IsChecked="{Binding IsChecked}"/>

요런식으루 하면 개별 CheckBox에서는 SelectCommand 가 필요 없어집니다.
(간단하고 깔끔하고 쉬운 방법이죠. 어렵게 생각하면 한 없이 어려워져욤 -ㅂ-;:wink:

그리고 또, 음… 뭐…

전체 체크박스는…

https://blog.naver.com/vactorman/221157715465

이런식이면 되지 않을까요 ?ㅅ?

거의 다 왔는데 한 발짝만 더 나가면 될 거 같아 보여욤 ㅇㅅㅇ/

5개의 좋아요

@Vincent @Greg.Lee 친절한 댓글 정말 감사드립니다. 우선, 조언 주신대로 구현해보고 결과를 다시 이곳에 공유하겠습니다. 오늘 하루도 건강한 하루 되세요!

3개의 좋아요

@Greg.Lee 님의 조언대로 해당 포스팅을 보고 변경을 해보았습니다. 우선 View의 code behind가 아닌 VM에서 바인딩이 되도록 수정하였고, IsChecked 프로퍼티를 추가해서 개별 ListItem에 대해서는 Checked가 정상으로 바인딩이 됩니다.

다만, <CheckBox x:Name="xSelectAll" IsChecked="{Binding Path=lists.IsSelectAll}" /> 에서 SelectableCollection 에 있는 IsSelectAll이 trigger되지 않는 것 같습니다. 기존 작성하신 코드는 Code behind에서 trigger하는 방식으로 <CheckBox x:Name="xSelectAll" IsChecked="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}, Path=DataContext.lists.IsSelectAll}" />동일한 것 같은데… 혹시 제가 놓친 부분이 있다면 조언을 부탁드립니다.

<Window x:Class="WPF_XML.MainWindow"
        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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WPF_XML"
        xmlns:vm="clr-namespace:WPF_XML.ViewModels"
        mc:Ignorable="d"
        Title="MainWindow"
        Height="450"
        Width="800">

    <Window.Resources>
        <vm:ListVM x:Key="vm" />
    </Window.Resources>
    <DockPanel>
       <StackPanel Orientation="Vertical"
                    Margin="10"
                    DataContext="{StaticResource vm}">
        <DataGrid Name="listsGrid"
                            ItemsSource="{Binding lists }"
                            HorizontalAlignment="Stretch"
                            VerticalAlignment="Top">
                      <DataGrid.Columns>
                          <DataGridCheckBoxColumn x:Name="xCheckBox" Binding="{Binding IsChecked, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" 
                                              ElementStyle="{StaticResource CheckBoxOneClick}"
                                              Width="30" MinWidth="30" >
                              <DataGridCheckBoxColumn.Header>
                                  <CheckBox x:Name="xSelectAll"
                                            IsChecked="{Binding Path=lists.IsSelectAll}" />
                              </DataGridCheckBoxColumn.Header>
                          </DataGridCheckBoxColumn>
                          <DataGridTextColumn Header="FileName" Binding="{Binding FileName}"/>
                          <DataGridTextColumn Header="Modified Name" Binding="{Binding Contents}"/>
                      </DataGrid.Columns>
                  </DataGrid>
        </StackPanel>
    </DockPanel>
</Window>
namespace WPF_ParsingXML.View
{
    /// <summary>
    /// Interaction logic for Driver_Page1.xaml
    /// </summary>
    public partial class MainWindow: Window
    {
        public MainWindow()
        {
            InitializeComponent();       
        }
    }
}

namespace WPF_ParsingXML.ViewModels
{
    public class ListVM: INotifyPropertyChanged
    {       
        public SelectableCollection<ListItem> lists { get; } = new SelectableCollection<ListItem>();
        XMLHelper xMLHelper;

        public ListVM()
        {          
            GetList();
        }

        public event PropertyChangedEventHandler PropertyChanged;

        [NotifyPropertyChangedInvocator]
        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {

            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public void GetList()
        {
            var initialList= XMLHelper.ParseXML_Sample2();

            foreach (var item in initialList)
            {
                lists .Add(item );
            }
        }    
    }
}

namespace WPF_ParsingXML.Models
{
    public class ListItem: INotifyPropertyChanged, ISelectable
    {
        public string FileName { get; set; }

        public string Modified { get; set; } //DateTime

        public string Contents{ get; set; }

        private bool _isChecked;

        public bool IsChecked
        {
            get { return this._isChecked; }
            set
            {
                this._isChecked = value;
                this.OnPropertyChanged();
            }
        }
        public event PropertyChangedEventHandler PropertyChanged;

        [NotifyPropertyChangedInvocator]
        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

namespace WPF_ParsingXML.Selectable
{
	public class SelectableCollection<T> : ObservableCollection<T> 
		where T : INotifyPropertyChanged, ISelectable
	{
		private int _selectedCount;

		private bool _isSelectAll;

		public bool IsSelectAll
		{
			get { return this._isSelectAll; }
			set
			{
				this._isSelectAll = value;
				this.SelectAllCheckBox(value);
				this.OnPropertyChanged(new PropertyChangedEventArgs("IsSelectAll"));
			}
		}

		public new void Add(T item)
		{
			base.Add(item);
			item.PropertyChanged -= this.ItemOnPropertyChanged;
			item.PropertyChanged += this.ItemOnPropertyChanged;
		}

		public new void Remove(T item)
		{
			base.Remove(item);
			item.PropertyChanged -= this.ItemOnPropertyChanged;
		}

		private void ItemOnPropertyChanged(object sender, PropertyChangedEventArgs e)
		{
			if (sender is ISelectable == false)
			{
				return;
			}

			if (string.CompareOrdinal(e.PropertyName, "IsChecked") != 0)
			{
				return;
			}

			var model = (ISelectable) sender;
			
			if (model.IsChecked)
			{
				this._selectedCount++;
				if (this.Count == this._selectedCount)
				{
					this.IsSelectAll = true;
				}

				return;
			}

			this._selectedCount--;
			this.IsSelectAll = false;
		}

		public void SelectAllCheckBox(bool isChecked)
		{
			var alreadyAllChecked = this._selectedCount == this.Count;
			if (isChecked == alreadyAllChecked)
			{
				return;
			}

			foreach (var checkModel in this.Items.OfType<ISelectable>())
			{
				checkModel.IsChecked = isChecked;
			}

			this._selectedCount = isChecked ? this.Count : 0;
		}
	}
}
namespace WPF_ParsingXML.Selectable
{
	public interface ISelectable
	{
		bool IsChecked { get; set; }
	}
}
1개의 좋아요

에… 뭔가 오해가 있으셨는지는 모르겠지만

code behind 에서 트리거하지 않아요. 그렇게 동작하지도 않고 제가 추구하는 바도 아닙니다…
(뭐 테스트 용도로를 간혹 쓰기는 하지만 권장하는 건 아니옵니다…-ㅂ-)

포스팅에 첨부된 파일은 완전히 빌드되는 건 아닐 겁니다.
(근데 그건 중요한 부분이 아니라 그냥 살짝 수정만 하면 됩니다. 에러 나는 부분은 그냥 삭제 ㄱㄱ)

그리고 안 된다고 하신 부분은…

음… 디버깅을 해보셨으면 output window에 에러 메시지 찍혔을 거 같은데요… ㅇㅅㅇ?

혹시 디버깅 시 Binding 관련 에러가 output window 에 표시되지 않는다면
image

요 세팅을 한 번 해보시면 될 거 같아요.

에러 메시지를 보시면 아마 binding 쪽 문제로 확인될 겁니다.
그거만 고치면 잘 될 거 같아욤 +ㅅ+

2개의 좋아요

@Greg.Lee 제가 자세히 보지 못했네요… 말씀해주신대로 바인딩을 확인해보니, Source 지정을 안해서 발생한 부분이었습니다. 감사합니다. :slight_smile:

<CheckBox x:Name="xSelectAll"  IsChecked="{Binding Source={StaticResource vm}, Path=Softwares.IsSelectAll}" /> ```
1개의 좋아요