kamulog

xamarin.formsのネタなど

Xamarin.Formsで割と何にでもCommandをくっつけるEffect

Button以外でCommandを使いたくなる場面が多々あったので、任意のViewにCommandを追加できるEffectを作成しました。実際に色しらべでもこのEffectを使ってます。

作成したもの

github.com

サンプルはこちら

nugetにもあるよ

www.nuget.org

Install-Package AiForms.Effects

nuget経由で使う場合はiOSのAppDelegate.csの
global::Xamarin.Forms.Forms.Init(); の後に
AiForms.Effects.iOS.Effects.Init();
の記述が必要です。

概要

任意のViewに対して、タップした時にCommandが実行される効果をくっつけるEffectで、以下のViewでAndroid/iOS双方の動作確認できました。

  • Label
  • BoxView
  • Button
  • StackLayout
  • AbsoluteLayout
  • Image
  • Editor

これ以外だと片方しか動作しなかったり全く動作しなかったりです。Layout系これ以外でも多分動きます。

パラメータ

  • On
    • EffectのOn・Off(trueでEffect開始)
  • Command
    • コマンドを指定
  • CommandParemeter
    • コマンドパラメータを指定
  • EffectColor
    • 押した感の背景色(上にレイヤー被せてるだけなのでアルファ指定しないと全部隠れますw)
    • 未指定だと押した感は出さない

f:id:kamusoft:20161124015834g:plain

Layout系に適用する場合の注意事項

背景が透過してないようなView(ButtonやBoxViewなど)があるとタップが反応しない(Android)ので、配置している各パーツのInputTranceparentをtrueにする必要があります。

前提知識

Effectsの基本的な使い方はこちらで勉強しました。

ticktack.hatenablog.jp

添付プロパティを利用したEffectの作り方はこちらから

xfcomplete.net

添付プロパティと組み合わせることでXamlからも使いやすくなって良い感じです。 添付プロパティ用のクラスを作ると結局手間はCustomRendererと変わらなくなるんですがね。まぁそこは気にしたらダメです。

コード解説

PCL AddCommand.cs

これはPCLに作成したラッパークラスでここにEffectに送るパラメータを定義しています。

public static class AddCommand
{
    public static readonly BindableProperty OnProperty =
        BindableProperty.CreateAttached(
                propertyName: "On",
                returnType: typeof(bool),
                declaringType: typeof(AddCommand),
                defaultValue: false,
                propertyChanged: OnOffChanged
            );

    public static void SetOn(BindableObject view, bool value) {
        view.SetValue(OnProperty, value);
    }

    public static bool GetOn(BindableObject view) {
        return (bool)view.GetValue(OnProperty);
    }

    private static void OnOffChanged(BindableObject bindable, object oldValue, object newValue) {
        var view = bindable as View;
        if (view == null)
            return;

        if ((bool)newValue) {
            view.Effects.Add(new AddCommandRoutingEffect());
        }
        else {
            var toRemove = view.Effects.FirstOrDefault(e => e is AddCommandRoutingEffect);
            if (toRemove != null)
                view.Effects.Remove(toRemove);
        }
    }
    //〜〜略〜〜

    class AddCommandRoutingEffect : RoutingEffect
    {
        public AddCommandRoutingEffect() : base("AiForms." + nameof(AddCommand)) { }
    }
}

基本的にCreatedAttachedで必要なプロパティを追加していってるだけなんですが、1つだけEffectのOnOff切り替え用の少し特別な「On」プロパティを用意してます。このプロパティのPropertyChangedでtrueが来た時にEffect追加、falseの時にEffectを削除するようになっています。

ここでは省略してますがOnプロパティ以外に

  • Command
  • CommandParameter
  • EffectColor

の添付プロパティを定義しています。 添付プロパティの量産にはコードスニペットが必須です。

最後の方の
public AddCommandRoutingEffect() : base("AiForms." + nameof(AddCommand)) { }
というところで実際のEffectを呼び出しています。

このクラスを用意しておくことでXaml

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
        xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
        xmlns:ef="clr-namespace:AiForms.Effects;assembly=AiForms.Effects"
        x:Class="AiEffects.Sample.Views.AddCommandPage">

        <StackLayout>
            <Label Text="Label"
                ef:AddCommand.On="true"
                ef:AddCommand.EffectColor="#50FFFF00"
                ef:AddCommand.Command="{Binding EffectCommand}"
                ef:AddCommand.CommandParameter="Label" />
        </StackLayout>
</ContentPage>

こんな感じで要素のプロパティでEffectに必要な値を渡せるようになります。毎回Effect名をつけるのはかなりダルいですが、そこは致し方ないですね。多少軽減するためにクラス名にEffectとかの文字はつけないようにしています。何回も出てくる要素ならStyleを使って指定することもできます。

iOS AddCommandPlatformEffect.cs

public class AddCommandPlatformEffect : PlatformEffect
{

    private ICommand command;
    private object commandParameter;
    private UITapGestureRecognizer tapGesture;
    private UIView view;
    private UIView layer;

    protected override void OnAttached() {
        view = Control ?? Container;

        tapGesture = new UITapGestureRecognizer(async (obj) => {
            if (layer != null) {
                layer.Frame = new CGRect(0, 0, view.Bounds.Width, view.Bounds.Height);
                view.AddSubview(layer);
                view.BringSubviewToFront(layer);
                layer.Alpha = 1;
                await UIView.AnimateAsync(0.3f, () => {
                    layer.Alpha = 0;
                });
                layer.RemoveFromSuperview();
            }

            command?.Execute(commandParameter ?? Element);
        });
        //これが無いとInput系のViewでタップが反応しない
        view.UserInteractionEnabled = true;
        view.AddGestureRecognizer(tapGesture);

        UpdateCommand();
        UpdateCommandParameter();
        UpdateEffectColor();
    }

