Tag Archives: RemoteViewsFactory

Screenshot_2015-10-23-11-52-50

How to create Calendar-like app widget with optimized images in the list

The process of app widgets creation is quite common and described in official docs very well. Google uses almost all the features from this guide in the Google Calendar that offers a few widgets with lists of scheduled events for nearest time.

Source code for this application is available in Google Git for everybody and it has the free license. But if you want to extend the functions and use your server API, you may encounter with some problems.

In this article I will try to write some code for widget with a list of images using RemoteViewsFactory. App widget will load the data about my meetings for the next week.

First of all, you should be familiar with appwidget’s Google guide.  Second, create your project and configure app widget as usual. Then let’s write some code for AppWidgetProvider:

public class AppWidgetProvider extends android.appwidget.AppWidgetProvider
{
    private static String APP_WIDGET_UPDATE = "your.package.name.MY_APPWIDGET_UPDATE";
    public static final long UPDATE_TIME_MILLIS = 15 * 60 * 1000; // 15 minutes

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int appWidgetIds[])
    {
        final int N = appWidgetIds.length;
        for (int i = 0; i < N; i++)
        {
            int appWidgetId = appWidgetIds[i];
            RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget);
            appWidgetManager.updateAppWidget(appWidgetId, views);
            updateAppWidget(context, appWidgetManager, appWidgetId);
        }
    }

    public static void updateAppWidget(Context context,
                                       AppWidgetManager appWidgetManager,
                                       int appWidgetId)
    {
        Intent updateIntent = new Intent(context, AppWidgetRefreshService.class);
        updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
        updateIntent.setData(Uri.parse(updateIntent.toUri(Intent.URI_INTENT_SCHEME)));
        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget);

        // Calendar header
        Calendar calendar = Calendar.getInstance();
        //long millis = calendar.getTimeInMillis();
        final String dayOfWeek = calendar.getDisplayName(Calendar.DAY_OF_WEEK,
                Calendar.LONG, Locale.getDefault());
        final String date = Utils.getReadableDate(calendar.getTime());
        views.setTextViewText(R.id.day_of_week, dayOfWeek);
        views.setTextViewText(R.id.date, date);
        // Attach to list of events
        views.setRemoteAdapter(R.id.events_list, updateIntent);
        appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.events_list);

        // Launch calendar app when the user taps on the header
        final Intent launchServiceIntent = new Intent(Intent.ACTION_VIEW);
        launchServiceIntent.setClass(context, MainActivity.class);
        final PendingIntent launchCalendarPendingIntent = PendingIntent.getActivity(
                context, 0 /* no requestCode */, launchServiceIntent, 0 /* no flags */);
        views.setOnClickPendingIntent(R.id.header, launchCalendarPendingIntent);

        // Each list item will call setOnClickExtra() to let the list know
        // which item
        // is selected by a user.
        final PendingIntent updateEventIntent = getLaunchPendingIntentTemplate(context);
        views.setPendingIntentTemplate(R.id.events_list, updateEventIntent);
        appWidgetManager.updateAppWidget(appWidgetId, views);
    }

    /**
     * Build a {@link PendingIntent} to launch the Calendar app. This should be used
     * in combination with {@link RemoteViews#setPendingIntentTemplate(int, PendingIntent)}.
     */
    public static PendingIntent getLaunchPendingIntentTemplate(Context context)
    {
        Intent launchIntent = new Intent();
        launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
        launchIntent.setData(Uri.parse(launchIntent.toUri(Intent.URI_INTENT_SCHEME)));
        launchIntent.setClass(context, OnItemClickActivity.class);
        return PendingIntent.getActivity(context, 0 , launchIntent,
                PendingIntent.FLAG_UPDATE_CURRENT);
    }

    private static PendingIntent createClockTickIntent(Context context)
    {
        Intent intent = new Intent(APP_WIDGET_UPDATE);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0,
                intent, PendingIntent.FLAG_UPDATE_CURRENT);
        return pendingIntent;
    }

    public static void startAppWidgetUpdater(Context context)
    {
        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        alarmManager.setRepeating(AlarmManager.RTC,
                System.currentTimeMillis(),// start with now
                UPDATE_TIME_MILLIS,
                createClockTickIntent(context));
    }

    public static boolean isUpdaterStarted(Context context)
    {
        return (PendingIntent.getBroadcast(context, 0, new Intent(APP_WIDGET_UPDATE),
                PendingIntent.FLAG_NO_CREATE) != null);
    }

    @Override
    public void onEnabled(Context context)
    {
        startAppWidgetUpdater(context);

        super.onEnabled(context);
    }

    @Override
    public void onDisabled(Context context)
    {
        AlarmManager alarmManager = (AlarmManager) context
                .getSystemService(Context.ALARM_SERVICE);
        alarmManager.cancel(createClockTickIntent(context));

        super.onDisabled(context);
    }

    @Override
    public void onReceive(Context context, Intent intent)
    {
        if (APP_WIDGET_UPDATE.equals(intent.getAction()))
        {
            // Get the widget manager and ids for this widget provider, then
            // call the shared
            // clock update method.
            ComponentName thisAppWidget = getComponentName(context);
            AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
            int ids[] = appWidgetManager.getAppWidgetIds(thisAppWidget);
            for (int appWidgetID : ids)
            {
                updateAppWidget(context, appWidgetManager, appWidgetID);
            }
        }
        else
            super.onReceive(context, intent);
    }

    /**
     * Build {@link ComponentName} describing this specific
     * {@link android.appwidget.AppWidgetProvider}
     */
    public static ComponentName getComponentName(Context context)
    {
        return new ComponentName(context, AppWidgetProvider.class);
    }
}

