SettingsView ver.1.1.1 新機能の紹介
ちびちびと development ブランチで大改造してたんですが、ここら辺で一区切りしようとpreとしてリリースすることにしました。 色々と変更がありますが、主な変更をご紹介します。
- CustomCell
- Section Header Footer CustomView
- TemplateStartIndex
- Section またぎの D&D
- Cell / Section の部分更新
- まとめ
CustomCell
SettingsViewのCellのコンテンツ部分をFormsViewで定義できるもので、ViewCellのようなものです。 ViewCellと異なるところは以下の通りです。
- タッチした時の反応(色の変化)がある
- 他のSettingsViewのCellと同じような余白がある
- 無しにもできる
- Command / LongCommand がある
- アイコンを設定できる
- 右端に矢印アイコンを表示できる
これにより、SettingsViewに定義されてない機能のCellを自由に定義することが可能です。 ただNativeCellに比べるとパフォーマンスは落ちると思います。 CustomCellRendererは protected virtual ばっかりにしたので、それを乗っ取れば好きなNativeCellを作れるかも知れません笑
詳細はReadMeを参照してください。
Section Header Footer CustomView
Section Header と Footer には文字列しか指定できませんでしたが、それぞれFormsViewを指定できるようになりました。 これにより、表現の幅がかなり広がったかと思います。
こちらも詳細は ReadMeを参照してください。
TemplateStartIndex
テンプレートの適用を開始するindexを指定できます。 これを使用すると例えばこういう定義の場合
<sv:Section ItemsSource="{Binding CountList}" TemplateStartIndex="1"> <sv:Section.ItemTemplate> <DataTemplate> <sv:NumberPickerCell Min="1" Max="999" Number="{Binding}" /> </DataTemplate> </sv:Section.ItemTemplate> <sv:LabelCell Title="カウント設定" /> </sv:Section>
XAMLの子要素のベタ書きとDataTemplateを混在させることができ、最初にLabelCellが表示され、TemplateStartIndexで指定された1番目からDataTemplateが適用され、ItemsSourceの要素の数だけNumberPickerCellが表示されます。
部分的にDataTemplateを適用できるので結構便利かと思います。
Section またぎの D&D
そもそも並べ替え機能とか誰も使ってないかもしれませんが笑、その並べ替え機能は今まで同一セクション内に限定されていましたが、UseDragSortがTrueになっているSectionの間で自由にD&Dで動かせるようになりました。
Cell / Section の部分更新
今まではセル1個の変更だろうが、問答無用でUITableView.ReloadData()やAdapter.NotifyDataSetChanged()を呼び出してたので、動的変更が非常に低パフォーマンスでしたが、それぞれInsertRowsなどの個別更新通知を呼び出すように変更して、そのあたりのパフォーマンスは改善されたと思います。 これに伴い、InsertやDeleteの際にセルがアニメーションするようになりました。 SectionのVisibleの切り替えの際も、個別更新するようにしたので、よりスムーズになりました。
まとめ
セルの個別変更通知の実装や、TemplateStartIndexの導入でかなり既存の部分を書き換えたので、しばらくはPreReleaseでの公開にしたいと思います。プロダクトに導入するのはおすすめできません笑
何かあればIssueで報告していただけると幸いです。
Xamarin.Forms CarouselView を試してみる
この記事は Xamarin Advent Calendar 2019 の 5日目の記事です。
CarouselViewの公式版が試験版で実装されていたので試してみました。
結果
終盤の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
- 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周りとスワイプの挙動が怪しいです。 ただ表示するだけなら現時点でも問題はなさそうです。 プラグイン版には無かった現在アイテム以外のチラ見せとかにも対応しているので、安定したら頼もしいコントロールになると思います。 期待して待ちましょう!
リポジトリ
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関連やプロファイル部分を書き換えればデバッグ実行できると思います。