Xamarin.Forms で Widget を実装する
この記事は Xamarin Advent Calendar 2019 の 4日目の記事です。
JXUGの8月の発表で Widget の実装について一瞬だけ触れたのですが、一瞬すぎたので改めてブログに書いてみます。 Widget は iOS では Today Extension、 Android では AppWidget と呼ばれているものでホームやロック画面に表示する子画面みたいなものです。 今回、改めてサンプルを作成した時に思いの外、手順が多すぎたので、細かいところは省略してざっくり紹介していきます。
※ スクショ等は VS for Mac での操作です。
サンプルアプリの仕様
- 起動時に Google Books から 「Xamarin」をキーワードに30件取得し一覧表示する
- Widget は Google Books から 4件取得してそれを表示する
- Widget のどこかをタップでアプリ起動
- (Androidのみ)アイコンをタップでWidgetのリストをリロードする
リポジトリ
iOS
下準備
iOS は 事前に developer.apple.com でいろいろ準備が必要です。
- Identifier を アプリ本体とWidgetの分とそれぞれ用意する。
- ワイルドカードだと動作しませんでした。
- 1つのAppGroupを作り、各Identifierに同じAppGroupを適用させる。
- 各Identifierに対応したProfileを作成する。
- 配布まで考えた場合はさらに development と App Store の2種類が必要
Today Extension プロジェクト追加
ソリューションにプロジェクトを追加します。 Today Extension のテンプレートがあるのでそれを使います。
Info.plist 設定
- アプリ本体とExtension側で対応バージョンを揃える
- それぞれのバンドル識別子に用意した Identifier を設定する
- Extension側のバンドルの表示名にWidgetのタイトルを設定する
- Extension側で NSExtension -> NSExtensionPrincipalClass に Widgetに表示するViewControllerのクラス名を登録する。
- 最初から用意してある StoryBoard を使う場合は不要
Entitlements.plist 設定
アプリ、Extension側、両方でAppGroupを有効にして、グループIdを指定します。 Groupの共有領域を使う場合は Keychainも有効にしておくと良いと思います。
バンドル署名設定
プロビジョニング プロファイルに明示的に対応するプロファイルを設定します。 必ずEntitlements.plistも指定しておきます。
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 に用意します。 このサンプルでは
- Widget.axml
- WidgetCell.axml
の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 なるものが実装されていたので試してみました。
今までの 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>
唐突感は否めません。
TriggerAction を使うと
動画のように Trigger を使って対象の View にアニメーションを設定することができます。
これだと唐突感はないのでより自然な切り替えの効果を付ける場合に有効です。
実装方法
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でも使えますのでぜひ使ってみてください。
今回のサンプル
「復習帳」 有料版の登録方法について
version 1.2.0 から 有料版 をご用意しましたので、その利用方法をご紹介します。
復習帳とは
「復習帳」は学習内容を忘れないように効率の良い復習をサポートするアプリです。
復習帳 のその他の記事はこちらです。
復習帳のマニュアルはこちらです。
有料版について
定期購読型で以下の2種類をご用意しています。
- 自動更新月額(1ヶ月)
- ¥300 / 1ヶ月
- 自動更新年額(12ヶ月)
- ¥3,000 / 12ヶ月
有料版を購入すると以下の特典があります。
- 広告削除
- 教材カテゴリ作成数無制限
- マイスケジュール作成数無制限
- スケジュール登録日数 無制限
- 一部の教材ソート順の解放
初めて購入する場合は1ヶ月間は無料でお試しできます。無料期間中に解約すれば料金はかかりません。解約しなければ自動で更新され次回から料金の請求が発生します。有料版の期限は自動的に更新されます。
また以下の利用規約とプライバシーポリシーもご確認ください。
購入方法
「その他」→「有料版の購入」をタップして「有料版の購入」ページに移動します。 「利用規約」を一度確認して戻り、同意できましたら「自動更新月額(1ヶ月)」「自動更新年額(12ヶ月)」のどちらかご希望の商品をタップします。
その後、ログインしてない場合はログイン画面に自動的に進みます。
ログインについて
ログインすると、購入した有料コンテンツの利用が可能になり、所有の各端末間(iPhone-Android問わず)で購入情報を共有できます。登録もここから行えます。SNSアカウントがあればメールアドレスは不要です。メールアドレスでの登録も可能です。
購入情報は共有できますが、データまでは共有されませんので、その場合はバックアップを使いデータを移動してください。
ユーザー登録してない場合
そのままログイン画面に進みますので、ここで新規登録が可能です。登録済みの方はそのままログインしてください。また、ログイン済みの方はこの画面はスキップされます。
iPhoneの場合は、確認が画面が出ますので「続ける」をタップします。
SNSアカウントで登録する場合
以下のアカウントをお持ちの場合は連携して登録することができます。
利用したいアカウントのボタンを押して進むと、各SNSの認証や権限確認画面が出ますので連携を許可してください。 これでSNSアカウントでの登録は完了です。
メールアドレスで登録する場合
SNSアカウントを使わず、メールアドレスで登録する場合は画面をスクロールして「今すぐ登録」ボタンをタップして新規登録ページに進んでください。
メールアドレスを入力し、「確認コードの送信」ボタンをタップすると、入力したメールアドレス宛に確認コードが届きます。 そのコードを確認コード欄に入力して、「コードの確認」ボタンをタップします。 次に登録するパスワードを入力してから「作成」ボタンをタップします。 これで完了です。
購入の確定
ログイン済みまたはログイン処理終了後、各ストアの確認画面がでてきますので図のように進めると購入が完了します。
購入の確認・解約
「有料版の購入」の「購読の確認・管理画面を開く」で iPhoneの場合はApple IDの登録確認画面、Androidの場合はPlayストアの定期購読確認画面に移動できますのでそこから確認と解約ができます。
上記確認画面は
からでも同じ確認画面に移動できます。
購入状態の復元
機種変更などで端末が変更した際は、「有料版の購入」ページの下の「購入状態を復元する」ボタンをタップすることで購入状態を復元することができます。
異なるプラットフォーム(iPhoneからAndroidやその逆)の場合は復元はできませんので、その場合はログインによって状態を引き継いでください。