Sans Pareil Technologies, Inc.

Key To Your Business

Lab 5: Menus

Extend our sample grid view application with an app bar and contextual action bar.

Add app bar

We will use the app bar to let the user select the number of items they wish to view in the grid view.  We will add menu items that correspond to the number of items to displayed.  When the user selects a particular number of items, the grid view will be updated with the selected number of items.

  • Copy the Lab3a directory to Lab5.
  • Edit the strings.xml resource file and add a new title for the menu item in the app bar.
    <string name="num_items">Number Of Items</string>
  • Add a new resource file File->New->Android Resource File.
    • Name the file item, and select Menu as the Resource type.
    • Add the main menu item and its sub-menu items similar to
      <item android:id="@+id/numItems" android:title="@string/num_items">
          <menu>
              <item android:id="@+id/hundred" android:title="100"/>
              <item android:id="@+id/twoHundred" android:title="200"/>
              <item android:id="@+id/threeHundred" android:title="300"/>
          </menu>
      </item>
  • Edit the Database class and add a setCount( final int count ) method that will set the value of a class field.  Modify the getCount() method to return the value stored in the field (initialize to the current value in the field declaration).
  • Edit the NumberAdapter class and add a method setCount( final int count ).  When the count is changed we need to notify the UI that the underlying dataset has changed.
    public void setCount( final int count )
    {
      Database.getInstance().setCount( count );
      notifyDataSetChanged();
    }
  • Edit the MainActivity class and add support for the app bar and handling clicks on the menu items in the app bar menu.
    • Add a utility method that returns the GridView instance (you will need to introduce a field that stores a reference to the GridView).
      private GridView getGridView()
      {
        if ( gridView == null ) gridView = (GridView) findViewById( R.id.gridView );
        return gridView;
      }
    • Add a utility method that returns the NumberAdapter instance bound to the GridView.
      private NumberAdapter getNumberAdapter() { return (NumberAdapter) getGridView().getAdapter(); }
    • Implement the onCreateOptionsMenu method.
      @Override
      public boolean onCreateOptionsMenu( Menu menu )
      {
        getMenuInflater().inflate( R.menu.item, menu );
        return true;
      }
    • Implement the onOptionsItemSelected method.
      @Override
      public boolean onOptionsItemSelected( MenuItem item )
      {
        switch ( item.getItemId() )
        {
          case R.id.hundred:
            getNumberAdapter().setCount( 100 );
            return true;
          case R.id.twoHundred:
            getNumberAdapter().setCount( 200 );
            return true;
          case R.id.threeHundred:
            getNumberAdapter().setCount( 300 );
            return true;
        }

        return super.onOptionsItemSelected( item );
      }
  • Run the app and test the app bar menu interactions.
  • Modify the MainActivityTest class and remove the existing tests (except cell).  Add tests for the app bar menu items.
    • Add a test for the menu item that sets the number of grid view items to 100.
      @Test
      public void hundred()
      {
        openActionBarOverflowOrOptionsMenu( activityTestRule.getActivity() );
        onView( withText( R.string.num_items ) ).perform( click() );
        onView( withText( "100" ) ).perform( click() );
        assertEquals( "Count not set to 100", 100, Database.getInstance().getCount() );
      }
    • Add a test for the menu item that sets the number of grid view items to 200 similar to the above test.
    • Add a test for the menu item that sets the number of grid view items to 300 similar to the above test.

Add a contextual menu bar

