kamulog

xamarin.formsのネタなど

Xamarin.Forms CarouselView を試してみる

この記事は Xamarin Advent Calendar 2019 の 5日目の記事です。

CarouselViewの公式版が試験版で実装されていたので試してみました。

docs.microsoft.com

結果

www.youtube.com

終盤のAndroidのボタンクリックの動きは想定外です笑

下準備

試験版なので AppDelegate.cs / MainActivity.cs で Forms.Initの前に以下のコードが必要です。

Xamarin.Forms.Forms.SetFlags("CarouselView_Experimental");

XAML

XAMLとしては以下な感じで使います。

<CarouselView Grid.Row="0" ItemsSource="{Binding Books}" 
        ItemSizingStrategy="MeasureFirstItem"
        HorizontalScrollBarVisibility="Never"
        PeekAreaInsets="{OnPlatform Android=150,iOS=50}"
        NumberOfSideItems="0"
        Position="{Binding Position.Value}"
        RemainingItemsThreshold="1"
        RemainingItemsThresholdReachedCommand="{Binding BindingContext.MoreCommand,Source={x:Reference me}}"
        VerticalOptions="Center" HorizontalOptions="Center" HeightRequest="350">
    <CarouselView.ItemsLayout>
        <LinearItemsLayout Orientation="Horizontal"
                            ItemSpacing="10"
                            SnapPointsType="MandatorySingle"
                            SnapPointsAlignment="Center" />
    </CarouselView.ItemsLayout>
    <CarouselView.ItemTemplate>
        <DataTemplate x:DataType="model:WebBook">
            <Frame CornerRadius="16" BorderColor="Silver" HasShadow="False" IsClippedToBounds="True">
                <Image Source="{Binding Thumbnail}" Aspect="AspectFill" Margin="-20" />
            </Frame>
        </DataTemplate>
    </CarouselView.ItemTemplate>
</CarouselView>

プロパティ

  • HorizontalScrollBarVisibility
    • スクロールバーの表示有無。Neverで非表示に。
  • PeekAreaInsets
    • 両隣でチラ見せするThickness値。これを設定することで現在のアイテムの両隣のアイテムの一部を表示させることが可能。
    • これはかなり嬉しい機能です。ただAndroidの単位が論理ピクセルじゃなくて物理ピクセルサイズな気がしてならない。
    • なのでAndroidだけ3倍の値にして誤魔化しました。
  • NumberOfSideItems
    • 隣に表示するアイテムの数。1にすると現在のアイテムの両隣に1個ずつの計3つのアイテムが並ぶことになります。
    • PeekAreaInsetsと組み合わせ可能で、組み合わせると計3つと両端に一部を表示させるといった感じになります。
  • Position
    • 現在のアイテムのIndexを参照したり設定したりします。
    • 現時点では初期位置を指定しても動かなかったり、指定位置に動かそうとしても動かなかったり微妙です。
  • RemainingItemsThreshold
    • 残り何個の段階でRemainingItemsThresholdReachedCommandまたは同様の機能のイベントを呼ぶかの閾値
    • 0だと最後が表示された段階で、1だと1つ前のアイテムが表示された段階でCommand/Eventを発火する。
  • RemainingItemsThresholdReachedCommand
    • 最後まで(最後からRemainingItemsThreshold番目まで)表示したら発火するCommand。
    • データの追加読み込みなどで使う。
  • SnapPointsType
    • スクロールした時の動き。
    • MandatorySingleで一回のスワイプで1項目分スクロールして自然にとまる感じの動きになる。
    • これが一番カルーセルっぽいと思います。
  • SnapPointsAlignment
    • スクロールが止まった時の配置
    • Centerがカルーセルとして自然な動きかと思います。

WidthRequestに関して

アイテムのWidthRequestの値はCarouselの特性上無視されるようです。 なのでアスペクト比を考慮する場合はデバイスの幅とかを取得して計算して各プロパティの値を決めていく必要があると思います。

