Sunday, April 27, 2014

Notifications Part 2: Expanded Notifications with Controls for Jelly Bean+

Since Android Jelly Bean, notifications have had the ability to be expanded into a larger view with a custom layout. One of the most common uses for this comes from media applications, such as Pandora and YouTube, that allow the user to interact with buttons in the custom layout in order to control their media services without being in the app. Given the usefulness of this technique, I have put together a demo service that creates a notification with a custom layout containing buttons, and added listeners for those buttons in order to call functions within the service. As with all of my other posts, the demo project can be found on my GitHub account here.

The entry point for this demo (MainActivity) uses a simple layout containing a single button that creates an intent with an action to start the background service that will be doing all of the work in our example.

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView( R.layout.activity_main );

    mLaunchNotificationButton = (Button) findViewById( R.id.launch_notification );
    mLaunchNotificationButton.setOnClickListener( new View.OnClickListener() {
        @Override
        public void onClick( View view ) {
            Intent intent = new Intent( getApplicationContext(), CustomNotificationService.class );
            intent.setAction( CustomNotificationService.ACTION_NOTIFICATION_PLAY_PAUSE );
            startService( intent );
        }
    });
}

Once the intent for the service is fired, onStartCommand is called in the service, which is where I pass the intent to a function that handles filtering out the action and calling the appropriate methods.

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    handleIntent( intent );
    return super.onStartCommand(intent, flags, startId);
}

private void handleIntent( Intent intent ) {
    if( intent != null && intent.getAction() != null )
    {
        if( intent.getAction().equalsIgnoreCase( ACTION_NOTIFICATION_PLAY_PAUSE ) )
        {
            mIsPlaying = !mIsPlaying;
            showNotification(mIsPlaying);
        } else if( intent.getAction().equalsIgnoreCase( ACTION_NOTIFICATION_FAST_FORWARD ) )
        {
             //fast forward function
        } else if( intent.getAction().equalsIgnoreCase( ACTION_NOTIFICATION_REWIND ) )
        {
             //rewind action
        }
    }
}

If the action is to play or pause the service, then the service would perform these actions and then display a new notification with the updated UI from the showNotification function.

private void showNotification( boolean isPlaying ) {
        Notification notification = new NotificationCompat.Builder( getApplicationContext() )
                .setAutoCancel( true )
                .setSmallIcon( R.drawable.ic_launcher )
                .setContentTitle( getString( R.string.app_name ) )
                .build();

        if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN )
            notification.bigContentView = getExpandedView( isPlaying );

        NotificationManager manager = (NotificationManager) getSystemService( Context.NOTIFICATION_SERVICE );
        manager.notify( 1, notification );
    }

The code here follows the same convention as my previous post for standard notifications, with the exception of the Jelly Bean code to create a bigContentView Remote Views. The code for pre-Jelly Bean devices creates a notification that looks like this:


The getExpandedView function returns a RemoteViews object that consists of an inflated custom view that has PendingIntents associated with each of the buttons that will be sent to the existing service in order to control the operations within with the service. The layout is fairly standard with a fixed size of 128dp and three buttons under some custom text:

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

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="@dimen/notification_expanded_height">

    <ImageView
        android:id="@+id/large_icon"
        android:layout_width="@dimen/notification_expanded_height"
        android:layout_height="@dimen/notification_expanded_height"
        android:scaleType="centerCrop"
        android:layout_alignParentLeft="true"
        android:layout_alignParentBottom="true"
    />
    <LinearLayout
        android:id="@+id/buttons_row"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_toRightOf="@id/large_icon"
        android:orientation="horizontal">

        <ImageButton
            android:id="@+id/ib_rewind"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:layout_height="@dimen/notification_button_height"
            android:scaleType="fitCenter" />

        <ImageButton
            android:id="@+id/ib_play_pause"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:layout_height="@dimen/notification_button_height"
            android:scaleType="fitCenter" />

        <ImageButton
            android:id="@+id/ib_fast_forward"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:layout_height="@dimen/notification_button_height"
            android:scaleType="fitCenter" />
    </LinearLayout>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@string/app_name"
        android:textSize="@dimen/notification_text_size"
        android:layout_gravity="center"
        android:gravity="center"
        android:layout_toRightOf="@+id/large_icon"
        android:layout_above="@+id/buttons_row"/>

</RelativeLayout>

which in turn looks like this when it is created:


In the getExpandedView method, each image in the notification is set using the setImageViewResource method

customView.setImageViewResource( R.id.ib_rewind, R.drawable.ic_rewind );

and each button has a pending intent with action associated with it

Intent intent = new Intent( getApplicationContext(), CustomNotificationService.class );
intent.setAction( ACTION_NOTIFICATION_PLAY_PAUSE );
PendingIntent pendingIntent = PendingIntent.getService( getApplicationContext(), 1, intent, 0 );
customView.setOnClickPendingIntent( R.id.ib_play_pause, pendingIntent );

Since each of these pending intents goes back to the service, they are filtered through the handleIntent method and actions can be carried out based on the button clicks from this notification. Since the notification and operations are controlled through a service, the notification can control media when the app has been closed out or the Android device is locked. As this demo is not using an actual audio service to determine what controls should be shown, the mIsPlaying flag is set and passed to the custom view creation method in order to show either the play or pause button.


And that's how simple it is to create a custom expanded view with buttons!