しっぽを追いかけて

ぐるぐるしながら考えています

Unity と猫の話題が中心   掲載内容は個人の私見であり、所属組織の見解ではありません

Xamarin で ItemsControl 風 AbsoluteLayout

Xamarin には ViewModel のリストを UI に表示するコントロールは ListView と TableView しか用意されてません

本家 XAML では Canvas 内に ItemsControl を配置して ViewModel の内容に応じて任意の絶対座標に UI を表示するといったことが簡単にできるのですが、ItemsControl に相当するコントロールがないわけです

というわけで下記のフォーラムで共有されたコードをもとに ItemsControl 風の AbsoluteLayout を作ってみました!

XForms needs an ItemsControl - Xamarin Forums

/// <summary>
/// ItemsControl 風 AbsoluteLayout
/// </summary>
public class AbsoluteItemsControl<T> : AbsoluteLayout
{
    /// <summary>
    /// コンストラクタ
    /// </summary>
    public AbsoluteItemsControl()
    {
    }

    #region ItemsSource

    /// <summary>
    /// ItemsSource BindableProperty
    /// </summary>
    public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create<AbsoluteItemsControl<T>, ObservableCollection<T>>(
        p => p.ItemsSource,
        new ObservableCollection<T>(),
        BindingMode.OneWay,
        null,
        OnItemsChanged);

    /// <summary>
    /// ItemsSource CLR プロパティ
    /// </summary>
    public ObservableCollection<T> ItemsSource
    {
        get { return (ObservableCollection<T>)this.GetValue(ItemsSourceProperty); }
        set { this.SetValue(ItemsSourceProperty, value); }
    }

    /// <summary>
    /// ItemsSource 変更イベントハンドラ
    /// </summary>
    /// <param name="bindable">BindableObject</param>
    /// <param name="oldValue">古い値</param>
    /// <param name="newValue">新しい値</param>
    private static void OnItemsChanged(BindableObject bindable, ObservableCollection<T> oldValue, ObservableCollection<T> newValue)
    {
        var control = bindable as AbsoluteItemsControl<T>;
        if (control == null)
        {
            return;
        }
        control.ItemsSource.CollectionChanged += control.OnCollectionChanged;
        control.Children.Clear();

        foreach (var item in newValue)
        {
            var content = control.ItemTemplate.CreateContent();
            View view;
            var cell = content as ViewCell;
            if (cell != null)
            {
                view = cell.View;
            }
            else
            {
                view = (View)content;
            }

            view.BindingContext = item;
            control.Children.Add(view);
        }

        control.UpdateChildrenLayout();
        control.InvalidateLayout();
    }

    #endregion //ItemsSource

    #region ItemTemplate

    /// <summary>
    /// ItemTemplate BindableProperty
    /// </summary>
    public static readonly BindableProperty ItemTemplateProperty = BindableProperty.Create<AbsoluteItemsControl<T>, DataTemplate>(
        p => p.ItemTemplate,
        default(DataTemplate));

    /// <summary>
    /// ItemTemplate CLR プロパティ
    /// </summary>
    public DataTemplate ItemTemplate
    {
        get { return (DataTemplate)this.GetValue(ItemTemplateProperty); }
        set { this.SetValue(ItemTemplateProperty, value); }
    }

    #endregion //ItemTemplate

    /// <summary>
    /// Items の変更イベントハンドラ
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.OldItems != null)
        {
            this.Children.RemoveAt(e.OldStartingIndex);
            this.UpdateChildrenLayout();
            this.InvalidateLayout();
        }

        if (e.NewItems == null)
        {
            return;
        }
        foreach (T item in e.NewItems)
        {
            var content = this.ItemTemplate.CreateContent();

            View view;
            var cell = content as ViewCell;
            if (cell != null)
            {
                view = cell.View;
            }
            else
            {
                view = (View)content;
            }

            view.BindingContext = item;
            this.Children.Insert(ItemsSource.IndexOf(item), view);
        }

        this.UpdateChildrenLayout();
        this.InvalidateLayout();
    }
}

AbsoluteLayout を継承し、内部に ObservableColection を持たせて BindingContext に ViewModel をバインドして表示しているだけです

?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:prismmvvm="clr-namespace:Prism.Mvvm;assembly=Prism.Mvvm.Xamarin"
             xmlns:cv="clr-namespace:XamarinUnityInjection.Converters;assembly=XamarinUnityInjection"
             xmlns:vm="clr-namespace:XamarinUnityInjection.ViewModels;assembly=XamarinUnityInjection"
             prismmvvm:ViewModelLocator.AutoWireViewModel="true"
             x:Class="XamarinUnityInjection.Views.TopPage">
  <ContentPage.Resources>
    <ResourceDictionary>
      <cv:RectangleConverter x:Key="RectangleConverter"/>
    </ResourceDictionary>
  </ContentPage.Resources>
  
      <local:AbsoluteItemsControl
        HorizontalOptions="FillAndExpand"
        VerticalOptions="FillAndExpand"
        x:TypeArguments="vm:TickItemViewModel"
        ItemsSource="{Binding TickItems}">
        <local:AbsoluteItemsControl.ItemTemplate>
          <DataTemplate>
            <BoxView Color="#7F4234"
                     AbsoluteLayout.LayoutBounds="{Binding LayoutBounds, Converter={StaticResource RectangleConverter}}"
                     AbsoluteLayout.LayoutFlags="None"
                     AnchorX="0.5"
                     AnchorY="0.5"
                     Rotation="{Binding Rotation}"/>
          </DataTemplate>
        </local:AbsoluteItemsControl.ItemTemplate>
      </local:AbsoluteItemsControl>

</ContentPage>

XAML への記述例はこんな感じ

AbsoluteItemsControl は ItemsSource と DataTemplate を内包でき、AbsoluteLayout.LayoutBounds の添付プロパティなどで座標や範囲を指定できます

RectangleConverter はカンマ区切りの数字文字列を Rectangle に変換するコンバーターです

ジェネリックの型として TypeArguments に ViewModel を指定する必要がありますが、今後改善したいところ

上記の Page の ViewModel にはこんな感じに記述します

/// <summary>
/// 最初の画面の ViewModel
/// </summary>
public class TopPageViewModel : BindableBase
{
    ~ 中略 ~

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public TopPageViewModel()
    {
        this.tickItems.Clear();
        for (var i = 0; i < 60; i++)
        {
            this.tickItems.Add(App.Container.Resolve<TickItemViewModel>());
        }

        this.width = 320;
        this.height = 480;

        this.CenterX = this.width  / 2;
        this.CenterY = this.height / 2;

        var radius = Math.Min(this.width, this.height);

        var index = 0;
        foreach (var tickItem in this.tickItems)
        {
            double size = 0.45 * radius / (index % 5 == 0 ? 15 : 30);
            double radians = index * 2 * Math.PI / this.tickItems.Count;

            tickItem.Left = this.centerX + 0.45 * radius * Math.Sin(radians) - size / 2;
            tickItem.Top = this.centerY - 0.45 * radius * Math.Cos(radians) - size / 2;
            tickItem.Width = size;
            tickItem.Height = size;
            tickItem.Rotation = 180 * radians / Math.PI;

            index++;
        }
    }
}

むにゃむにゃと座標位置を計算して ItemViewModel に設定しているだけ

これを実行すると

f:id:matatabi_ux:20141204080639p:plain

円状に矩形が並びました!