RemainingItemsThresholdReachedCommand の注意事項

AsyncReactiveCommand などの二度押し防止機能付きなら問題ないのですが、そうでない場合は、閾値に達してからスクロールを続ける度にCommandが連続で発火してしまいます。 なので

if (isLoading)
{
    return;
}
isLoading = true;
// 読み込み処理
isLoading = false;

みたいな処理が多分必要になると思います。

ViewModel例

Prism + ReactiveProperty 使ってます。

public class MainPageViewModel:BindableBase,INavigatedAware
{
    public ObservableCollection<WebBook> Books { get; set; }
    public ReactivePropertySlim<int> Position { get; } = new ReactivePropertySlim<int>(0);
    public ReactivePropertySlim<WebBook> CurrentItem { get; } = new ReactivePropertySlim<WebBook>();
    public AsyncReactiveCommand MoreCommand { get; } = new AsyncReactiveCommand();
    public ReactiveCommand PrevCommand { get; } = new ReactiveCommand();
    public ReactiveCommand NextCommand { get; } = new ReactiveCommand();

    int _nextPage = 0;
    IWebApi _webApi;
    public MainPageViewModel(IWebApi webApi)
    {
        _webApi = webApi;

        MoreCommand.Subscribe(async _ =>
        {
            foreach(var book in await GetData())
            {
                Books.Add(book);
            }
        });

        PrevCommand.Subscribe(_ =>
        {
            if(Position.Value > 0)
            {
                Position.Value--;
            }
        });

        NextCommand.Subscribe(_ =>
        {
            if (Position.Value < Books.Count - 1)
            {
                Position.Value++;
            }
        });
    }

    public void OnNavigatedTo(INavigationParameters parameters)
    {
        _ = InitializeData();
    }

    public async Task InitializeData()
    {
        Books = new ObservableCollection<WebBook>(await GetData());
        RaisePropertyChanged(nameof(Books));
        Position.Value = 1;
    }

    public async Task<List<WebBook>> GetData()
    {
        var books = await _webApi.GetByKeyword("Xamarin", 6, _nextPage);
        if(books.Count() < 6)
        {
            _nextPage = 0;
        }
        else
        {
            _nextPage += 6;
        }

        return books.ToList();
    }
}

初期化の段階で ObservableCollection に foreach で Addしていくと、エラーになったり6件あるはずが2件しか表示されなかったりと、うまくいかなかったので、new でListを渡してインスタンスを生成するようにしてます。 MoreCommandの段階では逐次追加でも問題なかったのでforeachの方法で。

Position.Value = 1; で初期位置を2番目にしてるつもりなんですが、動きませんでした笑

GetDataでは最後まで読み込んだら0からにする処理を入れて一応無限スクロールっぽくしています。

まとめ

現時点では間違いなく試験版でした。実践投入はまだ早いかなといったところです。 特にPosition周りとスワイプの挙動が怪しいです。 ただ表示するだけなら現時点でも問題はなさそうです。 プラグイン版には無かった現在アイテム以外のチラ見せとかにも対応しているので、安定したら頼もしいコントロールになると思います。 期待して待ちましょう!

リポジトリ

github.com

Xamarin.Forms で Widget を実装する

この記事は Xamarin Advent Calendar 2019 の 4日目の記事です。

JXUGの8月の発表で Widget の実装について一瞬だけ触れたのですが、一瞬すぎたので改めてブログに書いてみます。 WidgetiOS では Today Extension、 Android では AppWidget と呼ばれているものでホームやロック画面に表示する子画面みたいなものです。 今回、改めてサンプルを作成した時に思いの外、手順が多すぎたので、細かいところは省略してざっくり紹介していきます。

※ スクショ等は VS for Mac での操作です。

サンプルアプリの仕様

  • 起動時に Google Books から 「Xamarin」をキーワードに30件取得し一覧表示する
  • WidgetGoogle Books から 4件取得してそれを表示する
  • Widget のどこかをタップでアプリ起動
  • (Androidのみ)アイコンをタップでWidgetのリストをリロードする

