Search

'Dynamic Column'에 해당되는 글 2건

  1. 2011.04.10 DataGrid and Dynamic Column -- Part2
  2. 2011.04.01 DataGrid and Dynamic Column -- Part1 (2)

DataGrid and Dynamic Column -- Part2

프로그래밍 2011.04.10 16:51 Posted by 아일레프

지난 Post, DataGrid and Dynamic Column – Part1에서 DataGrid의 Column을 컴파일 타임 이전이 아니라 Runtime에 결정할 수 있는 하나의 예시를 보였다. 이는 Type에 index Property, “[ ]” 가 존재한다는 것과 DataGridTextColumn의 Binding에 사용되는 Binding객체의 Path에 “[ ]”를 사용할 수 있기 때문에 가능한 것이었다. 하지만 이 방법은 논리와 View가 밀접하게 연관되어 MVVM과 같은 Pattern에서 기꺼이 사용하기에는 뭔가 아쉬움이 남게된다. Part2에서는 이를 해결할 수 있는 또 다른 해법을 제시한다.

TypeDescriptor 그리고 ICustomTypeDescriptor

지난 Post에 DataGrid에 사용될 Column은 DataGrid에 바인딩 되는 클래스의 Property에 연관을 가지기에 Property를 어떻게 Dynamic하게 설정할 수 있는지를 고민해야 한다고 말한 바 있는데, 이는 ICustomTypeDescriptor의 존재로 해결 될 수 있다. 

WPF의 ItemsControl와 ItemsControl을 상속받는 ListBox, ComboBox, DataGrid등의 컨트롤들은 ItemsSource에 바인딩된 IEnumerable 객체를 바로 Items에 사용하지 않고 ICollectionView라는 형태로 변환해 사용한다. 이 ICollectionView는(예를 들어 ListCollectionview)는 TypeDescriptor를 적극적으로 사용하는데, TypeDescriptor는 ICustomTypeDescriptor를 구현하는 Type에 대해 별도의 동작을 하게 된다. ICustomTypeDescriptor, Part 1 를 보면 이에 대해서 자세히 확인 할 수 있으며 그 내용의 핵심은 아래 그림으로 간단히 요약될 수 있다. 

즉 TypeDescriptor는 ICustomTypeDescriptor를 구현하는 객체에 대해서는 Reflection이 아니라 ICustomTypeDescriptor의 GetProperties라는 메소드로 Property를 얻어오게 된다는 것이다. 따라서 DataGrid에 바인딩 될 객체가 ICustomTypeDescriptor를 구현하게 하고, DataGrid의 AutoGenerateColumn을 true로 설정함으로써 별도의 Column설정 없이 원하는 화면을 얻을 수 있다. 이때 Column Header는 Property의 이름과 동일하게 된다.

 

ICustomTypeDescriptor를 구현하는 DataForBinding 객체를 제작

public interface ICustomTypeDescriptor
{
    AttributeCollection GetAttributes();
    string GetClassName();
    string GetComponentName();
    TypeConverter GetConverter();
    EventDescriptor GetDefaultEvent();
    PropertyDescriptor GetDefaultProperty();
    object GetEditor(Type editorBaseType);
    EventDescriptorCollection GetEvents();
    EventDescriptorCollection GetEvents(Attribute[] attributes);
    PropertyDescriptorCollection GetProperties();
    PropertyDescriptorCollection GetProperties(Attribute[] attributes);
    object GetPropertyOwner(PropertyDescriptor pd);
}

ICustomTypeDescriptor를 구현하기 위해선 위와 같은 여러 메소드들을 만들어야 한다는 부담감이 드는데, 여기서는 GetClassName, GetComponentName, GetPropertiess, GetPropertyOwner만을 설정해도 충분하다. 그리고 이중 GetProperties메소드가 가장 큰 역할을 하게 된다. 우리가 원하는 화면을 다시 한번 확인해보자.

위 화면을 보면 우리가 1개의 “Key” Property와 N개의 “날짜” Property을 필요로 한다는 것을 알 수 있다. 즉 GetProperties가 반환하는 PropertyDescriptorCollection내에 1개의 “Key”에 해당 되는 PropertyDescriptor와 N개의 “날짜” PropertyDescriptor가 포함되게 되면 된다. 좋다. 구현을 해보자.