    protected override void OnDetached() {

        view.RemoveGestureRecognizer(tapGesture);
        tapGesture.Dispose();
        if (layer != null) {
            layer.Dispose();
            layer = null;
        }
    }

    protected override void OnElementPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) {
        base.OnElementPropertyChanged(e);

        if (e.PropertyName == AddCommand.CommandProperty.PropertyName) {
            UpdateCommand();
        }
        else if (e.PropertyName == AddCommand.CommandParameterProperty.PropertyName) {
            UpdateCommandParameter();
        }
        else if (e.PropertyName == AddCommand.EffectColorProperty.PropertyName) {
            UpdateEffectColor();
        }

    }

    void UpdateCommand() {
        command = AddCommand.GetCommand(Element);
    }

    void UpdateCommandParameter() {
        commandParameter = AddCommand.GetCommandParameter(Element);
    }

    void UpdateEffectColor() {

        if (layer != null) {
            layer.Dispose();
            layer = null;
        }

        var color = AddCommand.GetEffectColor(Element);
        if (color == Xamarin.Forms.Color.Default) {
            return;
        }

        layer = new UIView();
        layer.BackgroundColor = color.ToUIColor();

    }
}

TapGestureを追加して、そこでコマンドが発動するようにしてるのがほぼ全てなんですが、細かい部分を少し説明します。

OnAttachedの最初のview = Control ?? Containerの部分
これはPlatformEffectではControlにNativeのコントロールが、Containerにそれを格納するUIViewが入っているんですが、Layout系やPage系はControlがNullでContainerの方だけに値がある状態なので、それらの場合はContainerにTapGesture等を追加するようにしています。

TapGestureの中の
layer.Frame = new CGRect(0, 0, view.Bounds.Width, view.Bounds.Height);
あたりは押した感を何とか出そうと頑張ってる箇所なんで気にしないでください。

PCLとのパラメータのやりとりはOnElementPropertyChangedのところでやっています。 添付プロパティの変化もここで拾えるようになっているので各プロパティが変化した時の処理を書いてます。

Android AddCommandPlatformEffect.cs

public class AddCommandPlatformEffect : PlatformEffect
{
    private ICommand command;
    private object commandParameter;
    private Android.Views.View view;
    private FrameLayout layer;

    protected override void OnAttached() {

        view = Control ?? Container;

        UpdateCommand();
        UpdateCommandParameter();
        UpdateEffectColor();

        view.Click += OnClick;
    }

    protected override void OnDetached() {
        var renderer = Container as IVisualElementRenderer;
        if (renderer?.Element != null) {    // Disposeされているかの判定
            view.Click -= OnClick;
            view.Touch -= View_Touch;
        }
        command = null;
        commandParameter = null;
        view = null;

        if (layer != null) {
            layer.Dispose();
            layer = null;
        }
    }

    protected override void OnElementPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) {
        base.OnElementPropertyChanged(e);

        if (e.PropertyName == AddCommand.CommandProperty.PropertyName) {
            UpdateCommand();
        }
        else if (e.PropertyName == AddCommand.CommandParameterProperty.PropertyName) {
            UpdateCommandParameter();
        }
        else if (e.PropertyName == AddCommand.EffectColorProperty.PropertyName) {
            UpdateEffectColor();
        }

    }


    void OnClick(object sender, EventArgs e) {
        command?.Execute(commandParameter ?? Element);
    }

    void UpdateCommand() {
        command = AddCommand.GetCommand(Element);
    }

    void UpdateCommandParameter() {
        commandParameter = AddCommand.GetCommandParameter(Element);
    }

    void UpdateEffectColor() {

        view.Touch -= View_Touch;
        if (layer != null) {
            layer.Dispose();
            layer = null;
        }
        var color = AddCommand.GetEffectColor(Element);
        if (color == Xamarin.Forms.Color.Default) {
            return;
        }

        layer = new FrameLayout(Container.Context);
        layer.LayoutParameters = new ViewGroup.LayoutParams(-1, -1);
        layer.SetBackgroundColor(color.ToAndroid());
        view.Touch += View_Touch;
    }

    void View_Touch(object sender, Android.Views.View.TouchEventArgs e) {
        if (e.Event.Action == MotionEventActions.Down) {
            Container.AddView(layer);
            layer.Top = 0;
            layer.Left = 0;
            layer.Right = view.Width;
            layer.Bottom = view.Height;
            layer.BringToFront();
        }
        if (e.Event.Action == MotionEventActions.Up || e.Event.Action == MotionEventActions.Cancel) {
            Container.RemoveView(layer);
        }

        e.Handled = false;
    }
}

iOSとほぼ同じなので同じところの説明は省きます。

こっちで苦労したのはOnDetachedの処理です。
Androidの場合で手動削除以外でOnDetachedが呼ばれるタイミングは、ページがPopされた時やTabbedPage内の場合はタブ移動してキャッシュが破棄された時などなんですが、OnDetachedに入った時にはControlやContainerは既にDisposeされていて、そこに設定したイベントハンドラなんかを解除しようとすると「Cannot access a disposed object」の例外が発生していまいます。

まぁ本体がDisposeされているので、そのうちGCに回収されるので気にしなくても良いのかもしれませんが、手動でEffectを解除した時くらいはちゃんとイベントハンドラの購読解除をしたいなと。

手動Effect解除の場合はしっかりイベントハンドラの解除も行うために ContainerをIVisualElementRendererにキャストして、そのElementプロパティがnullかどうかで本体が生きているかどうかを判定しています。

課題

押した感が貧弱すぎるので何とかしたいがどうにもならない気も…
あとはCommandのCanExecuteもみてIsEnabledとも連動させるとかですかね。