본문 바로가기

.NET/WPF

[WPF] ViewModel이 아닌 다른 클래스의 속성에 바인딩하기

WPF로 개발을 하다 보면 종종 DataContext로 지정될 ViewModel 객체가 아닌 다른 클래스로부터 데이터를 가져와야 하는 상황이 발생할 수 있다.

아래 코드를 보자.

namespace MyProject.Core
{
    public class ObjectManager : BindableBase
    {
        private static ObjectManager _instance;
        public static ObjectManager Instance => _instance ??= new();
        
        private string _myString = string.Empty;
        public string MyString
        {
            get => _myString;
            set => SetProperty( ref _myString, value );
        }
    }
}


특정 데이터가 전역으로 관리되어야 하는 경우 아래 코드의 ObjectManager처럼 별도의 클래스를 만들어 싱글턴 패턴으로 프로그램 내 전역에서 접근이 가능하도록 데이터 집합을 구성할 수 있다.

이러한 경우 View에서 데이터 바인딩으로 ObjectManager의 MyString 속성을 View에 표시하거나 변경하기 위해서는 아래와 같은 방법을 사용할 수 있다.

 

1. ObjectManager의 Instance를 View의 DataContext로 지정한다.

// MainWindowView.xaml.cs (MainWindowView의 코드비하인드)
namespace MyProject.Views
{
    public partial class MainWindowView : Window
    {
        public MainWindowView()
        {
            InitializeComponent();
        
            DataContext = ObjectManager.Instance;
        }
    }
}

그 후 xaml에서 바인딩한다.

<Window x:Class="MyProject.Views.MainWindowView"
        ... 생략
        Title="MainWindowView">
    <Grid>
        <TextBox Text="{Binding MyString, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
    </Grid>
</Window>

방법이라고 하기 무색할 만큼 당연한 방법이다.

그러나 이러한 방법은 View에 별도의 ViewModel을 DataContext로 지정할 수 없다는 치명적인 단점이 존재한다. 전역으로 모든 View에서 같은 데이터로 동기화하고 싶어 ObjectManager에 추가해놓은 MyString을 바인딩하려면 ObjectManager.Instance를 DataContext로 지정하는 것이 강제되어 버린다.

 

2. ViewModel에서 ObjectManager의 속성을 미러링

namaspace MyProject.ViewModels
{
    public class MainWindowViewModel : ViewModelBase
    {
        private string _myString;
        public string MyString
        {
            get => _myString;
            set => SetProperty( ref _myString, value );
        }
        
        public MainWindowViewModel()
        {
            ObjectManager.Instance.PropertyChanged += NotifyCatcher;
        }
    }
}

ObjectManager에서 발생하는 PropertyChanged 이벤트를 구독하여 MainWindowViewModel에 있는 MyString 속성이 그대로 미러링하도록 하는 것이다.

위 코드에서 사용한 NotifyCatcher는 각 ViewModel에 직접 구현할 수 있지만 여기 저기에서 사용할 수 있도록 ViewModelBase에 아래와 같이 추가해둘 수 있다.

namespace MyProject.Core
{
    public abstract class ViewModelBase : INotifyPropertyChanged
    {
        // 생략
        
        
        // 이 메서드를 INotifyPropertyChanged 구현 클래스의 PropertyChanged에 등록하면 PropertyChanged 이벤트가 발생했을 때 현재 개체에서 똑같은 이름을 가지는 속성을 찾아 같은 값으로 변경한다.
        protected virtual void NotifyCatcher( object sender, PropertyChangedEventArgs e )
        {
            TryChangeProperty( sender, e );
        }
        
        // sender는 NotifyCatcher가 PropertyChanged에 등록되어 있고, 이 메서드가 호출된 시점에서 방금 막 속성값이 변경된 개체
        protected bool TryChangeProperty( object sender, PropertyChangedEventArgs e )
        {
            // 바뀐 속성 이름
            var pname = e.PropertyName;
            // 속성 이름으로 현재 개체에서 속성 정보를 가져온다.
            var pinfo = this.GetType().GetProperty( pname );
            
            // 가져와진게 없다 = 동일한 이름의 속성이 없다.
            if ( pinfo == null ) return false;

            // 가져와진게 있고, 해당 속성에 set 메서드가 존재하면 그 속성의 값을 바뀐 속성의 값을 가져와 동일하게 변경한다.
            pinfo.SetMethod?.Invoke( this, new object[] { sender.GetType().GetProperty( e.PropertyName ).GetValue( sender ) } );

            return true;
        }
    }
}

이렇게 되면 ObjectManager의 MyString 값이 변경될 때 PropertyChanged 이벤트가 발생하게 되고, 해당 이벤트에 등록된 NotifyCatcher 메서드가 ObjectManager의 MyString 값을 가져와 MainWindowViewModel의 MyString에 set한다.

단, 반대로 MainWindowViewModel의 MyString 값이 ObjectManager로도 올라가야 하기 때문에 값이 양방향으로 이동하도록 하려면 ObjectManager의 NotifyCatcher 역시 MainWindowViewModel의 PropertyChanged 이벤트에 등록되어야 하니 여간 번거로운 일이 아니다.

 

 

방법에 대해 구구절절 설명했지만 정작 해결 방법은 굉장히 간단하기 짝이 없다.

<Window x:Class="MyProject.Views.MainWindowView"
        ... 생략
        om:xmlns="clr-namespace:MyProject.Core"
        Title="MainWindowView">
    <Grid>
        <TextBox Text="{Binding Source={x:Static om:ObjectManager.Instance}, Path=MyString, UpdateSourceTrigger=PropertyChanged}"/>
    </Grid>
</Window>

Binding Source로 ObjectManager 클래스의 정적 속성인 Instance를 가져와 바인딩하는 코드이다.

위의 1번 또는 2번 방법처럼 번거롭거나 단점이 발생하는 등의 문제 없이 간단히 해결되었다.

 

만일 ObjectManager 클래스에 있는 정적 속성인 MyStaticString이 바인딩되어야 한다면 이렇게 해결할 수 있다.

<Window x:Class="MyProject.Views.MainWindowView"
        ... 생략
        om:xmlns="clr-namespace:MyProject.Core"
        Title="MainWindowView">
    <Grid>
        <TextBox Text="{Binding Source={x:Static om:ObjectManager.MyStaticString}, Path=., UpdateSourceTrigger=PropertyChanged}"/>
    </Grid>
</Window>

Binding에 Source를 지정하게 되면 Path를 함께 지정해야 한다는 오류가 발생하는데 당황하지 말고 Path를 .(점)으로 처리해주자. 그러면 Source로 지정된 개체 그 자체가 Path로도 지정되게 된다.