public class DataForBinding : ICustomTypeDescriptor
{

    //중략..

    IEnumerable<Data> originalDatas;
    string key;

    PropertyDescriptorCollection propertyDescriptorCollection = new PropertyDescriptorCollection(null);

    public DataForBinding(IEnumerable<Data> datas, IEnumerable<DateTime> datesForDisplay)
    {
        if (datas == null)
            throw new ArgumentNullException("datas");
        if (datas.Count() == 0)
            throw new ArgumentException();
        if (datesForDisplay == null)
        {
            throw new ArgumentException("datesForDisplay");
        }

        this.originalDatas = datas;
        this.key = datas.First().Type;
        if (!datas.All(d => d.Type == key))
        {
            //하나의 Row에 대응되는 Data들은 모두 Type이 동일해야 하므로
            throw new ArgumentException();
        }

        propertyDescriptorCollection.Add(new KeyPropertyDescriptor(key));
        foreach (DateTime dateTime in datesForDisplay)
        {
            propertyDescriptorCollection.Add(new DatePropertyDescriptor(datas, dateTime));
        }
    }

    public PropertyDescriptorCollection GetProperties()
    {
        return propertyDescriptorCollection;
    }

    public string GetClassName()
    {
        return typeof(DataForBinding).Name;
    }

    public string GetComponentName()
    {
        return typeof(DataForBinding).Name;
    }

    public object GetPropertyOwner(PropertyDescriptor pd)
    {
        return this;
    }
}

위에서 Bold체로 표시된 부분을 보면, 1개의 KeyPropertyDescriptor와 N개의 DatePropertyDescriptor객체가 PropertyDescroptorCollection에 포함되게 됨을 알 수 있다. 이제 남은 일은 이 각 Property에 해당되는 KeyPropertyDescriptor와 DatePropertyDescriptor를 구현하는 일이다.


KeyPropertyDescroptor와 DatePropertyDescroptor의 구현 

KeyPropertyDescriptor와 DatePropertyDescriptor는 PropertyDescriptor라는 Abstract클래스를 상속받아 구현된다. 이를 위해 다음과 같은 메소드들을 구현해야 하는데 우리가 보여주는 DataGrid는 모두 ReadOnly로 동작하기 때문에 Set, Reset 등의 프로퍼티는 구현할 필요가 없다.

public override bool CanResetValue(object component)
public override Type ComponentType
public override object GetValue(object component)
public override bool IsReadOnly
public override Type PropertyType
public override void ResetValue(object component)
public override void SetValue(object component, object value)
public override bool ShouldSerializeValue(object component)
그럼 KeyPropertyDescriptor부터 구현해보자. 
public class KeyPropertyDescriptor : PropertyDescriptor
{
    string key;
    public KeyPropertyDescriptor(string key):base("Key", null)
    {
        this.key = key;
    }

    #region PropertyDescroptor methods
    public override bool CanResetValue(object component)
    {
        return false;
    }

    public override Type ComponentType
    {
        get { return typeof(DataForBinding); }
    }

    public override object GetValue(object component)
    {
        return key;
    }

    public override bool IsReadOnly
    {
        get { return true; }
    }

    public override Type PropertyType
    {
        get { return typeof(string); }
    }

    public override void ResetValue(object component)
    {
        throw new InvalidOperationException("이 프로퍼티는 ReadOnly로만 동작해야 한다.");
    }

    public override void SetValue(object component, object value)
    {
        throw new InvalidOperationException("이 프로퍼티는 ReadOnly로만 동작해야 한다.");
    }

    public override bool ShouldSerializeValue(object component)
    {
        return false;
    }
    #endregion
}

Key 프로퍼티의 구현은 위와 같이 간단하다. 이는 DatePropertyDescriptor도 마찬가지이다. 단, 차이가 있다면 Property이름의 제약조건으로 인해 “2009/10/2”라는 Property를 그대로 사용할 수 없다. 따라서 약간의 변환이 필요하다.

public class DatePropertyDescriptor : PropertyDescriptor
{
    private IEnumerable<Data> datas;
    private DateTime dateTime;

