Android : comment ajouter des widgets à vos applications avec Xamarin
Les widgets sur Android sont en quelques sortes l'équivalent de vignettes dynamiques de Windows boostés au stéroïdes : épinglés sur le bureau d'accueil de l'utilisateur ils permettent d'afficher des informations et aussi d'interagir avec votre application. Dans cet article nous verrons comment en ajouter dans votre application Xamarin Platform Android.
Cela se passe en plusieurs étapes :
- Définir le rendu visuel du widget,
- Déclarer la présence d'un widget dans l'application et ses informations de base,
- Bien déterminer la taille à assigner à votre widget,
- Déclarer un fournisseur de widget pour créer la vue et la remplir,
- Optionnel : remplir une liste d'éléments avec les vraies données,
- Optionnel : gérer les clicks sur les éléments de la liste.
Déclaration de la vue à afficher dans le widget
La première étape consiste à définir le rendu visuel de votre Widget. Comme toujours sur Android, cela se passe via un fichier xml placé dans le dossier "Resources/Layout".
Pour créer cette interface, nous n'avons le droit d'utiliser qu'une partie des contrôles du SDK. La liste complète est disponible dans la documentation mais vous pouvez utiliser sans crainte ceux-ci : FrameLayout, LinearLayout, TextView, Listview, Button, ImageView, ProgressBar, etc. En effet, le SDK ne créé pas des View classiques mais des vues distantes (RemoteView) sorte de proxy sur des vues entiérement gérées par l'OS.
Dans notre cas, nous allons à titre d'exemple, afficher une liste d'épisodes prochainement parus.
Il nous faudra donc définir dans l'XML :
- Un composant image pour afficher le logo de l'application
- Un composant Text pour afficher le nom du widget
- Un composant Listview pour afficher les éléments.
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" > <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:elevation="9dp" android:layout_height="wrap_content" android:background="@color/BackgroundItem" android:layout_marginBottom="2dp" android:paddingLeft="6dp"> <ImageView android:layout_width="20dp" android:layout_height="20dp" android:src="@drawable/ic_launcher" android:layout_gravity="center_vertical"/> <TextView android:id="@+id/Widget_PanelTitle" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textColor="@color/Foreground" android:textSize="16dp" /> </LinearLayout> <ListView android:id="@+id/widget_nextEp_Listview" android:layout_width="match_parent" android:layout_height="match_parent" /> </LinearLayout>
Nous verrons plus tard que l'affichage des éléments est défini dans un autre fichier xml.
Déclaration des meta-informations de votre widget
Il faut maintenant définir le widget et ses informations utilisées par l'OS pour le proposer à l'utilisateur.
Pour cela, dans un dossier "xml" du dossier "Resources", ajoutez un fichier XML contenant une seule balise "appwidget-provider". Sur cette balise, il faut définir plusieurs attributs :
- minWidth et minHeight : les dimensions minimums de votre widget. Plus d'informaitons sur ce sujet dans la suite de l'article.
- resizeMode : les possibilités de redimensionnement du widget - horizontal et/ou vertical. Cela correspond aussi à la taille qui sera assignée par défaut à votre widget.
- updatePeriodMillis : la durée entre chaque rafraîchissement de votre widget. Le plus court cela sera, le plus à jour votre widget sera mais par contre il consommera plus de batterie. Il faut donc trouver le bon juste milieu.
- initialLayout : l'affichage initial de votre widget avant que la vue "réelle" ne soit construite. L'idée est de la laisser simple pour un affichage rapide.
- previewImage : l'image affichée à l'utilisateur dans l'interface de choix d'ajout d'un widget.
Voici un exemple de définition pour un rafraîchissement toutes les heures :
<?xml version="1.0" encoding="utf-8" ?> <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" android:minWidth="250dp" android:minHeight="40dp" android:resizeMode="horizontal|vertical" android:minResizeWidth="250dp" android:updatePeriodMillis="1800000" android:initialLayout="@layout/widget_nextepisodes" android:previewImage="@drawable/WidgetNexEpisodesPreview" />
Taille des widgets
Les widgets utilisent un système de taille par "cellule" plutôt que par pixel. Un écran de smartphone est en général large de 4 cellules par exemple. Il est donc nécessaire de définir les tailles en fonction de la taille des cellules. Attention cependant, chaque device / version de l'OS peut définir des tailles de cellules différentes même s'il existe une grille de valeur par défaut que je remets ici :
Il faut aussi noter que les OS de version supérieure ou égale à la version 4.0 ajouteront automatiquement une marge autour vos widgets. Il ne sera donc pas nécessaire de le faire vous même dans vos définitions d'UI.
Pour rappel, il reste possible de définir les fichiers de définition des widgets en fonction des version de l'OS/device en utilisant le système de choix par "nom des dossiers" comme expliqué par Xamarin. Voici un exemple pour supporter 2 version d'APIs différentes :
Déclaration d'un fournisseur de widget - AppWidgetProvider
Un fournisseur de widget consiste tout simplement à un BroadCastReceiver recevant les intents de demande de mise à jour des Widgets. Il est aussi nécessaire de définir le fichier de déclaration des métadonnées à utiliser dans le manifest.
Avec Xamarin, il nous suffira cependant d'ajouter des attributs sur une classe dérivant de AppWidgetProvider. Le label défini sur le broadcast receiver sera celui affiché par l'OS dans l'interface de choix de widget à épingler à l'utilisateur. Il est de bon goût de mettre celui-ci dans un fichier de ressources afin de bénéficier de l'internationalisation de vos textes.
[BroadcastReceiver(Label = "@string/Widget_NextEp_PanelTitle")] [IntentFilter(new string[] { "android.appwidget.action.APPWIDGET_UPDATE" })] [MetaData( "android.appwidget.provider", Resource = "@xml/nextepisodewidgetprovider")] public class NextEpisodeWidgetProvider : AppWidgetProvider { }
Dans l'attribut "Metadata", pensez à bien mettre le nom de votre fichier xml tout en minuscule même s'il y a des majuscules dont le nom du fichier sinon quoi vous ne pourrez pas compiler l'application. Visual Studio ne donnant qu'un code d'erreur peu informatif sur ce genre d'erreur...
Mise à jour de votre widget
La mise à jour de votre widget se réalise au sein de la méthode "OnUpdate" du provider de widget. Cette dernière prend en paramètres un contexte, un gestionnaire de widgets et une liste d'identifiants de widgets à rafraîchir.
public override void OnUpdate( Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { foreach (var widgetId in appWidgetIds) { // mettre à jour le widget } }
Dans cette méthode, nous allons créer une "vue distante" (une instance de la classe RemoteView) en lui indiquant le fichier de layout précédemment créé. Il restera alors à le remplir avec les valeurs finales. Dans notre cas, il faut utiliser des méthodes spécifique de la RemoteView pour assigner les différentes valeurs. Il n'est pas possible d'obtenir directement l'élément d'UI ciblé pour assigner soit même les valeurs. Finalement, il faudra demander au gestionnaire de widget de mettre à jour le widget.
// récupération de la remote view var remoteViews = new RemoteViews(context.PackageName, Resource.Layout.Widget_NextEpisodes); remoteViews.SetTextViewText(Resource.Id.Widget_PanelTitle, "titre"); remoteViews.SetTextColor(Resource.Id.Widget_PanelTitle, Color.Red); // on indique que le widget est à mettre à jour appWidgetManager.UpdateAppWidget(widgetId, remoteViews);
Remplir les éléments de la liste.
Jusqu'à présent, seuls des éléments d'UI simples sont remplis mais pas encore notre liste.
En effet il faut pour cela procéder à plusieurs étapes :
- Une fabrique de RemoteView correspondant aux différents éléments de la liste. Elle implémente l'interface IRemoteViewsFactory.
- Définir un service permettant de créer cette factory,
- Définir un Intent permettant de lancer ce service,
- Assigner cet Intent sur la Listview,
- rien que cela...
Notre classe NextEpisodeRemoteViewsFactory va dériver de IRemoteViewsFactory et permettre la création de vue correspondant à chaque élément. Dans notre cas, on retournera notamment le nombre d'éléments via la propriété Count et retourner une vue à une position donnée via la méthode GetViewAt. La définition de la structure de vos éléments doit être faite dans un fichier xml (à placer dans le dossier layout) et utiliser l'ensemble restreint de contrôles à votre disposition. Je passe volontairement le remplissage classique des images dans l'exemple ci-dessous afin d'être moins verbeux.
public RemoteViews GetViewAt(int position) { var rV = new RemoteViews(_context.PackageName, Resource.Layout.Widget_EpisodeItem); var data = _episodes[position]; rV.SetTextViewText(Resource.Id.Home_IncomingItem_Title, "titre"); var ei = "2x04 : Nous à infinite square"; rV.SetTextViewText(Resource.Id.Home_IncomingItem_EpisodeTitle, ei); return remoteView; }
Le service de création de la factory va se contenter d'instancier notre classe RemoteViewsService et de demander le chargement des données (spécifique à votre application) dans une méthode OnGetViewFactory à surcharger. Il faut que votre service dérive de la classe RemoteViewsService et demande (via un attribut) la permission "android.permission.BIND_REMOTEVIEWS" :
[Service(Permission = "android.permission.BIND_REMOTEVIEWS")] public class IncomingEpisodeWidgetService : RemoteViewsService { public override IRemoteViewsFactory OnGetViewFactory(Intent intent) { var rViewFactory = new IncomingEpisodeRemoteViewsFactory (ApplicationContext); rViewFactory.LoadDataAsync().Wait(); return rViewFactory; } }
Dernièrement, iI faut créer un Intent permettant de lancer le service et l'assigner comme Provider sur la listview de la RemoteView correspondant au Widget. Cela reste assez classique : on définit le nom du package, le type du service ciblé et on utilise la méthode 'ToUri' pour nous aider à trouver l'URI à fournir au système.
// création de l'intent pour lancer le service qui fourni la remote view factory var svcIntent = new Intent(context, typeof(IncomingEpisodeWidgetService)); svcIntent.SetPackage(context.PackageName); svcIntent.PutExtra(AppWidgetManager.ExtraAppwidgetId, widgetId); svcIntent.SetData(Android.Net.Uri.Parse( svcIntent.ToUri(IntentUriType.AndroidAppScheme))); // on set l'adapter de la listview en passant le service remoteViews.SetRemoteAdapter( Resource.Id.widget_incomingEp_Listview, svcIntent);
Et voilà, l'affichage pur est terminé et il reste uniquement à permettre de cliquer sur chaque élément.
Gérer les clics sur les éléments de la liste
La création d'un Intent à lancer lors du clic est une opération relativement coûteuse et le SDK ne permet pas de le faire tel quel. La solution proposée consiste à créer un template d'Intent (cette opération est coûteuse) et de le remplir au moment du clic avec les informations spécifiques à l'élément cliqué (cette opération est rapide).
La première étape (le template) se fait dans la création de la vue principale du Widget en utilisant la méthode SetPendingIntentTemplate sur la RemoteView. Dans notre cas, on va toujours demander de lancer l'activité correspondant à la page d'accueil :
var intent = new Intent(context, Java.Lang.Class.FromType(typeof(Views.HomeView))); var pendingIntent = PendingIntent.GetActivity(context, 0, intent, PendingIntentFlags.UpdateCurrent); remoteViews.SetPendingIntentTemplate(Resource.Id.widget_incomingEp_Listview, pendingIntent);
Finalement, sur chaque élément, nous allons définir un Intent contenant l'identifiant de l'épisode cliqué. On utilise ici la méthode SetOnClickFillInIntent lors de la création de la vue dans la méthode GetViewAt de la RemoteViewFactory :
var intent = new Intent(); intent.PutExtra("id", data.EpisodeId); remoteView.SetOnClickFillInIntent(Resource.Id.Widget_LayoutRoot, intent);
Conclusion
Comme vous pouvez le remarquer à la lecture de cet article, la mise en place des widgets n'est pas des plus évidente.
Beaucoup de code ne sert uniquement qu'à lier les différents composants et il est possible de perdre beaucoup de temps sur une simple erreur de case dans le nom d'un fichier. Il peut aussi être fastidieux de tester sur plusieurs devices/versions pour prendre en compte les différentes tailles/marges appliquées arbitrairement par l'OS ou ses surcouches.
Le développement n'est pas non plus des plus productifs car chaque test sur un device nécessite, du moins dans mon environnement de travail, un redéploiement et donc la perte des widgets précédemment épinglés, qu'il faut donc aller replacer...
Cela reste cependant un élément beaucoup demandé par les utilisateurs et permettant une expérience utilisateur très sympathique et le jeu en vaut sûrement la chandelle !
Commentaires