www.youtube.com

リポジトリ

github.com

iOS

下準備

iOS は 事前に developer.apple.com でいろいろ準備が必要です。

  • Identifier を アプリ本体とWidgetの分とそれぞれ用意する。
  • 1つのAppGroupを作り、各Identifierに同じAppGroupを適用させる。
  • 各Identifierに対応したProfileを作成する。
    • 配布まで考えた場合はさらに development と App Store の2種類が必要

f:id:kamusoft:20191203155424p:plain

f:id:kamusoft:20191203155617p:plain

Today Extension プロジェクト追加

ソリューションにプロジェクトを追加します。 Today Extension のテンプレートがあるのでそれを使います。

f:id:kamusoft:20191203155850p:plain

Info.plist 設定

  • アプリ本体とExtension側で対応バージョンを揃える
  • それぞれのバンドル識別子に用意した Identifier を設定する
  • Extension側のバンドルの表示名にWidgetのタイトルを設定する
  • Extension側で NSExtension -> NSExtensionPrincipalClass に Widgetに表示するViewControllerのクラス名を登録する。
    • 最初から用意してある StoryBoard を使う場合は不要

f:id:kamusoft:20191203160006p:plain

Entitlements.plist 設定

アプリ、Extension側、両方でAppGroupを有効にして、グループIdを指定します。 Groupの共有領域を使う場合は Keychainも有効にしておくと良いと思います。

f:id:kamusoft:20191203160133p:plain f:id:kamusoft:20191203160145p:plain

バンドル署名設定

プロビジョニング プロファイルに明示的に対応するプロファイルを設定します。 必ずEntitlements.plistも指定しておきます。

f:id:kamusoft:20191203160242p:plain f:id:kamusoft:20191203160304p:plain

Widget用のViewControllerを実装

Info.plistに指定したクラスを作っていきます。INCWidgetProvidingインターフェースを適用し実装する必要があります。

[Register("WidgetViewController")]
public class WidgetViewController:UIViewController,INCWidgetProviding
{
    IWebApi _webApi;
    UITableView _tableView;
    WidgetTableViewSource _source;
    UITapGestureRecognizer _tapGesutre;

    public override void ViewDidLoad()
    {
        base.ViewDidLoad();
        _webApi = new WebApi();

        ExtensionContext.SetWidgetLargestAvailableDisplayMode(NCWidgetDisplayMode.Expanded);

        _tableView = new UITableView(View.Frame, UITableViewStyle.Plain);
        _tableView.AllowsSelection = false;
        _tableView.SeparatorStyle = UITableViewCellSeparatorStyle.None;
        _tableView.RowHeight = 52;
        _tableView.EstimatedRowHeight = 52;

        _source = new WidgetTableViewSource();
        _tableView.Source = _source;

        _tapGesutre = new UITapGestureRecognizer((obj) =>
        {
            ExtensionContext.OpenUrl(NSUrl.FromString("jp.kamusoft.sample://"), null);
        });

        View.AddGestureRecognizer(_tapGesutre);
        View.AddSubview(_tableView);

    }

    public override void ViewDidUnload()
    {
        base.ViewDidUnload();

        View.RemoveGestureRecognizer(_tapGesutre);
        _tapGesutre.Dispose();
        _tapGesutre = null;
        _source.Dispose();
        _tableView.Dispose();
    }

    [Export("widgetActiveDisplayModeDidChange:withMaximumSize:")]
    public void WidgetActiveDisplayModeDidChange(NCWidgetDisplayMode activeDisplayMode, CoreGraphics.CGSize maxSize)
    {
        if (activeDisplayMode == NCWidgetDisplayMode.Compact)
        {
            PreferredContentSize = maxSize;
        }
        else
        {
            PreferredContentSize = _tableView.ContentSize;
        }
    }