    public DatePropertyDescriptor(IEnumerable<Data> datas, DateTime dateTime) : 
        base(string.Format("P{0}_{1}_{2}",dateTime.Year, dateTime.Month, dateTime.Day), null)
    {
        // TODO: Complete member initialization
        this.datas = datas;
        this.dateTime = dateTime;
    }

    #region PropertyDescroptor methods
    public override bool CanResetValue(object component)
    {
        return false;
    }

    public override Type ComponentType
    {
        get { return typeof(DataForBinding); }
    }

    public override object GetValue(object component)
    {
        StringBuilder stringBuilder = new StringBuilder();
        foreach (Data data in datas.Where(d => d.DateTime.Day == dateTime.Day && d.DateTime.Month == dateTime.Month))
        {
            stringBuilder.AppendLine(string.Format("{0:00}:{1:00}", (int)data.DateTime.TimeOfDay.TotalHours, data.DateTime.TimeOfDay.Minutes)
                                        + " - " + data.Detail);
        }
        return stringBuilder.ToString();
    }
    //후략…
    #endregion
}



DataGrid ItemssSource설정, Header설정

이제 모든 준비가 끝났다. 남은 것은 서비스로 부터 얻은 데이타를 DataForBinding객체로 변환해 DataGrid의 ItemsSource로 설정하는 일이다.

public MainWindow()
{
    InitializeComponent();
    IEnumerable<Data> datas = GetData(); //Data Service로 부터 원본 Data집합을 얻어낸다.
    this.dataGrid.ItemsSource = new ArrayList(GetBindingData(datas).ToList());//바인딩을 위한 객체로 변환한다.       
}

private IEnumerable<DataForBinding> GetBindingData(IEnumerable<Data> datas)
{
    IEnumerable<IGrouping<string, Data>> groupDatas = datas.GroupBy(data => data.Type);

    foreach (IGrouping<string, Data> group in groupDatas)
    {
        yield return new DataForBinding(group, datas.GroupBy(d => d.DateTime.Date).OrderBy(s => s.Key).Select(s => s.Key));
    }
}
<DataGrid x:Name="dataGrid"  AutoGenerateColumns="True" IsReadOnly="true" />

주목해야 할 것은 별도의 Column설정이 필요하지 않으므로 이 부분이 View와 독립적으로 이루어질 수 있다는 점이다. 따라서 이를 MVVM패턴으로 변환하는 것도 간단하다.  또한 유의 해야 할 점은 new ArrayList(GetBindingData(datas).ToList());  와 같이 ArrayList로 변환해 ItemsSource를 설정해야 한다는 점이다. List나 IEnumerable객체를 바로 사용하면 DataGrid가 ICustomTypeDescriptor의 GetProperties를 이용하지 않는다.(이 부분은 미지로 남아있다.) 여하튼, 이 코드를 실행시키면 다음과 같은 화면을 얻을 수 있다.

하지만 보시다시피 Column의 Header가 P2009_10_1과 같이 표현되므로 이를 2009/10/1 로 표시하는 작업을 따로 해주어야 한다.

    <DataGrid x:Name="dataGrid"  AutoGenerateColumns="True" IsReadOnly="true" >
        <DataGrid.Resources>
            <local:GridColumnConverter x:Key="GridColumnConverter"/>
            <Style TargetType="DataGridColumnHeader">
                <Setter Property="ContentTemplate">
                    <Setter.Value>
                        <DataTemplate>
                            <TextBlock Text="{Binding Converter={StaticResource GridColumnConverter}}"/>
                        </DataTemplate>
                    </Setter.Value>
                </Setter>
            </Style>
        </DataGrid.Resources>
    </DataGrid>

위 설정을 마치면 다음과 같이 우리가 워하는 화면을 볼 수 있다.

 

이 방법의 문제점

