본문 바로가기

Develop/MAUI 가이드

[Xamarin] Item 이 Layout 에 따라 자동으로 배치되도록 해보자

반응형

ItemsView
ItemsView

Item 이 Layout 에 따라 자동으로 배치되도록 해보자

ItemsView<TItemsLayout> 개발 배경

ListViewCollectionView 는 Xamarin 팀에서 제공하는 Layout 만 사용해야 하는 단점이 있습니다.

저는 이를 극복해보고자 CollectionView.ItemsLayout 에 저만의 레이아웃을 추가해보려고 했지만 실패했고, Custom Layout 을 활용해서 Item 을 배치할 수 있는ItemsView<TItemsLayout>를 만들게 되었습니다.

글 순서는 프로젝트에 바로 사용하실 수 있도록 배치했습니다.

  1. 소스코드
  2. 활용방법
  3. 설명

ItemsView<TItemsLayout> 소스코드

[ContentProperty(nameof(ItemsLayout))]
public abstract class ItemsView<TItemsLayout> 
    : ContentView, IItemsElement 
    where TItemsLayout : Layout<View>, new()
{
    #region ItemsSource
    public static readonly BindableProperty ItemsSourceProperty = ItemsElement.ItemsSourceProperty;
    public void OnItemsSourceChanged(IEnumerable newItemsSource)
    {
        ItemsLayout.Children.Clear();
        if (newItemsSource != null && ItemTemplate != null)
        {
            var enumerator = newItemsSource.GetEnumerator();
            while (enumerator.MoveNext())
            {
                var itemView = ItemTemplate.CreateContent() as View;
                itemView.BindingContext = enumerator.Current;
                ItemsLayout.Children.Add(itemView);
            }

            if (newItemsSource is INotifyCollectionChanged observableColleciton)
                observableColleciton.CollectionChanged += OnItemSourceChanged;
        }
    }
    public IEnumerable ItemsSource
    {
        get => GetValue(ItemsSourceProperty) as IEnumerable;
        set => SetValue(ItemsSourceProperty, value);
    }
    #endregion

    #region ItemTemplate
    public static readonly BindableProperty ItemTemplateProperty = ItemsElement.ItemTemplateProperty;
    public void OnItemTemplateChanged(DataTemplate newDataTemplate)
    {

    }
    public DataTemplate ItemTemplate
    {
        get => GetValue(ItemTemplateProperty) as DataTemplate;
        set => SetValue(ItemTemplateProperty, value);
    }
    #endregion

    public TItemsLayout ItemsLayout { get; set; }

    public ItemsView()
    {
        Content = ItemsLayout = new TItemsLayout();
    }

    private void OnItemSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Reset)
            ItemsLayout.Children.Clear();

        // Item 제거
        if (e.OldItems != null && 0 < e.OldItems.Count)
        {
            var removedItems = ItemsLayout.Children
                .Where(item => e.OldItems.Contains(item.BindingContext))
                .Select(item => item);

            foreach (var item in removedItems.ToList())
                ItemsLayout.Children.Remove(item);
        }

        // Item 추가
        if (e.NewItems != null && 0 < e.NewItems.Count)
        {
            foreach (var newItem in e.NewItems)
            {
                var itemView = ItemTemplate.CreateContent() as View;
                itemView.BindingContext = newItem;
                ItemsLayout.Children.Add(itemView);
            }
        }
    }

}

Xaml 에서 ItemsView<TItemsLayout> 사용하는 방법

1번 레이아웃 속성 활용이 불가능한 사용방법

ItemsView<TItemsLayout> 를 단순하게 사용하면 다음과 같이 사용할 수 있습니다. 하지만 BindableProperty 를 만들 수 없기 때문에 굉장히 제한적입니다.

<local:ItemsView
    x:TypeArguments="StackLayout"
    ItemsSource="{Binding ItemsSource}">
    <local:ItemsView.ItemTemplate>
        <DataTemplate>
            <Label Text="{Binding Text}"/>
        </DataTemplate>
    </local:ItemsView.ItemTemplate>
</local:ItemsView>

2번 레이아웃 속성 활용이 가능한 사용방법

1번과 달리 ItemsView<TItemsLayout> 상속해서 특정 레이아웃으로 고정한 View 를 만들고 BindableProperty 추가해 사용하는 방법도 있습니다.

public class StackItemsView : ItemsView<StackLayout>
{
    #region Orientation
    public static readonly BindableProperty OrientationProperty
        = BindableProperty.Create(nameof(Orientation), typeof(StackOrientation), typeof(StackItemsView), StackOrientation.Vertical,
            propertyChanged: (bindable, oldValue, newValue) => (bindable as StackItemsView).ItemsLayout.Orientation = (StackOrientation)newValue);
    public StackOrientation Orientation
    {
        get => (StackOrientation)GetValue(OrientationProperty);
        set => SetValue(OrientationProperty, value);
    }
    #endregion

