kamulog

xamarin.formsのネタなど

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関連やプロファイル部分を書き換えればデバッグ実行できると思います。