ICustomTypeDescriptor를 사용하는 이 방법은 Part1의 방법과 같은 DataGridColumn의 작업을 별도로 요구하지 않기에 View와 논리를 분리할 수 있다는 장점이 있다. 그러나 아쉬운 것은 이를 위해 ICustomTypeDescriptor와 PropertyDescriptor를 구현하는 별도의 객체를 매번 만들어 줘야 한다는 것이다. 이를 해결할 수 있을까? .NET 4에는 ExpandoObject라는 객체가 추가됨으로 이 문제에 대한 해결의 가능성을 보여준다. 그러나 ExpandoObject만을 사용하는 것으로는 문제가 해결되지 않음을 확인했다. 몇가지 작업이 필요한 것 같은데 이 작업이 성공한다면 Part3 포스트가 나올 수 있을 것이다.

신고

DataGrid and Dynamic Column -- Part1

프로그래밍 2011.04.01 15:08 Posted by 아일레프
public class Data
{
    public string Type { get; set; }
    public DateTime DateTime { get; set; }
    public string Detail { get; set; }
}

위 Type 으로 이루어진 데이터의 집합이 다음과 같을 때,

<local:Data Type="A" DateTime="10-01-2009 19:56:01" Detail="활동 1"/>
<local:Data Type="A" DateTime="10-01-2009 21:56:01" Detail="활동 2"/>
<local:Data Type="A" DateTime="10-01-2009 23:56:01" Detail="활동 3"/>
<local:Data Type="A" DateTime="10-02-2009 19:56:01" Detail="활동 1"/>
<local:Data Type="A" DateTime="10-02-2009 21:56:01" Detail="활동 1"/>
<local:Data Type="B" DateTime="10-02-2009 14:56:01" Detail="활동 4"/>
<local:Data Type="C" DateTime="10-03-2009 18:56:01" Detail="활동 2"/>

이 7개의 Data를 아래와 같이 표시해야 한다고 해보자. "Type"이라는 Key로 grouping한 데이터를 날짜 순으로 표시하는 것이 목적이다.

이 경우 역시 DataGrid를 떠올리게된다. 그런데 DataGrid를 이용하기 위해 가장 먼저 생각해야 하는 것은 Column의 설정이다. 보통 DataGrid.Columns에 이를 설정해야 하는데 이 경우에는 쉽지 않다. Column으로 사용되게 되는 날짜 정보를 컴파일 이전 타임에 결정할 수 없기 때문이다. 당장 위 경우, {D, 2009/10/04 07:21, 활동 1} 데이터가 추가되게 되면 "2009 /10/4"라는 Column이 필요하게 된다. 따라서 우리는 Runtime에 어떻게 DataGrid의 Column을 설정할 수 있는가? 라는 질문을 던져야 하고 이에 대한 해답을 얻어야 한다. 또한 Column을 Runtime에 결정하는 것 외에 또 한가지 문제가 남는다. DataGrid의 Column은 ItemSource에 바인딩된 개개의 Item의 Property에 대응되게 되므로 바인딩된 Item객체의 Property를 어떻게 Dynamic하게 만들수 있는가? 라는 질문에 답해야 하는 것이다.

해답은 다양하게 있을 수 있다. 나는 3개의 가능성을 떠올렸고 이 가운데 2개는 동작하는 것을 확인했다.(나머지 하나는 확실하지 않다.) 이번 포스트는 첫번째 해답에 대해 다룬다.

 

DataGrid에 바인딩될 객체 만들기

이를 해결하기 위해 DataBase에서 얻어온 객체를 그대로 사용하는것이 아니라 DataGrid의 Row를 표현할 수 있는 새로운 class를 정의하기로 한다. 그런데 어떻게 Property를 Dynamic하게 만들 수 있을까? 사실 모든 Type의 Property는 컴파일 타임 이전에 결정된다. 그런데 단 하나의 Property는 Runtime에 결정될 수 있다. 바로 index Property "[ ]"가 바로 그것이다. 이를 이용하자.

DataGrid에 표현될 Column들은 "Key"와 "날짜" 요소들이므로 바인딩을 위한 클래스는 다음과 같이 설계될 수 있을 것이다.