    #region Spacing
    public static readonly BindableProperty SpacingProperty 
        = BindableProperty.Create(nameof(Spacing), typeof(double), typeof(StackItemsView), 0.0D,
            propertyChanged: (bindable, oldValue, newValue) => (bindable as StackItemsView).ItemsLayout.Spacing = (double)newValue);
    public double Spacing
    {
        get => (double)GetValue(SpacingProperty);
        set => SetValue(SpacingProperty, value);
    }
    #endregion

    public StackItemsView()
    {
        ItemsLayout.Orientation = StackOrientation.Vertical;
        ItemsLayout.Spacing = 0.0D;
    }
}
<local:StackItemsView
    Orientation="Horizontal"
    Spacing="10"
    ItemsSource="{Binding ItemsSource}">
    <local:StackItemsView.ItemTemplate>
        <DataTemplate>
            <Label Text="{Binding Text}"/>
        </DataTemplate>
    </local:StackItemsView.ItemTemplate>
</local:StackItemsView>

ItemsView<TItemsLayout>ResponsiveLayout 과 함께 사용

ResponsiveLayoutItemsView<TItemsLayout> 를 합쳐서 만든 ResponsiveItemsView 를 사용하면 화면 크기에 따라 Layout 이 변화하면서 Item 을 재배치 해주는 유용한 View 를 만들 수 있습니다.

public class ResponsiveItemsView : ItemsView<ResponsiveLayout>, IGridSpacingElement
{
    #region RowSpacing
    public static readonly BindableProperty RowSpacingProperty = GridSpacingElement.RowSpacingProperty;
    public void OnRowSpacingChanged(double newRowSpacing)
        => ItemsLayout.RowSpacing = newRowSpacing;
    public double RowSpacing
    {
        get => (double)GetValue(RowSpacingProperty);
        set => SetValue(RowSpacingProperty, value);
    }
    #endregion

    #region ColumnSpacing
    public static readonly BindableProperty ColumnSpacingProperty = GridSpacingElement.ColumnSpacingProperty;
    public void OnColumnSpacingChanged(double newColumnSpacing)
        => ItemsLayout.ColumnSpacing = newColumnSpacing;
    public double ColumnSpacing
    {
        get => (double)GetValue(ColumnSpacingProperty);
        set => SetValue(ColumnSpacingProperty, value);
    }
    #endregion
}

Element 패턴을 적용한 GridSpacingElement

interface IGridSpacingElement
{
    double RowSpacing { get; set; }
    void OnRowSpacingChanged(double newRowSpacing);

    double ColumnSpacing { get; set; }
    void OnColumnSpacingChanged(double newColumnSpacing);
}

static class GridSpacingElement
{
    public static readonly BindableProperty RowSpacingProperty
        = BindableProperty.Create(
            propertyName: nameof(IGridSpacingElement.RowSpacing),
            returnType: typeof(double),
            declaringType: typeof(IGridSpacingElement),
            defaultValue: 0.0D,
            defaultBindingMode: BindingMode.OneWay,
            propertyChanged: OnRowSpacingChanged);
    private static void OnRowSpacingChanged(BindableObject bindable, object oldValue, object newValue)
        => (bindable as IGridSpacingElement).OnRowSpacingChanged((double)newValue);

    public static readonly BindableProperty ColumnSpacingProperty
        = BindableProperty.Create(
            propertyName: nameof(IGridSpacingElement.ColumnSpacing),
            returnType: typeof(double),
            declaringType: typeof(IGridSpacingElement),
            defaultValue: 0.0D,
            defaultBindingMode: BindingMode.OneWay,
            propertyChanged: OnColumnSpacingChanged);
    private static void OnColumnSpacingChanged(BindableObject bindable, object oldValue, object newValue)
        => (bindable as IGridSpacingElement).OnColumnSpacingChanged((double)newValue);
}

ItemsView<TItemsLayout> 설명

ItemsView<TItemsLayout> 클래스 정의

public class ItemsView<TItemsLayout> 
    : ContentView, IItemsElement 
    where TItemsLayout : Layout<View>, new()

ItemsView<TItemsLayout>ContentView 를 상속받았으며 Element 패턴을 구현한 ItemsElement static 클래스와 IItemsElement 인터페이스를 활용해서 ItemsSourcePropertyItemTemplateProperty 속성을 구현합니다.

interface IItemsElement
{
    public IEnumerable ItemsSource { get; set; }
    void OnItemsSourceChanged(IEnumerable newItemsSource);

