しっぽを追いかけて

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

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

Xamarin で ViewModel から画面状態の変化を検知する

本来なら MVPVM で Presenter から View のイベントハンドラを設定したいところですが、あえて MVVM で、ViewModel から検知したいと思います

本家 XAML であればきっと EventToCommand みたいな Behavior を利用して検知するんでしょうが、今回は別の方法です

/// <summary>
/// 画面状態監視サービス
/// </summary>
public class PageStateDetectService : IPageStateDetectService
{
    /// <summary>
    /// 現在の画面
    /// </summary>
    private Page currentPage = null;

    /// <summary>
    /// 現在の画面
    /// </summary>
    public Page CurrentPage
    {
        get { return this.currentPage; }

        set
        {
            if (this.currentPage != null)
            {
                this.currentPage.Appearing -= this.OnPageAppearing;
                this.currentPage.SizeChanged -= this.OnPageSizeChanged;
                this.currentPage.Disappearing -= this.OnPageDisappearing;
            }
            this.currentPage = value;

            if (this.currentPage != null)
            {
                this.currentPage.Appearing += this.OnPageAppearing;
                this.currentPage.SizeChanged += this.OnPageSizeChanged;
                this.currentPage.Disappearing += this.OnPageDisappearing;
            }
        }
    }

    #region Events

    /// <summary>
    /// PageAppearing イベント
    /// </summary>
    public event PageStateChangedEventHandler PageAppearing;

    /// <summary>
    /// PageSizeChanged イベント
    /// </summary>
    public event PageStateChangedEventHandler PageSizeChanged;

    /// <summary>
    /// PageDisappearing イベント
    /// </summary>
    public event PageStateChangedEventHandler PageDisappearing;

    /// <summary>
    /// PageStateChanged イベントハンドラ delegate
    /// </summary>
    /// <param name="sender">イベント発行者</param>
    /// <param name="e">イベント引数</param>
    public delegate void PageStateChangedEventHandler(object sender, PageStateChangedEventArgs e);

    /// <summary>
    /// 画面の Appearing イベントハンドラ
    /// </summary>
    /// <param name="sender">イベント発行者</param>
    /// <param name="e">イベント引数</param>
    private void OnPageAppearing(object sender, EventArgs e)
    {
        if (this.PageAppearing == null)
        {
            return;
        }
        this.PageAppearing(this, new PageStateChangedEventArgs(new Size(this.currentPage.Width, this.currentPage.Height)));
    }

    /// <summary>
    /// 画面の SizeChanged イベントハンドラ
    /// </summary>
    /// <param name="sender">イベント発行者</param>
    /// <param name="e">イベント引数</param>
    private void OnPageSizeChanged(object sender, EventArgs e)
    {
        if (this.PageSizeChanged == null)
        {
            return;
        }
        this.PageSizeChanged(this, new PageStateChangedEventArgs(new Size(this.currentPage.Width, this.currentPage.Height)));
    }

    /// <summary>
    /// 画面の Disappearing イベントハンドラ
    /// </summary>
    /// <param name="sender">イベント発行者</param>
    /// <param name="e">イベント引数</param>
    private void OnPageDisappearing(object sender, EventArgs e)
    {
        if (this.PageDisappearing == null)
        {
            return;
        }
        this.PageDisappearing(this, new PageStateChangedEventArgs(new Size(this.currentPage.Width, this.currentPage.Height)));
    }

    #endregion //Events
}

/// <summary>
/// 画面状態変更イベント引数
/// </summary>
public class PageStateChangedEventArgs : EventArgs
{
    /// <summary>
    /// 画面サイズ
    /// </summary>
    public Size PageSize { get; private set; }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="size">画面サイズ</param>
    public PageStateChangedEventArgs(Size size)
    {
        this.PageSize = size;
    }
}

まずは Page の Appearing や SizeChanged などのイベントを監視してイベントを発生させるサービスクラスを作ります

現在の画面として CurrentPage というプロパティを持たせてます

/// <summary>
/// 画面状態監視用添付プロパティ付与クラス
/// </summary>
public static class PageStateDetect
{
    /// <summary>
    /// Page の状態を監視するか否かを表す BindableProperty
    /// </summary>
    public static readonly BindableProperty DetectStateProperty
        = BindableProperty.Create(
        "DetectState",
        typeof(bool),
        typeof(PageStateDetect),
        false,
        BindingMode.TwoWay,
        null,
        DetectStateChanged);

    /// <summary>
    /// DetectStateProperty の変更イベントハンドラ
    /// </summary>
    /// <param name="bindable">BindableObject</param>
    /// <param name="oldValue">変更前の値</param>
    /// <param name="newValue">変更後の値</param>
    private static void DetectStateChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var view = bindable as Page;

        if (view == null)
        {
            throw new Exception("Your views isn't Page");
        }
        // Unity から画面状態監視サービスを解決して現在の Page を設定します
        App.Container.Resolve<IPageStateDetectService>().CurrentPage = view;
    }
}

お次は XAML に指定する添付プロパティを持つ static クラスを作りました

下記のように Page の XAML に記述します

<?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"

             xmlns:local="clr-namespace:XamarinUnityInjection.Views;assembly=XamarinUnityInjection"
             local:PageStateDetect.DetectState="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>

local:PageStateDetect.DetectState="true" と記述するだけで、PageStateDetect クラスに BindableObject が渡されます

そこから現在の Page を取得して PageStateDetectService.CurrentPage に設定すればいけそうです

これは Prism の ViewModelLocator と ViewModelLocationProvider の実装パターンと同じやり方です

あとは ViewModel から利用するだけ

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

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="pageStateDetectService">画面状態監視サービス(DI コンテナにより自動注入される)</param>
    public TopPageViewModel(IPageStateDetectService pageStateDetectService)
    {
        this.pageStateDetectService = pageStateDetectService;

        this.tickItems.Clear();
        for (var i = 0; i < 60; i++)
        {
            this.tickItems.Add(App.Container.Resolve<TickItemViewModel>());
        }

        this.pageStateDetectService.PageAppearing += this.OnPageAppearing;
    }

    /// <summary>
    /// 画面表示イベントハンドラ
    /// </summary>
    /// <param name="sender">イベント発行者</param>
    /// <param name="e">イベント引数</param>
    private void OnPageAppearing(object sender, PageStateChangedEventArgs e)
    {
        this.pageStateDetectService.PageAppearing -= this.OnPageAppearing;

        this.Width = e.PageSize.Width;
        this.Height = e.PageSize.Height;

        this.Draw();

        this.pageStateDetectService.PageSizeChanged += this.OnPageSizeChanged;
    }

    /// <summary>
    /// 画面サイズ変更イベントハンドラ
    /// </summary>
    /// <param name="sender">イベント発行者</param>
    /// <param name="e">イベント引数</param>
    private void OnPageSizeChanged(object sender, PageStateChangedEventArgs e)
    {
        this.Width = e.PageSize.Width;
        this.Height = e.PageSize.Height;

        this.Draw();
    }

    /// <summary>
    /// 時計盤を描画します
    /// </summary>
    public void Draw()
    {
        if (this.width < 1 || this.height < 1)
        {
            return;
        }

        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++;
        }
    }
}

Appearing や SizeChanged のタイミングで Width や Height を更新されるようにしています

サービスクラス経由で画面状態の変化を検知するので、View と疎結合です

サービスクラスもコンストラクタから依存性注入してるので疎結合にできました!