    [Export("widgetPerformUpdateWithCompletionHandler:")]
    public async void WidgetPerformUpdate(Action<NCUpdateResult> completionHandler)
    {
        try
        {
            var books = await _webApi.GetByKeyword("Xamarin", 4, 0);

            _source.Source.Clear();

            foreach (var item in books)
            {
                _source.Source.Add(item);
            }

            _tableView.ReloadData();


            completionHandler(NCUpdateResult.NewData);
        }
        catch (Exception ex)
        {
            completionHandler(NCUpdateResult.Failed);
        }
    }
}

Register属性で Info.plistで指定したクラス名を登録する必要があります。

ViewDidLoadで表示するViewを作り、Gestureで URLスキームによってアプリを起動させたり(要Info.plist設定)、WidgetActiveDisplayModeDidChangeではWidgetの高さを広げるか縮めるかの制御をし、WidgetPerformUpdateでデータの更新を行うといった感じです。

余談ですが、iOSはアプリ本体とWidget側のプロジェクトでコードの共有ができないので、上のWebApiに関連するクラスはファイルをリンク追加することで無理やり共有させています。その際 System.Net.Http などの参照を手動追加したり、そこで使用しているライブラリをインストールしたりする必要がありましたが、とりあえず許容範囲内の面倒くささでした。 そういう訳で、もし本体プロジェクトのクラスを使いまわしたい場合は、なるべく疎結合にしておくと良いと思います。

ここまでで、実行するとWidgetが登録できるようになります。

本体とのデータ共有

今回は、Webに問い合わせるだけなので本体のデータは不要でしたが、普通はなんかしらのデータが必要になると思うので、そういう場合は AppGroup の共有領域を使います。

詳しくはドキュメントで。

Xamarin. iOS でのファイルシステムアクセス - Xamarin | Microsoft Docs

未解決

iOSでExtentionsプロジェクトを追加して Release / シミュレータ だとビルドが通らない問題がありました。 が実機だと問題ないので気にしない事にしてます。

Android

こちらを参考にしました。

How to make an App Widget with Xamarin.Android

Androidは事前準備は不要でしたが、面倒臭さはかなり強いです。

レイアウトの作成

Widgetで使うレイアウトを Resources/layout に用意します。 このサンプルでは

の2種類を用意しました。WidgetCellはListViewのItem用のレイアウトです。

AppWidget Provider の作成

Resources/xml に AppWidget Providerの定義を置きます。

<?xml version="1.0" encoding="UTF-8" ?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="288dip"
    android:minHeight="72dip"
    android:resizeMode="horizontal|vertical"
    android:initialLayout="@layout/Widget" />

initialLayoutに先ほど用意したWidgetのレイアウトを指定します。

Job Scheduler用に Permissionの追加

AndroidManifest.xmlでReceiveBootCompletedにチェックを入れます。 これはJob Schedulerを使わない場合は不要です。

AppWidgetProviderのサブクラスを作成

AppWidgetProviderのサブクラスを作成します。これがWidgetのメインのコードになります。

属性がいろいろ付いているのは、本来はManifestに記述するところを Xamarin.Androidでは属性をつけることで対応できるので、その記述です。

[BroadcastReceiver(Label = "Xamarin Widget")]
[IntentFilter(new string[] { "android.appwidget.action.APPWIDGET_UPDATE" })]
[MetaData("android.appwidget.provider", Resource = "@xml/appwidget_provider")]
public class AppWidget : AppWidgetProvider
{
    public const string ACTION_REFRESH = "jp.kamusoft.sample.widget.ACTION_REFRESH";
    public const string ACTION_SELECTED = "jp.kamusoft.sample.widget.ACTION_SELECTED";
    public const string WIDGET_BUNDLE = "widget_bundle";

