Killer Way to Show a List of Items in Android Collection Widget
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, showing the list of temperature information for the whole next week. Collection widgets were introduced in Android 3.0 to provide this additional benefit. Collection widgets support ListView
, GridView
and StackView
layouts.
Today, I am going to help you understand how the collection widget works. We are going to build an app widget for a Todo app. Collection widgets will be used to display the list of pending tasks.
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.
Getting Started
Please download the starter project code here as we’ll build from it.
The code has a basic widget already implemented, we are going to create a collection widget within the same project. A basic widget shows the number of pending tasks and collection widget will show the complete list. 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.
Using RemoteViewsFactory
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.
Let’s add this class into our project. Create a new Java class, name it MyWidgetRemoteViewsFactory, and set it to implement the class RemoteViewsService.RemoteViewsFactory
.
public class MyWidgetRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
private Context mContext;
private Cursor mCursor;
public MyWidgetRemoteViewsFactory(Context applicationContext, Intent intent) {
mContext = applicationContext;
}
@Override
public void onCreate() {
}
@Override
public void onDataSetChanged() {
if (mCursor != null) {
mCursor.close();
}
final long identityToken = Binder.clearCallingIdentity();
Uri uri = Contract.PATH_TODOS_URI;
mCursor = mContext.getContentResolver().query(uri,
null,
null,
null,
Contract._ID + " DESC");
Binder.restoreCallingIdentity(identityToken);
}
@Override
public void onDestroy() {
if (mCursor != null) {
mCursor.close();
}
}
@Override
public int getCount() {
return mCursor == null ? 0 : mCursor.getCount();
}
@Override
public RemoteViews getViewAt(int position) {
if (position == AdapterView.INVALID_POSITION ||
mCursor == null || !mCursor.moveToPosition(position)) {
return null;
}
RemoteViews rv = new RemoteViews(mContext.getPackageName(), R.layout.collection_widget_list_item);
rv.setTextViewText(R.id.widgetItemTaskNameLabel, mCursor.getString(1));
return rv;
}
@Override
public RemoteViews getLoadingView() {
return null;
}
@Override
public int getViewTypeCount() {
return 1;
}
@Override
public long getItemId(int position) {
return mCursor.moveToPosition(position) ? mCursor.getLong(0) : position;
}
@Override
public boolean hasStableIds() {
return true;
}
}
In the code above, MyWidgetRemoteViewsFactory overrides a few methods from the RemoteViewsFactory
class:
onCreate
is called when the appwidget is created for the first time.onDataSetChanged
is called whenever the appwidget is updated.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 aRemoteViews
object which in our case is the single list item.getViewTypeCount
returns the number of types of views we have inListView
. In our case, we have same view types in eachListView
item so wereturn 1
there.
Using RemoteViewsService
The main purpose of RemoteViewsService
is to return a RemoteViewsFactory
object which further handles the task of filling the widget with appropriate data. There isn’t much going on in this class.
Create a new class named MyWidgetRemoteViewsService extending the class RemoteViewsService
.
public class MyWidgetRemoteViewsService extends RemoteViewsService {
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new MyWidgetRemoteViewsFactory(this.getApplicationContext(), intent);
}
}
As with all the other services in android, we must register this service in the manifest file.
<service android:name=".AppWidget.MyWidgetRemoteViewsService"
android:permission="android.permission.BIND_REMOTEVIEWS"></service>
Note the special permission android.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.
Starting the RemoteViewsService
Now that we have the additional components set up, it’s time to create the WidgetProvider
to call the RemoteViewsService
.
Create a new class in AppWidget package and name it CollectionAppWidgetProvider:
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
for (int appWidgetId : appWidgetIds) {
RemoteViews views = new RemoteViews(
context.getPackageName(),
R.layout.collection_widget
);
Intent intent = new Intent(context, MyWidgetRemoteViewsService.class);
views.setRemoteAdapter(R.id.widgetListView, intent);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
}
Creating the Widget Layout
Now create a new resource file in res/xml and name it collection_widget.xml.
In this file, we define the widget settings, such as which layout file the widget should be using, and adding a preview image for better user experience.
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:updatePeriodMillis="864000"
android:previewImage="@drawable/simple_widget_preview"
android:initialLayout="@layout/collection_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>
Create one more resource file but this time in res/layout and name it collection_widget.xml
In this file, we define the layout of what we want to show in our collection widget. We are going to have a title on top and then the ListView
in the bottom to display the list of tasks.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorWhite"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<FrameLayout android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView android:layout_width="match_parent"
android:id="@+id/widgetTitleLabel"
android:text="@string/title_collection_widget"
android:textColor="@color/colorWhite"
android:background="@color/colorPrimary"
android:textSize="18dp"
android:gravity="center"
android:textAllCaps="true"
android:layout_height="@dimen/widget_title_min_height"></TextView>
</FrameLayout>
<LinearLayout android:layout_width="match_parent"
android:layout_height="wrap_content">
<ListView android:id="@+id/widgetListView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorWhite"
android:dividerHeight="1dp"
android:divider="#eeeeee"
tools:listitem="@layout/collection_widget_list_item"></ListView>
</LinearLayout>
</LinearLayout>
We need to create one more file in res/layout to define the layout of each list item.
Create this file and name it collection_widget_list_item.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:paddingLeft="@dimen/widget_listview_padding_x"
android:paddingRight="@dimen/widget_listview_padding_x"
android:paddingStart="@dimen/widget_listview_padding_x"
android:paddingEnd="@dimen/widget_listview_padding_x"
android:minHeight="@dimen/widget_listview_item_height"
android:weightSum="2"
android:id="@+id/widgetItemContainer"
android:layout_height="wrap_content">
<TextView android:id="@+id/widgetItemTaskNameLabel"
android:layout_width="wrap_content"
android:gravity="start"
android:layout_weight="1"
android:textColor="@color/text"
android:layout_gravity="center_vertical"
android:layout_height="wrap_content"></TextView>
</LinearLayout>
Run the app now, you should be able to see the widget populated with todo items. (Make sure you re-install the app to see the changes. You can also disable the Instant Run option in Android Studio).
Updating the widget manually
The logic goes like this: whenever you create a new todo item, you have to send a Broadcast to WidgetProvider
.
Define a new method in CollectionAppWidgetProvider class.
public static void sendRefreshBroadcast(Context context) {
Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE);
intent.setComponent(new ComponentName(context, CollectionAppWidgetProvider.class));
context.sendBroadcast(intent);
}
then override the onReceive
method in the CollectionAppWidgetProvider class,
@Override
public void onReceive(final Context context, Intent intent) {
final String action = intent.getAction();
if (action.equals(AppWidgetManager.ACTION_APPWIDGET_UPDATE)) {
// refresh all your widgets
AppWidgetManager mgr = AppWidgetManager.getInstance(context);
ComponentName cn = new ComponentName(context, CollectionAppWidgetProvider.class);
mgr.notifyAppWidgetViewDataChanged(mgr.getAppWidgetIds(cn), R.id.widgetListView);
}
super.onReceive(context, intent);
}
When the new todo task is created, call the sendRefreshBroadcast
method defined in CollectionAppWidgetProvider class.
In MainActivity
, modify the addTodoItem
method accordingly.
a.runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(mContext, "New task created", Toast.LENGTH_LONG).show();
getTodoList();
// this will send the broadcast to update the appwidget
CollectionAppWidgetProvider.sendRefreshBroadcast(mContext);
}
});
Event handling in widgets
In our widget, we have a title on the top and a list view in the bottom. So when the user clicks on the title, we launch the app. When a single item is clicked in the list view, we launch the details activity. In our todo app, detail activity may not be that useful, but let’s just do it to understand the concept.
Click event on single views
Adding click events to views like TextView
, ImageView
, etc is very easy. Here is the updated code for the onUpdate
method.
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
for (int appWidgetId : appWidgetIds) {
RemoteViews views = new RemoteViews(
context.getPackageName(),
R.layout.collection_widget
);
// click event handler for the title, launches the app when the user clicks on title
Intent titleIntent = new Intent(context, MainActivity.class);
PendingIntent titlePendingIntent = PendingIntent.getActivity(context, 0, titleIntent, 0);
views.setOnClickPendingIntent(R.id.widgetTitleLabel, titlePendingIntent);
Intent intent = new Intent(context, MyWidgetRemoteViewsService.class);
views.setRemoteAdapter(R.id.widgetListView, intent);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
}
The idea here is similar to how we add click events in our apps. But as widgets run in a different context, we need to register the click event through a PendingIntent
.
Click events on ListView
items
Adding click events on ListView
items is not as simple as setting up the setOnItemClickListener
on the ListView
object. It requires some additional steps.
First you need to setup a template for PendingIntent
. Add this code in onUpdate
method in CollectionAppWidgetProvider class after views.setRemoteAdapter(R.id.widgetListView, intent);
// template to handle the click listener for each item
Intent clickIntentTemplate = new Intent(context, DetailsActivity.class);
PendingIntent clickPendingIntentTemplate = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(clickIntentTemplate)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
views.setPendingIntentTemplate(R.id.widgetListView, clickPendingIntentTemplate);
For each ListView
item we are launching DetailsActivity
which will simply display the task description sent as an extra.
Then fill this template every time a new RemoteViews
object is created by the RemoteViewsFactory
.
Add this code in getViewAt
method in MyWidgetRemoteViewsFactory class.
Intent fillInIntent = new Intent();
fillInIntent.putExtra(CollectionAppWidgetProvider.EXTRA_LABEL, mCursor.getString(1));
rv.setOnClickFillInIntent(R.id.widgetItemContainer, fillInIntent);
Here we are filling the pending intents template defined in CollectionAppWidgetProvider class. Note that we want to make the full row clickable, so we are setting the click listener on the root element of collection_widget_list_item.xml
Conclusion
In this article, I tried to help with the most common issues that beginners usually face. If you have any questions or if anything doesn’t work for you, let me know in comments below.
You can download the full working code here.