    public DataTemplate ItemTemplate { get; set; }
    void OnItemTemplateChanged(DataTemplate newDataTemplate);
}

static class ItemsElement
{
    public static readonly BindableProperty ItemsSourceProperty
        = BindableProperty.Create(
            propertyName: nameof(IItemsElement.ItemsSource),
            returnType: typeof(IEnumerable),
            declaringType: typeof(IItemsElement),
            defaultValue: null,
            defaultBindingMode: BindingMode.OneWay,
            propertyChanged: OnItemsSourceChanged);
    private static void OnItemsSourceChanged(BindableObject bindable, object oldValue, object newValue)
        => (bindable as IItemsElement).OnItemsSourceChanged(newValue as IEnumerable);

    public static readonly BindableProperty ItemTemplateProperty
        = BindableProperty.Create(
            propertyName: nameof(IItemsElement.ItemTemplate),
            returnType: typeof(DataTemplate),
            declaringType: typeof(IItemsElement),
            defaultValue: null,
            defaultBindingMode: BindingMode.OneWay,
            propertyChanged: OnItemTemplateChanged);
    private static void OnItemTemplateChanged(BindableObject bindable, object oldValue, object newValue)
        => (bindable as IItemsElement).OnItemTemplateChanged(newValue as DataTemplate);
}

그리고 ItemsView<TItemsLayout> 에 사용할 generic 타입을 다음과 같이 정의했습니다.

<TItemsLayout> where TItemsLayout : Layout\<View\>, new()

TItemsLayout 은 Custom Layout 을 사용해야 하니 당연히 Layout<View> 을 상속하고, ItemsView<TItemsLayout> 생성자에서 TItemsLayout 을 생성하기 위해 new() 제약 조건을 지정했습니다. new() 를 지정하면 TItemsLayout 타입은 반드시 매개 변수가 없는 생성자가 있어야만 합니다.

ItemsSourceItemsTemplate 활용

ItemsSource 는 View 의 BindingContext 가 될 ViewModel 이 바인딩되고 ItemsTemplate 은 Layout 에 배치할 Item 의 DataTemplate 을 바인딩합니다. 그래서 ItemsSource 가 바인딩 되면 ItemsView<TItemsLayout>ItemsLayout 에 새로운 View 를 만들어서 추가합니다.

public void OnItemsSourceChanged(IEnumerable newItemsSource)
{
    // ItemsSource 가 달라지게 되었으니 기존 View 를 모두 제거합니다.
    ItemsLayout.Children.Clear();

    if (newItemsSource != null && ItemTemplate != null)
    {
        var enumerator = newItemsSource.GetEnumerator();
        while (enumerator.MoveNext())
        {
            // ItemTemplate 을 활용해 새로운 View 를 생성합니다.
            var itemView = ItemTemplate.CreateContent() as View;
            // 새롭게 생성된 View 에 현재 ItemSource 를 BindingContext 로 할당합니다.
            itemView.BindingContext = enumerator.Current;
            // 완성된 View 를 레이아웃에 추가합니다.
            ItemsLayout.Children.Add(itemView);
        }

        // ItemsSource 가 변하면 View 도 변화해야 하므로 CollectionChanged 이벤트를 구독합니다.
        if (newItemsSource is INotifyCollectionChanged observableColleciton)
            observableColleciton.CollectionChanged += OnItemSourceChanged;
    }
}

만약 바인딩된 ItemsSourceINotifyCollectionChanged 을 구현한 ObservableColleciton 일 경우 INotifyCollectionChanged.CollectionChanged 이벤트를 구독해서 ItemsSource 가 변경되었을 때 ItemsView<TItemsLayout> 도 변경될 수 있도록 합니다.

private void OnItemSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
{
    if (e.Action == NotifyCollectionChangedAction.Reset)
        ItemsLayout.Children.Clear();

    // Item 제거
    if (e.OldItems != null && 0 < e.OldItems.Count)
    {
        // ItemsSource 에서 제거된 Item 과 연결된 View 를 제거합니다.
        var removedItems = ItemsLayout.Children
            .Where(item => e.OldItems.Contains(item.BindingContext))
            .Select(item => item);

        foreach (var item in removedItems.ToList())
            ItemsLayout.Children.Remove(item);
    }

    if (e.NewItems != null && 0 < e.NewItems.Count)
    {
        // Item 추가
        foreach (var newItem in e.NewItems)
        {
            // ItemsSource 에 새로운 Item 이 추가되었다면 새로운 View 를 만들어서 ItemsLayout 에 추가합니다.
            var itemView = ItemTemplate.CreateContent() as View;
            itemView.BindingContext = newItem;
            ItemsLayout.Children.Add(itemView);
        }
    }
}
반응형