    public override void OnUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds)
    {
        System.Diagnostics.Debug.WriteLine("Widget OnUpdate");
        var me = new ComponentName(context, Java.Lang.Class.FromType(typeof(AppWidget)).Name);
        appWidgetManager.UpdateAppWidget(me, BuildRemoteViews(context, appWidgetIds));

        var scheduler = context.GetSystemService(Context.JobSchedulerService) as JobScheduler;

        var jobName = new ComponentName(context, Java.Lang.Class.FromType(typeof(WidgetJobService)).Name);
        var jobInfo = new JobInfo.Builder(1, jobName);
        jobInfo.SetBackoffCriteria(5000, BackoffPolicy.Linear);
        jobInfo.SetPersisted(true);
        jobInfo.SetPeriodic(900000);
        jobInfo.SetRequiredNetworkType(NetworkType.Any);
        jobInfo.SetRequiresCharging(false);

        var bundle = new PersistableBundle();
        bundle.PutIntArray(WIDGET_BUNDLE, appWidgetIds);

        jobInfo.SetExtras(bundle);

        var job = jobInfo.Build();
        scheduler.Schedule(job);
    }

    public override void OnReceive(Context context, Intent intent)
    {
        base.OnReceive(context, intent);
        switch (intent.Action)
        {
            case ACTION_SELECTED:
                var dummyIntent = new Intent(context, typeof(MainActivity));
                dummyIntent.SetFlags(ActivityFlags.SingleTop);
                dummyIntent.AddFlags(ActivityFlags.NewTask);
                context.StartActivity(dummyIntent);
                break;
            case ACTION_REFRESH:
                var appWidgetIds = intent.GetIntArrayExtra(ACTION_REFRESH);
                if (appWidgetIds != null && appWidgetIds.Length > 0)
                {
                    AppWidgetManager.GetInstance(Application.Context).NotifyAppWidgetViewDataChanged(appWidgetIds, Resource.Id.widget_listview);
                }
                break;
        }
    }

    RemoteViews BuildRemoteViews(Context context, int[] appWidgetIds)
    {
        var widgetView = new RemoteViews(context.PackageName, Resource.Layout.Widget);

        var intent = new Intent(context, Java.Lang.Class.FromType(typeof(WidgetScheduleService)));
        widgetView.SetRemoteAdapter(Resource.Id.widget_listview, intent);

        RegisterClicks(context, appWidgetIds, widgetView);

        return widgetView;
    }

    void RegisterClicks(Context context, int[] appWidgetIds, RemoteViews widgetView)
    {
        var intent = new Intent(context, typeof(AppWidget));
        intent.SetAction(ACTION_SELECTED);

        var piBackground = PendingIntent.GetBroadcast(context, 0, intent, PendingIntentFlags.UpdateCurrent);
        widgetView.SetPendingIntentTemplate(Resource.Id.widget_listview, piBackground);

        SetRefreshPendingIntent(context, widgetView, appWidgetIds);
    }

    void SetRefreshPendingIntent(Context ctx, RemoteViews rv, int[] appWidgetIds)
    {
        var refreshIntent = new Intent(ctx, typeof(AppWidget));
        refreshIntent.SetAction(ACTION_REFRESH);
        refreshIntent.PutExtra(ACTION_REFRESH, appWidgetIds);

        PendingIntent btnClickPendingIntent = PendingIntent.GetBroadcast(
            ctx,
            0,
            refreshIntent,
            PendingIntentFlags.UpdateCurrent
        );

        rv.SetOnClickPendingIntent(Resource.Id.widget_refresh, btnClickPendingIntent);
    }
}

OnUpdateは、Widget登録時に呼び出されます。 appwidget_provider.xmlでupdateperiodmillisを設定していればその秒数ごとに呼び出されますが、このサンプルでは、タイマー的な動きは JobScheduler を使うので設定していません。 WidgetでのUI操作は直接いじれなくて、RemoteViewというのを経由しないといけないので、そのRemoteViewをここで生成したり、クリックなどのイベント登録を行います。 イベントも通常の方法は使えないので Intent を使った方法を使います。

通常の要素のクリックイベントだと SetOnClickPendingIntent を使いますが、ListViewのItemに対するクリックイベントの場合は SetPendingIntentTemplateを使って登録します。 あとここで JobScheduler を登録して起動しています。