public class DataForBinding : INotifyPropertyChanged
{
    private IEnumerable<Data> originalDatas;
    public string Key { get; private set; }
    public string this[string day] //yyyy/MM/dd Format의 String이 Input으로 들어오게 된다.
    {
        get 
        {
            DateTime dateTime;
            if(!DateTime.TryParseExact(day, "yyyy/MM/dd",null, System.Globalization.DateTimeStyles.None, out dateTime))
            {
                throw new ArgumentException();
            }
            StringBuilder stringBuilder = new StringBuilder();
            foreach (Data data in originalDatas.Where(d => d.DateTime.Day == dateTime.Day && d.DateTime.Month == dateTime.Month))
            {
                stringBuilder.AppendLine(string.Format ("{0:00}:{1:00}", (int)data.DateTime.TimeOfDay.TotalHours,data.DateTime.TimeOfDay.Minutes) 
                                            + " - " + data.Detail);
            }
            //19 : 56 – 활동 1
            //21 : 56 – 활동 2
            //23: 56 – 활동 3 과 같은 string을 반환한다.
            return stringBuilder.ToString();
        } 
    }

    public DataForBinding(IEnumerable<Data> datas)
    {
        if (datas == null)
            throw new ArgumentNullException("datas");
        if (datas.Count() == 0)
            throw new ArgumentException();

        this.originalDatas = datas;
        this.Key = datas.First().Type;
        if (!datas.All(d => d.Type == Key))
        {
            //하나의 Row에 대응되는 Data들은 모두 Type이 동일해야 하므로
            throw new ArgumentException();
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
}

이제 Service로 부터 얻은 Data집합을 DataForBinding 집합으로 변환해야 할 것이다.

IEnumerable<Data> datas = GetData(); //Data Service로 부터 원본 객체를 얻는다.
IEnumerable<DataForBinding> datasForBinding = GetBindingData(datas); //바인딩을 위한 객체로 변환한다.

이 경우 GetBindingData는 다음과 같이 구현될 수 있다.

private IEnumerable<DataForBinding> GetBindingData(IEnumerable<Data> datas)
{
    IEnumerable<IGrouping<string, Data>> groupDatas = datas.GroupBy(data => data.Type);

    foreach (IGrouping<string, Data> group in groupDatas)
    {
        yield return new DataForBinding(group);
    }
}   

DataGrid의 Column 설정하기

바인딩될 Data는 위와 같이 손쉽게 만들 수 있었다. 이제 DataGrid의 Column을 설정하는 일이 남았다. 이 경우 DataGrid의 Column객체를 다음과 같이 설정하면 된다. 이는 Binding객체가 index 프로퍼티에도 적용될 수 있기에 가능한 것이다.

private void SetDataGridColumn(IEnumerable<DataForBinding> datasForBinding, IEnumerable<Data> datas)
{
    dataGrid.ItemsSource = datasForBinding;

    //Add Key Columns
    DataGridTextColumn keyTextColumn = new DataGridTextColumn();
    keyTextColumn.Header = "Key";
    Binding binding = new Binding("Key");
    keyTextColumn.Binding = binding;
    dataGrid.Columns.Add(keyTextColumn);


    //Add Date Columns
    IEnumerable<string> columnStrings = datas.GroupBy(data => data.DateTime.Date).Select(group => group.Key.ToString("yyyy/MM/dd"));

    foreach (string columnString in columnStrings)
    {
        DataGridTextColumn dateTextColumn = new DataGridTextColumn();
        dateTextColumn.Header = columnString;
        Binding bindingForDate = new Binding("[" + columnString + "]");
        dateTextColumn.Binding = bindingForDate;
        dataGrid.Columns.Add(dateTextColumn);
    }
}

 



이 방법의 문제점

그런데 이 방법은 아쉬움이 깊게 남는다. 왜인가? View와 분리되어야 할 논리 클래스에서 DataGrid, DataGridTextColumn과 같은 UI 객체를 그대로 사용하고 있기 때문이다. 이 경우 View와 논리 클래스 사이의 의존도로 인해 View가 변경되었을 때 어쩔 수 없이 논리 클래스도 변경되어야 하는 안타까운 일이 생길 수 있다. 만약 여러분의 논리 클래스에서 UIElement객체를 직접 참조하거나 new 키워드를 통해 생성하는 코드가 있다면 부지런히 다른 방법을 탐구해보아야 한다. Part2에서는 이를 해결한 해답을 보여주기로 한다.

 

신고