Rework the application to use a contextual actual bar instead of the click listener on the TextView widgets we have been using as the grid cells.  We will also enhance the grid cell to be a complex widget that will show the title of the item as before, but also optionally display the author of the item.

  • Add a  new resource file of type menu named context (use the IDE to automatically assign values for the @string/xxx resources).
    <item android:id="@+id/detailed" android:showAsAction="ifRoom|withText" android:title="@string/detailed" android:titleCondensed="@string/detailed"/>
    <item android:id="@+id/compact" android:showAsAction="ifRoom|withText" android:title="@string/compact" android:titleCondensed="@string/compact"/>
    <item android:id="@+id/display" android:showAsAction="ifRoom|withText" android:title="@string/display" android:titleCondensed="@string/display"/>
  • Create a new resource file of type Layout named item_cell.  We will this layout for our grid cells.
    • Add a Large Text widget to the layout and give it id title.
    • Add a Medium Text widget to the layout and give it id author.
  • Add a getAuthor method to the Item class similar to
    public String getAuthor() { return format( "Author %d", index + 1 ); }
  • Introduce a new ViewHolder inner class to NumberAdapter that will hold references to the TextView's we load from the item_cell layout file.  This helps avoid using the heavy findViewById method on every cell.
    private class ViewHolder
    {
      private TextView title;
      private TextView author;
    }
  • Add three methods to the NumberAdapter class that will be invoked corresponding to the menu items in the contextual action menu.
    • Add a set field to the class that will store the indices at which we display the author information along with the title.
      private final Set<Integer> detailedIndices = new HashSet<>();
    • Add a method named detailed which we will invoke for the corresponding contextual menu item.
      public void detailed( final Collection<Integer> indices )
      {
        detailedIndices.addAll( indices );
        notifyDataSetChanged();
      }
    • Add a method named compact which we will invoke for the corresponding contextual menu item.
      public void compact( final Collection<Integer> indices )
      {
        int count = detailedIndices.size();
        for ( final int index : indices ) detailedIndices.remove( index );
        if ( count != detailedIndices.size() ) notifyDataSetChanged();
      }
    • Add a method named display which we will invoke for the corresponding contextual menu item.  Since we cannot display multiple activities in sequence, we will just take the first index value we get and display it.
      public void display( final Collection<Integer> indices )
      {
        for ( final int index : indices )
        {
          final Item item = (Item) getItem( index );
          final Intent intent = new Intent( context, ItemView.class );
          intent.putExtra( "index", item.getIndex() );
          context.startActivity( intent );
          break;
        }
      }
    • Modify the getView method to load the item_cell layout file for the view being returned.  Note that we also remove the on-click handling from the cells (since that would interfere with the default long-click event that displays the contextual menu bar).  Note how we use the ViewHolder to avoid having to use the heavy findViewById methods when re-using the convertView parameter.
      @Override
      public View getView( int position, View convertView, ViewGroup parent )
      {
        View view = convertView;

        if ( view == null )
        {
          final LayoutInflater inflater = (LayoutInflater) context.getSystemService( Context.LAYOUT_INFLATER_SERVICE );
          view = inflater.inflate( R.layout.item_cell, parent, false );
          final ViewHolder holder = new ViewHolder();
          holder.title = (TextView) view.findViewById( R.id.title );
          holder.author = (TextView) view.findViewById( R.id.author );
          view.setTag( holder );
        }

        final ViewHolder holder = (ViewHolder) view.getTag();
        final Item item = (Item) getItem( position );
        holder.title.setText( item.getTitle() );
        if ( detailedIndices.contains( position ) ) holder.author.setText( item.getAuthor() );
        else holder.author.setText( "" );
        return view;
      }
    • Remove the ClickListener inner class.
  • Add a SelectionListener class that implements MultiChoiceModeListener.  Let the IDE generate the required methods.
    • Introduce three fields in the class that will hold a reference to the GridView (let IDE generate the constructor) used in the app and track the cells the user selects as well as the default background color for the selected cells.
      private final GridView gridView;
      private final Set<Integer> indices = new HashSet<>();

      private int defaultColor = -1;
    • Implement the onItemCheckedStateChanged method.  We will change the background color of the selected cells to provide the user visual feedback of their selection.
      if ( defaultColor == -1 ) defaultColor = gridView.getChildAt( position ).getDrawingCacheBackgroundColor();

      if ( checked )
      {
        indices.add( position );
        gridView.getChildAt( position ).setBackgroundColor( Color.GRAY );
      }
      else
      {
        indices.remove( position );
        if ( defaultColor != -1 ) gridView.getChildAt( position ).setBackgroundColor( defaultColor );
      }
    • Implement onCreateActionMode similar to
      mode.getMenuInflater().inflate( R.menu.context, menu );
      return true;
    • Introduce a clear method.  This will clear the state we have kept for the menu interaction, as well as reset the selected state of cells that were selected.  If we do not reset the selected state, the views will maintain that, and affect our application behavior on subsequent selection. 
      private void clear()
      {
        for ( final int index : indices )
        {
          final View view = getGridView().getChildAt( index );
          view.setBackgroundColor( defaultColor );
          view.setSelected( false );
        }

        indices.clear();
      }
    • Modify the implementation of onDestroyActionMode to just invoke the clear method.
    • Add a utility method to return the instance of NumberAdapter used similar to
      private NumberAdapter getNumberAdapter() { return (NumberAdapter) gridView.getAdapter(); }
    • Implement the onActionItemClicked method similar to
      boolean result = false;

      switch ( item.getItemId() )
      {
        case R.id.compact:
          getNumberAdapter().compact( indices );
          result = true;
          break;
        case R.id.detailed:
          getNumberAdapter().detailed( indices );
          result = true;
          break;
        case R.id.display:
          getNumberAdapter().display( indices );
          result = true;
          break;
      }

      clear();
      mode.finish();
      return result;
  • Edit the MainActivity class
    • Modify the onCreate method to configure the GridView with the multiple choice selection listener.
      getGridView().setAdapter( new NumberAdapter( this ) );
      getGridView().setMultiChoiceModeListener( new SelectionListener( getGridView() ) );
      getGridView().setChoiceMode( ListView.CHOICE_MODE_MULTIPLE_MODAL );
  • Run the application and test the contextual action bar interactions by long clicking initially on any cell.  Test multiple selection behavior as well.
  • Modify the MainActivityTest to incorporate tests for the contextual action bar.  Note that Espresso does not support looking up menu items by id.  So, we have to perform string matching to select a menu item.
    • Modify the cell test to account for the complex View used in the grid cells.
      @Test
      public void cell()
      {
        final int index = 4;
        final Item item = Database.getInstance().getItem( index );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index )
            .onChildView( withText( item.getTitle() ) )
            .check( matches( withText( item.getTitle() ) ) );
      }
    • Add a new test method for the contextual action bar menu item.
      @Test
      public void onLongClick()
      {
        final int index = 4;
        final Item item = Database.getInstance().getItem( index );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index )
            .perform( longClick() );

        onView( withText( R.string.detailed ) ).perform( click() );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index )
            .onChildView( withText( item.getAuthor() ) )
            .check( matches( withText( item.getAuthor() ) ) );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index )
            .perform( longClick() );

        onView( withText( R.string.compact ) ).perform( click() );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index )
            .onChildView( withText( "" ) )
            .check( matches( withText( "" ) ) );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index )
            .perform( longClick() );

        openActionBarOverflowOrOptionsMenu( activityTestRule.getActivity() );

        onView( withText( R.string.display ) ).perform( click() );

        onView( withId( R.id.description ) )
            .check( matches( withText( item.getDescription() ) ) );
      }
    • Add a test for multiple selection similar to
      @Test
      public void multipleSelection()
      {
        final int index1 = 4;
        final Item item1 = Database.getInstance().getItem( index1 );

        final int index2 = 6;
        final Item item2 = Database.getInstance().getItem( index2 );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index1 )
            .perform( longClick() );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index2 )
            .perform( click() );

        onView( withText( R.string.detailed ) ).perform( click() );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index1 )
            .onChildView( withText( item1.getAuthor() ) )
            .check( matches( withText( item1.getAuthor() ) ) );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index2 )
            .onChildView( withText( item2.getAuthor() ) )
            .check( matches( withText( item2.getAuthor() ) ) );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index2 )
            .perform( longClick() );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index1 )
            .perform( click() );

        onView( withText( R.string.compact ) ).perform( click() );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index1 )
            .onChildView( withText( "" ) )
            .check( matches( withText( "" ) ) );

        onData( anything() )
            .inAdapterView( withId( R.id.gridView ) ).atPosition( index2 )
            .onChildView( withText( "" ) )
            .check( matches( withText( "" ) ) );
      }