Android does not allow us to use widget update time less than 60 minutes. So we use AlarmManager to refresh app widget with the period we need. AlarmManager helps to save the battery and in some cases can avoid unnecessary call of expensive app widget refresh methods.

Note: always set the update frequency as less as you can!

This code creates header for widget. The header contains today’s day of week, date and starts MainActivity on click. Also we specified PendingIntent template that we will use for launching OnItemClickActivity when user touches list item.

appwidget.xml:

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:focusable="true"
    android:clickable="true"
    >

    <!-- Header -->
    <LinearLayout
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="56dip"
        android:gravity="center_vertical|start"
        android:orientation="vertical"
        android:background="@drawable/widget_header_shape">

        <TextView
            android:id="@+id/day_of_week"
            android:paddingLeft="8dip"
            android:paddingRight="8dip"
            android:paddingTop="2dip"
            android:paddingBottom="2dip"
            android:textColor="@color/appwidget_week"
            android:shadowColor="#0dffffff"
            android:shadowRadius="3"
            style="@style/WidgetDayOfWeekStyle" />

        <TextView
            android:id="@+id/date"
            android:paddingLeft="8dip"
            android:paddingRight="8dip"
            android:paddingTop="2dip"
            android:paddingBottom="2dip"
            android:textColor="@color/appwidget_month"
            style="@style/WidgetDateStyle"/>
    </LinearLayout>

    <!-- Event list -->
    <ListView
        android:id="@+id/events_list"
        android:layout_width="match_parent"
        android:layout_height="0dip"
        android:layout_weight="1"
        android:divider="@null"
        android:listSelector="@android:color/transparent"
        android:cacheColorHint="@null"
        android:background="@android:color/white"/>
</LinearLayout>

Add some code to AndroidManifest.xml:

<receiver
    android:name=".appwidget.AppWidgetProvider"
    android:label="@string/appwidget_name_large"
    android:enabled="true">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        <action android:name="your.package.name.MY_APPWIDGET_UPDATE" />
    </intent-filter>
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/appwidget_info" />
</receiver>

<service
    android:name=".appwidget.AppWidgetRefreshService"
    android:permission="android.permission.BIND_REMOTEVIEWS"
    android:exported="false"/>

<receiver android:name=".appwidget.AppWidgetRefreshService$CalendarFactory"
          android:permission="android.permission.BIND_REMOTEVIEWS">
    <intent-filter>
        <action android:name="your.package.name.MY_APPWIDGET_UPDATE" />
    </intent-filter>
</receiver>

Here we have one additional receiver for custom intent your.package.name.MY_APPWIDGET_UPDATE that we use for fire from AlarmManager.

Now all components are ready for custom RemoteViewsService that will contain a list of scheduled meetings from server.

public class AppWidgetRefreshService extends RemoteViewsService
{
    private static final String TAG = "MY_APPWIDGET_UPDATE";
    @Override
    public RemoteViewsFactory onGetViewFactory(Intent intent)
    {
        return new CalendarFactory(getApplicationContext(), intent);
    }

