App widgets with collections
In the earlier versions of Android, app widgets could only display views like TextView, ImageView, etc. But what if we want to show a list of items in our widget? For example, a collection of pictures from a gallery app, or a collection of emails/messages from a communication app. Collection widgets were introduced in Android 3.0 to provide this additional benefit. Collection widgets support ListView, GridView and StackView layouts.
In this article, we will talk about how the collection widget works with a simple AppwidgetCollectionDemo application.
Get GITHUB code from here.
I assume you already know how to make a basic app widget. If not please refer to this article and come back when you are ready to build your own collection widgets.
Implementing app widgets with collections
To implement an app widget with collections, you follow the same basic steps you would use to implement any app widget. The following sections describe the additional steps you need to perform to implement an app widget with collections.
To make a collection widget, two main components are required in addition to the basic components:
- RemoteViewsService
- RemoteViewsFactory
Let’s understand what these components do.
RemoteViewsFactory Interface
RemoteViewsFactory serves the purpose of an adapter in the widget’s context. An adapter is used to connect the collection items(for example, ListView items or GridView items) with the data set.
The two most important methods you need to implement for your RemoteViewsFactory subclass are onCreate() and getViewAt().
Let’s add this class into our project. Create a new Java class, name it ListRemoteViewsFactory, and set it to implement the class RemoteViewsService.RemoteViewsFactory.
class ListRemoteViewsFactory implements RemoteViewsFactory{ private static final int mCount = 10; private List<WidgetItem> mWidgetItems = new ArrayList<WidgetItem>(); private Context mContext; private int mAppWidgetId; public ListRemoteViewsFactory(Context context,Intent intent) { mContext = context; mAppWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); } // Initialize the data set. @Override public void onCreate() { // In onCreate() you setup any connections / cursors to your data source. Heavy lifting, // for example downloading or creating content etc, should be deferred to onDataSetChanged() // or getViewAt(). Taking more than 20 seconds in this call will result in an ANR. for (int i = 1; i <= mCount; i++) { mWidgetItems.add(new WidgetItem("Item" + i)); } // We sleep for 3 seconds here to show how the empty view appears in the interim. // The empty view is set in the ListWidgetProvider and should be a sibling of the // collection view. try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public void onDestroy() { // In onDestroy() you should tear down anything that was setup for your data source, // eg. cursors, connections, etc. mWidgetItems.clear(); } @Override public int getCount() { return mCount; } // Given the position (index) of a WidgetItem in the array, use the item's text value in // combination with the app widget item XML file to construct a RemoteViews object. @Override public RemoteViews getViewAt(int position) { // position will always range from 0 to getCount() - 1. // construct a remote views item based on our widget item xml file, and set the // text based on the position. RemoteViews rv = new RemoteViews(mContext.getPackageName(), R.layout.widget_item); rv.setTextViewText(R.id.widget_item, mWidgetItems.get(position).text); // Next, we set a fill-intent which will be used to fill-in the pending intent template // which is set on the collection view in ListWidgetProvider. Bundle extras = new Bundle(); extras.putInt(ListWidgetProvider.EXTRA_ITEM, position); Intent fillInIntent = new Intent(); fillInIntent.putExtras(extras); // Make it possible to distinguish the individual on-click // action of a given item rv.setOnClickFillInIntent(R.id.widget_item, fillInIntent); // You can do heaving lifting in here, synchronously. For example, if you need to // process an image, fetch something from the network, etc., it is ok to do it here, // synchronously. A loading view will show up in lieu of the actual contents in the // interim. try { System.out.println("Loading view " + position); Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } // Return the remote views object. return rv; } @Override public RemoteViews getLoadingView() { // You can create a custom loading view (for instance when getViewAt() is slow.) If you // return null here, you will get the default loading view. return null; } @Override public int getViewTypeCount() { return 1; } @Override public long getItemId(int position) { return position; } @Override public boolean hasStableIds() { return true; } @Override public void onDataSetChanged() { // This is triggered when you call AppWidgetManager notifyAppWidgetViewDataChanged // on the collection view corresponding to this factory. You can do heaving lifting in // here, synchronously. For example, if you need to process an image, fetch something // from the network, etc., it is ok to do it here, synchronously. The widget will remain // in its current state while work is being done here, so you don't need to worry about // locking up the widget. } }
In the code above, ListRemoteViewsFactory overrides a few methods from the RemoteViewsFactory class:
- onCreate is called by the system when creating your factory for the first time. This is where you set up any connections and/or cursors to your data source.
- onDataSetChanged is called when notifyDataSetChanged() is triggered on the remote adapter.
- getCount returns the number of records in the cursor. (In our case, the number of task items that need to be displayed in the app widget)
- getViewAt handles all the processing work. It returns a RemoteViews object which in our case is the single list item.
- getViewTypeCount returns the number of types of views we have in ListView. In our case, we have same view type for each ListView item so we return 1 there.
- onDestroy is called when the last RemoteViewsAdapter that is associated with this factory is unbound. Here you should tear down anything that was set up for your data source eg. cursors, connections, etc.
- getLoadingView allows for the use of a custom loading view which appears between the time that getViewAt(int) is called and returns.
- hasStableIds Indicates whether the item ids are stable across changes to the underlying data. It returns True if the same id always refers to the same object.
- getItemId gets the row id associated with the specified position in the list.
RemoteViewsService class
The main purpose of RemoteViewsService is to return a RemoteViewsFactory object which further handles the task of filling the widget with appropriate data.
Create a new class named ListWidgetService extending the class RemoteViewsService.
ListWidgetService.java
public class ListWidgetService extends RemoteViewsService { @Override public RemoteViewsFactory onGetViewFactory(Intent intent) { return new ListRemoteViewsFactory(getApplicationContext(),intent); } class ListRemoteViewsFactory implements RemoteViewsFactory{ private static final int mCount = 10; private List<WidgetItem> mWidgetItems = new ArrayList<WidgetItem>(); private Context mContext; private int mAppWidgetId; public ListRemoteViewsFactory(Context context,Intent intent) { mContext = context; mAppWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); } // Initialize the data set. @Override public void onCreate() { // In onCreate() you setup any connections / cursors to your data source. Heavy lifting, // for example downloading or creating content etc, should be deferred to onDataSetChanged() // or getViewAt(). Taking more than 20 seconds in this call will result in an ANR. for (int i = 1; i <= mCount; i++) { mWidgetItems.add(new WidgetItem("Item" + i)); } // We sleep for 3 seconds here to show how the empty view appears in the interim. // The empty view is set in the ListWidgetProvider and should be a sibling of the // collection view. try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public void onDestroy() { // In onDestroy() you should tear down anything that was setup for your data source, // eg. cursors, connections, etc. mWidgetItems.clear(); } @Override public int getCount() { return mCount; } // Given the position (index) of a WidgetItem in the array, use the item's text value in // combination with the app widget item XML file to construct a RemoteViews object. @Override public RemoteViews getViewAt(int position) { // position will always range from 0 to getCount() - 1. // construct a remote views item based on our widget item xml file, and set the // text based on the position. RemoteViews rv = new RemoteViews(mContext.getPackageName(), R.layout.widget_item); rv.setTextViewText(R.id.widget_item, mWidgetItems.get(position).text); // Next, we set a fill-intent which will be used to fill-in the pending intent template // which is set on the collection view in ListWidgetProvider. Bundle extras = new Bundle(); extras.putInt(ListWidgetProvider.EXTRA_ITEM, position); Intent fillInIntent = new Intent(); fillInIntent.putExtras(extras); // Make it possible to distinguish the individual on-click // action of a given item rv.setOnClickFillInIntent(R.id.widget_item, fillInIntent); // You can do heaving lifting in here, synchronously. For example, if you need to // process an image, fetch something from the network, etc., it is ok to do it here, // synchronously. A loading view will show up in lieu of the actual contents in the // interim. try { System.out.println("Loading view " + position); Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } // Return the remote views object. return rv; } @Override public RemoteViews getLoadingView() { // You can create a custom loading view (for instance when getViewAt() is slow.) If you // return null here, you will get the default loading view. return null; } @Override public int getViewTypeCount() { return 1; } @Override public long getItemId(int position) { return position; } @Override public boolean hasStableIds() { return true; } @Override public void onDataSetChanged() { // This is triggered when you call AppWidgetManager notifyAppWidgetViewDataChanged // on the collection view corresponding to this factory. You can do heaving lifting in // here, synchronously. For example, if you need to process an image, fetch something // from the network, etc., it is ok to do it here, synchronously. The widget will remain // in its current state while work is being done here, so you don't need to worry about // locking up the widget. } } }
As with all the other services in android, we must register this service in the manifest file.
Manifest for app widgets with collections
To make it possible for app widgets with collections to bind to your RemoteViewsService, you must declare the service in your manifest file with the permission BIND_REMOTEVIEWS.
<service android:name=".ListWidgetService" android:permission="android.permission.BIND_REMOTEVIEWS" android:exported="false" />
Note the special permission BIND_REMOTEVIEWS. This lets the system bind your service to create the widget views for each row and prevents other apps from accessing your widget’s data.
Layout for app widgets with collections
The main requirement for your app widget layout XML file is that it include one of the collection views: ListView, GridView, StackView, or AdapterViewFlipper. Here is the widget_layout.xml for the AppwidgetCollectionDemo sample:
widget_layout.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_margin="8dp"> <ListView android:id="@+id/list_view" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:loopViews="true" /> <TextView android:id="@+id/empty_view" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_item_background_empty" android:gravity="center" android:text="Empty" android:textColor="#ffffff" android:textSize="20sp" android:textStyle="bold" /> </RelativeLayout>
Note: Empty views must be siblings of the collection view for which the empty view represents empty state.
In addition to the layout file for your entire app widget, you must create another layout file that defines the layout for each item in the collection. The AppwidgetCollectionDemo sample only has one layout file, widget_item.xml, since all items use the same layout.
widget_item.xml
<?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/widget_item" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:background="@drawable/widget_item_background_data" android:padding="10dp" android:textColor="#ffffff" android:textStyle="bold" android:text="Item n" android:textSize="30sp"> </TextView>
AppWidgetProvider class for app widgets with collections
The major difference in your implementation for onUpdate() when creating an app widget with collections is that you must call setRemoteAdapter(). This tells the collection view where to get its data.
When you call setRemoteAdapter() method, you must pass an intent that points to your implementation of RemoteViewsService and the app widget ID that specifies the app widget to update.`
The RemoteViewsService can then return your implementation of RemoteViewsFactory, and the widget can serve up the appropriate data.
For example, here’s how the AppWidgetCollectionDemo sample implements the onUpdate() callback method to set the RemoteViewsService as the remote adapter for the app widget collection:
@Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { // There may be multiple widgets active, so update all of them // update each of the widgets with the remote adapter for (int i = 0; i < appWidgetIds.length; ++i) { // Here we setup the intent which points to the StackViewService which will // provide the views for this collection. Intent intent = new Intent(context, ListWidgetService.class); intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetIds[i]); // When intents are compared, the extras are ignored, so we need to embed the extras // into the data so that the extras will not be ignored. intent.setData(Uri.parse(intent.toUri(intent.URI_INTENT_SCHEME))); // Construct the RemoteViews object RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_layout); views.setRemoteAdapter(R.id.list_view, intent); // The empty view is displayed when the collection has no items. It should be a sibling // of the collection view. views.setEmptyView(R.id.list_view,R.id.empty_view); // This section makes it possible for items to have individualized behavior. // It does this by setting up a pending intent template. Individuals items of a collection // cannot set up their own pending intents. Instead, the collection as a whole sets // up a pending intent template, and the individual items set a fillInIntent // to create unique behavior on an item-by-item basis. Intent toastIntent = new Intent(context, StackWidgetProvider.class); // Set the action for the intent. // When the user touches a particular view, it will have the effect of // broadcasting TOAST_ACTION. toastIntent.setAction(StackWidgetProvider.TOAST_ACTION); toastIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetIds[i]); intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); PendingIntent toastPendingIntent = PendingIntent.getBroadcast(context, 0, toastIntent, PendingIntent.FLAG_UPDATE_CURRENT); views.setPendingIntentTemplate(R.id.list_view, toastPendingIntent); // Instruct the widget manager to update the widget appWidgetManager.updateAppWidget(appWidgetIds, views); } }
Click events on ListView items
The above sections show you how to bind your data to your app widget collection. But what if you want to add dynamic behavior to the individual items in your collection view?
We normally use setOnClickPendingIntent() to set an object’s click behavior—such as to cause a button to launch an Activity. But this approach is not allowed for child views in an individual collection item (to clarify, you could use setOnClickPendingIntent() to set up a global button in the Gmail app widget that launches the app, for example, but not on the individual list items).
Instead, to add click behavior to individual items in a collection, you use setOnClickFillInIntent(). This entails setting up a pending intent template for your collection view, and then setting a fill-in intent on each item in the collection via your RemoteViewsFactory.
In the AppWidgetCollectionDemo sample, if the user touches any item, the app widget displays the Toast message “Item n selected,” where n is the index (position) of the touched view. This is how it works:
- The ListWidgetProvider (an AppWidgetProvider subclass) creates a pending intent that has a custom action called TOAST_ACTION.
- When the user touches a view, the intent is fired and it broadcasts TOAST_ACTION.
- This broadcast is intercepted by the ListWidgetProvider‘s onReceive() method, and the app widget displays the Toast message for the touched view. The data for the collection items is provided by the RemoteViewsFactory, via the RemoteViewsService.
Setting up the pending intent template
The ListWidgetProvider (AppWidgetProvider subclass) sets up a pending intent. Individuals items of a collection cannot set up their own pending intents. Instead, the collection as a whole sets up a pending intent template, and the individual items set a fill-in intent to create unique behavior on an item-by-item basis.
This class also receives the broadcast that is sent when the user touches a view. It processes this event in its onReceive() method. If the intent’s action is TOAST_ACTION, the app widget displays a Toast message for the current view.
public class ListWidgetProvider extends AppWidgetProvider { public static final String TOAST_ACTION = "com.example.android.stackwidget.TOAST_ACTION"; public static final String EXTRA_ITEM = "com.example.android.stackwidget.EXTRA_ITEM"; // Called when the BroadcastReceiver receives an Intent broadcast. // Checks to see whether the intent's action is TOAST_ACTION. If it is, the app widget // displays a Toast message for the current item. @Override public void onReceive(Context context, Intent intent) { AppWidgetManager mgr = AppWidgetManager.getInstance(context); if (intent.getAction().equals(TOAST_ACTION)) { int appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); int viewIndex = intent.getIntExtra(EXTRA_ITEM, 0); Toast.makeText(context,"Item" + ++viewIndex + " selected", Toast.LENGTH_SHORT).show(); } super.onReceive(context, intent); } @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { // There may be multiple widgets active, so update all of them // update each of the widgets with the remote adapter for (int i = 0; i < appWidgetIds.length; ++i) { Intent toastIntent = new Intent(context, StackWidgetProvider.class); // Set the action for the intent. // When the user touches a particular view, it will have the effect of // broadcasting TOAST_ACTION. toastIntent.setAction(StackWidgetProvider.TOAST_ACTION); toastIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetIds[i]); intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); PendingIntent toastPendingIntent = PendingIntent.getBroadcast(context, 0, toastIntent, PendingIntent.FLAG_UPDATE_CURRENT); views.setPendingIntentTemplate(R.id.list_view, toastPendingIntent); // Instruct the widget manager to update the widget appWidgetManager.updateAppWidget(appWidgetIds, views); } } }
Setting the fill-in Intent
Your RemoteViewsFactory must set a fill-in intent on each item in the collection. This makes it possible to distinguish the individual on-click action of a given item. The fill-in intent is then combined with the PendingIntent template in order to determine the final intent that will be executed when the item is clicked.
public class ListWidgetService extends RemoteViewsService { @Override public RemoteViewsFactory onGetViewFactory(Intent intent) { return new StackRemoteViewsFactory(getApplicationContext(),intent); } class StackRemoteViewsFactory implements RemoteViewsFactory{ private static final int mCount = 10; private List<WidgetItem> mWidgetItems = new ArrayList<WidgetItem>(); private Context mContext; private int mAppWidgetId; // Given the position (index) of a WidgetItem in the array, use the item's text value in // combination with the app widget item XML file to construct a RemoteViews object. @Override public RemoteViews getViewAt(int position) { // position will always range from 0 to getCount() - 1. // construct a remote views item based on our widget item xml file, and set the // text based on the position. RemoteViews rv = new RemoteViews(mContext.getPackageName(), R.layout.widget_item); rv.setTextViewText(R.id.widget_item, mWidgetItems.get(position).text); // Next, we set a fill-intent which will be used to fill-in the pending intent template // which is set on the collection view in StackWidgetProvider. Bundle extras = new Bundle(); extras.putInt(StackWidgetProvider.EXTRA_ITEM, position); Intent fillInIntent = new Intent(); fillInIntent.putExtras(extras); // Make it possible to distinguish the individual on-click // action of a given item rv.setOnClickFillInIntent(R.id.widget_item, fillInIntent); // Return the remote views object. return rv; } }
When you run the AppwidgetCollectionDemo application it will look something like this:
I hope this article will help you in understanding how to work with collection widgets.