OnReceiveでは、OnUpdateで設定したイベントのIntentを受信して、そのActionによって処理を分けています。 ここで本体アプリを起動したり、リロード処理を通知したりしています。

JobSchedulerの作成

[Service(Name = "jp.kamusoft.sample.widgetscheduler", Permission = "android.permission.BIND_JOB_SERVICE")]
public class WidgetJobService:JobService
{
    public override bool OnStartJob(JobParameters @params)
    {
        Task.Run(() =>
        {
            var ids = @params.Extras.GetIntArray(AppWidget.WIDGET_BUNDLE);
            AppWidgetManager.GetInstance(this.ApplicationContext).NotifyAppWidgetViewDataChanged(ids, Resource.Id.widget_listview);
            
            JobFinished(@params, false);
        });

        return true;
    }

    public override bool OnStopJob(JobParameters @params)
    {
        return false;
    }
}

AppWidgetクラスで渡した widgetのid を受け取ってそれを元にListViewに対して更新通知を送るようにしています。

RemoteViewsServiceとRemoteViewsFactoryの作成

RemoteViewsServiceはIRemoteViewsFactoryを返すだけで、IRemoteViewsFactoryの実装でRemoteViewを操作するアダプタ的なものを書いていきます。

[Service(Permission = "android.permission.BIND_REMOTEVIEWS", Exported = false)]
public class WidgetScheduleService:RemoteViewsService
{
    public override IRemoteViewsFactory OnGetViewFactory(Intent intent)
    {
        return new WidgetScheduleFactory(this.ApplicationContext);
    }
}

public class WidgetScheduleFactory : Java.Lang.Object, RemoteViewsService.IRemoteViewsFactory
{
    ...
    List<WebBook> _source;
    Context _context;
    IWebApi _webApi;

    public WidgetScheduleFactory(Context context)
    {
        _context = context;
        _webApi = new WebApi();
    }    

    public RemoteViews GetViewAt(int position)
    {
        if (_source.Count == 0)
        {
            return null;
        }

        var book = _source[position];

        var remoteViews = new RemoteViews(_context.PackageName, Resource.Layout.WidgetCell);

        remoteViews.SetTextViewText(Resource.Id.widgetcell_title, book.Title);

        var data = _webApi.GetThumbnail(book.Thumbnail).Result;
        var image = BitmapFactory.DecodeByteArray(data, 0, data.Length);
        remoteViews.SetImageViewBitmap(Resource.Id.widgetcell_image, image);

        var intent = new Intent(); // TODO: セルごとにアクションを変えたい場合などはこのIntendにデータをセットする
        remoteViews.SetOnClickFillInIntent(Resource.Id.widgetcell_container, intent);

        return remoteViews;
    }

    public void OnDataSetChanged()
    {
        _source = GetData();
    }

    List<WebBook> GetData()
    {
        return _webApi.GetByKeyword("Xamarin", 4, 0).Result.ToList();
    }
    ...
}

GetViewAtでは各アイテムのRemoteViewを作ってデータをセットして返しています。

OnDataSetChangedは一番最初や更新通知を受け取ったときに呼ばれるので、ここでデータを取得してListViewのSourceに設定します。 非同期メソッドをasync/awaitで使うと呼び出しが闇に消えてしまったので Result で使うようにしています。

余談ですが、ここのWebApiはiOSとは違って普通にnetstandardプロジェクトのコードを呼んでいるだけなので、問題なくコード共有できています。

まとめ

もはや Xamarin.Forms は、ほぼ関係なくなってしまいましたが、一応Formsからでもこういった特殊な機能も使えるということが伝われば良いかなと思います。 Widgetに関しては、Formsから使うのが面倒くさいというということではなく、どのプラットホームからでもどの言語からでもすべて面倒くさいので、好きな環境でやるのが一番良いと思います。

記事内ではコードをかなり省略していますので、詳細が気になる場合はGithubのソースを参照してください。 このリポジトリのバンドルId関連やプロファイル部分を書き換えればデバッグ実行できると思います。