Add a PopupMenu

Add a PopupMenu to the ItemView activity used to display an individual item selected from the GridView.  The popup menu will be used to show either the description or a synopsis for the current item.

  • Edit the Item class and add a getSynopsis() method which will return a shorter text than getDescription() (use 25 iterations instead of 100 for instance).
  • Add a new resource file of type Menu named popup and add two menu items similar to
    <item android:id="@+id/detailed" android:title="@string/detailed"/>
    <item android:id="@+id/compact" android:title="@string/compact"/>
  • Edit the ItemView class and add support for displaying a PopupMenu when user clicks on the description TextView.  Modify the onCreate method to something similar to the following:
    item = Database.getInstance().getItem( index );
    description = ( ( TextView) findViewById( R.id.description ) );

    ( (TextView) findViewById( R.id.title ) ).setText( item.getTitle() );
    description.setText( item.getDescription() );
    description.setOnClickListener( new ClickListener() );
  • Add a ClickListener inner class that implements OnClickListener.  Implement the onClick method similar to
    final PopupMenu popup = new PopupMenu( ItemView.this, v );
    popup.inflate( R.menu.popup );
    popup.setOnMenuItemClickListener( new LengthHandler() );
    popup.show();
  • Add a LengthHandler inner class that implements PopupMenu.OnMenuItemClickListener.  Implement the event handler method similar to
    switch ( item.getItemId() )
    {
      case R.id.compact:
        description.setText( ItemView.this.item.getSynopsis() );
        return true;
      case R.id.detailed:
        description.setText( ItemView.this.item.getDescription() );
        return true;
    }

    return false;
  • Run the app and test the popup menu interactions.
  • Update the test suite to test the ItemView activity.
    • Create a new class named ItemViewTestRule that inherits from ActivityTestRule.  We will use this Rule to directly test ItemView activity without launching the MainActivity.
      public class ItemViewTestRule extends ActivityTestRule<ItemView>
      {
        private final int index;

        public ItemViewTestRule( final int index )
        {
          super( ItemView.class );
          this.index = index;
        }

        @Override
        protected Intent getActivityIntent()
        {
          final Intent intent = new Intent( Intent.ACTION_VIEW );
          intent.setClassName( ItemView.class.getPackage().getName(), ItemView.class.getName() );
          intent.putExtra( "index", index );
          return intent;
        }
      }
    • Create a new test class named ItemViewTest.
      public class ItemViewTest
      {
        private static final int index = 4;

        @Rule
        public final ItemViewTestRule activityTestRule = new ItemViewTestRule( index );

        @Test
        public void display()
        {
          final Item item = Database.getInstance().getItem( index );
          onView( withId( R.id.title ) )
              .check( matches( withText( item.getTitle() ) ) );
          onView( withId( R.id.description ) )
              .check( matches( withText( item.getDescription() ) ) );
        }

        @Test
        public void detailed()
        {
          final Item item = Database.getInstance().getItem( index );
          onView( withId( R.id.description ) ).perform( click() );
          onView( withText( R.string.detailed ) ).perform( click() );
          onView( withId( R.id.description ) )
              .check( matches( withText( item.getDescription() ) ) );
        }

        @Test
        public void compact()
        {
          final Item item = Database.getInstance().getItem( index );
          onView( withId( R.id.description ) ).perform( click() );
          onView( withText( R.string.compact ) ).perform( click() );
          onView( withId( R.id.description ) )
              .check( matches( withText( item.getSynopsis() ) ) );
        }
      }
    • Add a new Run configuration that will run all our test classes.
      • Click the Run->Edit Configurations ... menu
      • Click the + button at the top to add a new run configuration.  Select the type as Android Tests in the drop-down list displayed.
      • Change the Name to Tests.
      • Select the app module from the Module drop-down.
      • Click OK to create the new run configuration.
      • Make sure the new Tests configuration is selected and run the target.