Killer Way to Show a List of Items in Android Collection Widget

    Gagandeep Singh
    Share

    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 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 types in each ListView item so we return 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.

    CSS Master, 3rd Edition