Xamarin.Forms の TriggerAction を使って Trigger に連動したアニメーションを設定する

この記事は Xamarin Advent Calendar 2019 の 1日目の記事です。

Triggerの機能にいつのまにか EnterActions / ExitActions なるものが実装されていたので試してみました。

docs.microsoft.com

今までの Trigger でできたこと

Triggerにも種類がありますが、ここでは DataTrigger を使う例を紹介していきます。 DataTriggerを使うと、あるデータに対応してプロパティを変更させたりできるので、色を変えたり可視状態を変えたりすることができます。 ただ、シンプルに切り替えるだけなので唐突な変化になってしまいます。

例えば以下のような XAML で、ViewModelのIsActiveの値に応じてOpacityを変更するようなコードを書くと

<Grid Grid.Row="0">
    <BoxView CornerRadius="60" HeightRequest="120" WidthRequest="120" BackgroundColor="Blue" HorizontalOptions="Center" VerticalOptions="Center" Opacity="0">
        <BoxView.Triggers>
            <DataTrigger TargetType="BoxView" Binding="{Binding IsActive.Value}" Value="True">
                <Setter Property="Opacity" Value="1" />
            </DataTrigger>
        </BoxView.Triggers>
    </BoxView>
</Grid>

www.youtube.com

唐突感は否めません。

TriggerAction を使うと

動画のように Trigger を使って対象の View にアニメーションを設定することができます。

www.youtube.com

これだと唐突感はないのでより自然な切り替えの効果を付ける場合に有効です。

実装方法

TriggerAction<T> のサブクラスを作成し

public class MoveTriggerAction:TriggerAction<VisualElement>
{
    public bool IsActive { get; set; }

    protected override void Invoke(VisualElement sender)
    {
        if (IsActive)
        {
            sender.TranslationX = -sender.Width;
            sender.Opacity = 0;

            sender.TranslateTo(0, 0);
            sender.FadeTo(1);
        }
        else
        {
            sender.TranslateTo(sender.Width, 0);
            sender.FadeTo(0);
        }
    }
}

Invoke メソッドの override にアクションの実装を記述します。 プロパティを設定すると使う時にパラメータを渡せるので、ここではOnとOffを区別するためのIsActiveというプロパティを作りました。 TriggerAction<T>のTはより具体的なクラスにしても良いですが、アニメーションの設定だけであれば汎用的に使えるVisualElementにしておくのが良いと思います。

でこれを、XAML で指定します。

<ContentPage ...
    xmlns:local="clr-namespace:Sample.Triggers"
    ...>
<Grid Grid.Row="0">
    <BoxView CornerRadius="60" HeightRequest="120" WidthRequest="120" BackgroundColor="Blue" HorizontalOptions="Center" VerticalOptions="Center" Opacity="0">
        <BoxView.Triggers>
            <DataTrigger TargetType="BoxView" Binding="{Binding IsActive.Value}" Value="True">
                <DataTrigger.EnterActions>
                    <local:MoveTriggerAction IsActive="True" />
                </DataTrigger.EnterActions>
                <DataTrigger.ExitActions>
                    <local:MoveTriggerAction IsActive="False" />
                </DataTrigger.ExitActions>
            </DataTrigger>
        </BoxView.Triggers>
    </BoxView>
</Grid>
...

EnterActions のところに Trigger の条件を満たした時のアクションを、ExitActionsのところに条件を満たさなくなった時のアクションを指定します。ここには複数のアクションを指定できるようです。

まとめ

TriggerAction を使うことで、Triggerきっかけでアニメーションを設定できるようになりました。 今までTriggerだけだとフワッと表示させたり消したりすることができませんでしたが、これで結構簡単にできるようになりました。地味に便利な機能かと思います。 記事で紹介した DataTrigger 以外のどのTriggerでも使えますのでぜひ使ってみてください。

今回のサンプル

github.com