    public static class CalendarFactory extends BroadcastReceiver implements
            RemoteViewsService.RemoteViewsFactory
    {
        private Context mContext;
        private Resources mResources;
        private int mAppWidgetId;
        private int mStandardColor;
        private int mFadedColor;

        private static List<Event> queryResults = new ArrayList<>();

        private LruCache<String, Bitmap> bitmapLruCache;

        public CalendarFactory() {
            // This is being created as part of onReceive
        }

        protected CalendarFactory(Context context, Intent intent)
        {
            mContext = context;
            mResources = context.getResources();
            mAppWidgetId = intent.getIntExtra(
                    AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);

            mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color);
            mFadedColor = mResources.getColor(R.color.appwidget_item_declined_color);
            initializeCache();
        }

        protected void initializeCache()
        {
            final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

            // Use 1/10th of the available memory for this memory cache.
            final int cacheSize = maxMemory / 10;

            bitmapLruCache = new LruCache<String, Bitmap>(cacheSize)
            {
                @Override
                protected int sizeOf(String key, Bitmap bitmap) {
                    // The cache size will be measured in kilobytes rather than
                    // number of items.
                    return bitmap.getByteCount() / 1024;
                }
            };
        }
        @Override
        public void onCreate()
        {
            loadData();
        }

        @Override
        public void onDataSetChanged()
        {
            Log.d(TAG, "onDataSetChanged");
        }

        @Override
        public void onDestroy()
        {
        }

        @Override
        public RemoteViews getLoadingView()
        {
            RemoteViews views = new RemoteViews(mContext.getPackageName(),
                    R.layout.appwidget_loading);
            return views;
        }

        @Override
        public RemoteViews getViewAt(int position)
        {
            // we use getCount here so that it doesn't return null when empty
            if (position < 0 || position >= getCount()) return null;

            if (queryResults == null)
            {
                return new RemoteViews(mContext.getPackageName(),
                        R.layout.appwidget_loading);
            }
            if (queryResults.isEmpty())
            {
                return new RemoteViews(mContext.getPackageName(),
                        R.layout.appwidget_no_events);
            }

            final RemoteViews views;
            views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);

            final Event event = queryResults.get(position);
            if(event == null) return views;

            if(position == 0)
                if(!Utils.dateOfSameDay(event.getDate(), new Date()))
                    updateTextView(views, R.id.date,
                            View.VISIBLE, Utils.getReadableDate(event.getDate()));
                else
                    views.setViewVisibility(R.id.date, View.GONE);
            else
            {
                Event prevEvent = queryResults.get(position - 1);

                if(!Utils.dateOfSameDay(event.getDate(), prevEvent.getDate()))
                    updateTextView(views, R.id.date, View.VISIBLE,
                            Utils.getReadableDate(event.getDate()));
                else
                    views.setViewVisibility(R.id.date, View.GONE);
            }

            if (event.getState() == Event.EventState.waiting)
                views.setInt(R.id.widget_row, "setBackgroundColor",
                            mContext.getResources().getColor(event.isApprovedByMe()
                                    ? R.color.event_state_bg_yellow
                                    : R.color.event_state_bg_red));
            else
                views.setInt(R.id.widget_row, "setBackgroundResource",
                        R.drawable.agenda_item_bg_primary);

            updateTextView(views, R.id.when,
                    View.VISIBLE,
                    Utils.getReadableTime(event.getStartHour()));

            updateTextView(views, R.id.title, View.VISIBLE, event.getTitle());
            updateTextView(views, R.id.who, View.VISIBLE, event.getName());

            updateImageView(views, R.id.avatar, event.getAvatar());

            views.setInt(R.id.who, "setTextColor", mStandardColor);
            views.setInt(R.id.when, "setTextColor", mStandardColor);
            views.setInt(R.id.title, "setTextColor", mFadedColor);

            final Intent eventViewIntent = getLaunchFillInIntent(
                    mContext, event.getEventId());
            views.setOnClickFillInIntent(R.id.widget_row, eventViewIntent);
            return views;
        }

        @Override
        public int getViewTypeCount() {
            return 3;
        }

        @Override
        public int getCount()
        {
            // if there are no events, we still return 1 to represent the "no
            // events" view
            if (queryResults == null)
                return 1;

            return Math.max(1, queryResults.size());
        }

        @Override
        public long getItemId(int position)
        {
            if(queryResults == null || queryResults.isEmpty()) return 0;
            return queryResults.get(position).getEventId();//result;
        }

        @Override
        public boolean hasStableIds() {
            return true;
        }

        /**
         * Load events from server
         */
        public void loadData()
        {
            //fill the queryResults
        }

        void updateTextView(RemoteViews views, int id, int visibility, String string) {
            views.setViewVisibility(id, visibility);
            if (visibility == View.VISIBLE) {
                views.setTextViewText(id, string);
            }
        }

        void updateImageView(RemoteViews views, int resourceId, final String imageUrl)
        {
            Bitmap bitmap = bitmapLruCache.get(imageUrl);

            if (bitmap == null)
               bitmapLruCache.put(imageUrl, bitmap);
            views.setImageViewBitmap(resourceId, bitmap);
        }

        @Override
        public void onReceive(Context context, Intent intent)
        {
            mContext = context;
            loadData();
        }
    }
    /**
     * Build an {@link Intent} available to launch the Calendar app.
     * This should be used in combination with
     * If the go to time is 0, then calendar will be launched without a starting time.
     *
     */
    static Intent getLaunchFillInIntent(Context context, int id)
    {
        Intent eventViewIntent = new Intent();

        if (id != 0)
        {
            eventViewIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
                    Intent.FLAG_ACTIVITY_TASK_ON_HOME);
            eventViewIntent.setClass(context, OnItemClickActivity.class);
        }
        else
        {
            // If we do not have an event id - start MainActivity
            eventViewIntent.setClass(context, MainActivity.class);
        }

        return eventViewIntent;
    }
}

First important thing in this code is caching drawables with LruCache. This approach helps us to fix weird issue with memory leak in list with images.

Second, in getViewAt method I realize grouping by the day in quite unusual manner:

            if(position == 0)
                if(!Utils.dateOfSameDay(event.getDate(), new Date()))
                    updateTextView(views, R.id.date,
                            View.VISIBLE, Utils.getReadableDate(event.getDate()));
                else
                    views.setViewVisibility(R.id.date, View.GONE);

I think when you need just a divider between days without actual grouping them (for collapse, for example), this solution is quite quick and beautiful (but follows to some excessive TextViews on the other hand).

widget_item layout:

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@android:id/content"
    android:layout_height="wrap_content"
    android:layout_width="match_parent"
    android:minHeight="72dp"
    android:background="@drawable/bg_event_cal_widget_holo">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <TextView
            android:id="@+id/date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:maxLines="1"
            android:textSize="14sp"
            android:ellipsize="marquee"
            android:textColor="@color/appwidget_item_declined_color"
            style="?android:attr/textAppearanceMediumInverse"
            android:visibility="gone"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:paddingTop="5dp"
            android:paddingBottom="8dp"
            android:gravity="center_vertical"/>

        <LinearLayout
            android:id="@+id/widget_row"
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:paddingBottom="4dp"
            android:paddingTop="4dp"
            android:orientation="horizontal"
            android:minHeight="72dp" >

            <ImageView
                android:id="@+id/avatar"
                android:layout_marginLeft="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginRight="16dp"
                android:layout_marginEnd="16dp"
                android:layout_width="40dip"
                android:layout_height="40dip"
                android:scaleType="fitXY"
                android:layout_gravity="center_vertical"
                android:src="@drawable/ic_launcher"/>

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_vertical"
                android:layout_marginRight="16dip"
                android:orientation="vertical">

                <TextView
                    android:id="@+id/who"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:maxLines="2"
                    android:textSize="14sp"
                    android:textStyle="bold"
                    android:ellipsize="marquee"
                    android:textColor="@color/appwidget_title"
                    style="?android:attr/textAppearanceMediumInverse" />

                <TextView
                    android:id="@+id/when"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:singleLine="true"
                    android:textSize="12sp"
                    android:ellipsize="marquee"
                    android:textColor="@color/appwidget_when"
                    style="?android:attr/textAppearanceSmallInverse" />

                <TextView
                    android:id="@+id/title"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:singleLine="true"
                    android:ellipsize="marquee"
                    android:paddingBottom="2dip"
                    android:textSize="12sp"
                    android:textColor="@color/appwidget_where"
                    style="?android:attr/textAppearanceSmallInverse" />
            </LinearLayout>
        </LinearLayout>
    </LinearLayout>
</FrameLayout>

Third, I intentionally keep the code that use my Event class fields with aim of showing you how to change some TextView and ImageView properties in RemoteViews. I believe the sense of this code is clear.

The result is:

Screenshot_2015-10-23-11-52-50