Sans Pareil Technologies, Inc.

Key To Your Business

Lab 7: Fragments

In this exercise we will convert the GridView application we have been using to use fragments.  We will convert the code that displays the grid view (list) with items from our mock database as well as the detail view (ItemView) into fragments.  We will use the dynamic model of replacing the list fragment with the detail fragment in the main activity.

  • Copy the Lab6 directory to Lab7
  • Open the application manifest file and remove the ItemView activity (we will make that a fragment).
  • Edit the ItemView class
    • Make the class extend Fragment instead of Activity.  Comment out the onCreate method.
    • Add a private field named view of type View to hold the inflated layout.
    • Add a private field named title of type TextView to hold a reference to the widget loaded from the layout.
    • Add a private field named description of type TextView to hold a reference to the widget loaded from the layout.
    • Add a factory method getInstance to create a properly initialized instance of the fragment. Note that we could easily have passed in the Item instance to this method, however this illustrates using a Bundle to add contextual information to a fragment.
      public static ItemView getInstance( final int index )
      {
        final ItemView itemView = new ItemView();

        final Bundle bundle = new Bundle();
        bundle.putInt( "index", index );
        itemView.setArguments( bundle );

        return itemView;
      }
    • Over-ride the onCreateView method.  Accept the IDE suggestion of adding the annotations library to project.
      if ( view == null ) view = inflater.inflate( R.layout.activity_item_view, container, false );
      return view;
    • Over-ride the onActivityCreated method to initialize the widget references as appropriate
      super.onActivityCreated( savedInstanceState );

      if ( title == null ) title = (TextView) getView().findViewById( R.id.title );

      if ( description == null )
      {
        description = ( ( TextView) getView().findViewById( R.id.description ) );
        description.setOnClickListener( new ClickListener() );
      }

      if ( item == null )
      {
        final int index = getArguments().getInt( "index" );
        setItem( Database.getInstance( getActivity() ).getItem( index ) );
      }
    • Add a setItem package private method to allow the MainActivity to set the item to be displayed.
      void setItem( final Item item )
      {
        this.item = item;

        title.setText( item.getTitle() );
        description.setText( item.getDescription() );
      }
    • Edit the ClickListener inner class and correct the error reported by the IDE.  Use getActivity() as the Context instance instead of ItemView.this.
  • Copy the activity_main layout file to item_list
  • Edit the activity_main layout file and convert GridView to LinearLayout
    • Add android:orientation="vertical" attribute
    • Assign a different id for the layout container (android:id="@+id/listContainer")
  • Create a new class ItemList that inherits from Fragment.
    • Create a private field named gridView that will store the GridView instance we load from the layout file
    • Create a private field named numberAdapter that will store the NumberAdapter instance to associate with the GridView
    • Create a private field named view of type View that will store the inflated layout for the fragment.
    • Create a factory method to create a new instance of the fragment using a specified instance of NumberAdapter
      public static ItemList getInstance( final NumberAdapter adapter )
      {
        final ItemList itemList = new ItemList();
        itemList.numberAdapter = adapter;
        return itemList;
      }
    • Over-ride the onCreate method to indicate that the fragment has an options menu
      super.onCreate( savedInstanceState );
      setHasOptionsMenu( true );
    • Over-ride the onCreateView method
      if ( view == null ) view = inflater.inflate( R.layout.item_list, container, false );
      return view;
    • Over-ride the onActivityCreated method
      super.onActivityCreated( savedInstanceState );

      if ( gridView == null )
      {
        gridView = (GridView) getView().findViewById( R.id.gridView );

        gridView.setAdapter( numberAdapter );
        gridView.setMultiChoiceModeListener( new SelectionListener( gridView ) );
        gridView.setChoiceMode( ListView.CHOICE_MODE_MULTIPLE_MODAL );
      }
    • Over-ride the onCreateOptionsMenu method and inflate the resource for the menu in it (note implementation is similar to what is currently in MainActivity)
      inflater.inflate( R.menu.item, menu );
    • Over-ride the onOptionsItemSelected method (again similar to the method currently in MainActivity)
      switch ( item.getItemId() )
      {
        case R.id.settings:
          startActivity( new Intent( getActivity(), SettingsActivity.class ) );
          return true;
        case R.id.hundred:
          numberAdapter.setCount( 100 );
          return true;
        case R.id.twoHundred:
          numberAdapter.setCount( 200 );
          return true;
        case R.id.threeHundred:
          numberAdapter.setCount( 300 );
          return true;
      }

      return super.onOptionsItemSelected( item );
  • Modify the NumberAdapter class
    • Replace the context field with a field named activity of type MainActivity.
    • Modify the constructor accordingly.
    • Rename all occurrences of context with activity.
    • Modify the display method to invoke the displayDetails method in MainActivity
      public void display( final Collection<Integer> indices )
      {
        for ( final int index : indices )
        {
          final Item item = (Item) getItem( index );
          activity.displayDetails( item );
          break;
        }
      }
  • Edit the MainActivity class
    • Create a private field named itemList of type ItemList
    • Create a private field named itemView of type ItemView
    • Create a private field named numberAdapter of type NumberAdapter
    • Create a package private method displayItems which will load the ItemList fragment into the activity
      void displayItems()
      {
        if ( numberAdapter == null ) numberAdapter = new NumberAdapter( this );
        if ( itemList == null )
        {
          itemList = ItemList.getInstance( numberAdapter );
          itemList.setRetainInstance( true );
        }
        final FragmentTransaction transaction = getFragmentManager().beginTransaction();
        if ( itemView != null ) transaction.remove( itemView ); // Just for illustration.  replace will remove any existing fragment from the container
        transaction.replace( R.id.listContainer, itemList );
        transaction.commit();
      }
    • Create a package private method displayDetails which will load the ItemView fragment into the activity as follows
      void displayDetails( final Item item )
      {
        if ( itemView == null ) itemView = ItemView.getInstance( item.getIndex() );
        else itemView.setItem( item );

        final FragmentTransaction transaction = getFragmentManager().beginTransaction();
        if ( itemList != null ) transaction.remove( itemList ); // again just for illustration
        transaction.replace( R.id.listContainer, itemView );
        transaction.addToBackStack( "ItemView" );
        transaction.commit();
      }
    • Modify the onCreate method to display the ItemList fragment
      super.onCreate( savedInstanceState );
      setContentView( R.layout.activity_main );
      setDefaultValues( this, R.xml.preferences, false );
      displayItems();
    • Remove the onCreateOptionsMenu and onOptionsItemSelected methods
  • Run the application and test the interactions.
  • Modify the test suite to match the modifications.  We will need to remove the ItemViewTestRule custom Rule we had created, since we no longer have a separate Activity for displaying our Item objects.  We will also need to modify the ItemViewTest to conform to the new application
    • Modify the ItemViewTest class
      • Modify the activityTestRule instance to use MainActivity (note you can copy this line from the MainActivityTest class)
        public final ActivityTestRule<MainActivity> activityTestRule = new ActivityTestRule<>( MainActivity.class );
      • For each test method we will need to long click on a grid cell, display the overflow menu and select the Display menu item to display the ItemView fragment in the MainActivity.  Since this is common across all the test methods, we will use a method that JUnit will invoke before each test using the @Before annotation.  Note that our test methods themselves remain intact, since from a end-user perspective, the transition from using different activities to using a single activity that swaps fragments has no visual differences.
        @Before
        public void displayDetailView()
        {
          onData( anything() )
              .inAdapterView( withId( R.id.gridView ) ).atPosition( index )
              .perform( longClick() );

          openActionBarOverflowOrOptionsMenu( activityTestRule.getActivity() );

          onView( withText( R.string.display ) ).perform( click() );
        }
    • Remove the ItemViewTestRule class
    • Run the entire test suite using a custom Run target.

We will now add support for landscape orientation which will display both the ItemList and ItemView fragments side-by-side in the same activity.

  • Right-click the main_activity layout file in the IDE (under the res/layout directory) and create a new android resource file using the contextual menu in the IDE
    • Specify activity_main as the file name
    • Select Orientation from the available qualifiers pane and add to the selected qualifiers pane
    • Select Landscape from the screen orientation drop down list and click the OK button to create the new layout file.
  • Modify the newly created layout file
    • Change the orientation property of the layout to horizontal.
    • Drag and drop a FrameLayout widget from the palette to the view and assign it an id property listContainer.
    • Drag and drop a FrameLayout widget from the palette to the view and assign it an id property viewContainer.
    • Set the layout:weight property for both framelayouts to 1 to make them use up 50% each of the available screen space.
  • Modify the MainActivity class
    • Create a displayFragments method that will display the ItemList and ItemView fragments depending upon device orientation
      private void displayFragments()
      {
        displayItems();

        switch ( getWindowManager().getDefaultDisplay().getRotation() )
        {
          case Surface.ROTATION_90:
          case Surface.ROTATION_270:
            final Database database = Database.getInstance( this );
            if ( database.getCount() > 0 )
            {
              displayDetails( database.getItem( 0 ) );
            }
            break;
        }
      }
    • Modify the onCreate method to use the displayFragments method
      super.onCreate( savedInstanceState );
      setContentView( R.layout.activity_main );
      setDefaultValues( this, R.xml.preferences, false );
      displayFragments();
    • Modify the displayDetails method to populate the viewContainerFrameLayout instance
      if ( itemView == null ) itemView = ItemView.getInstance( item.getIndex() );
      else itemView.setItem( item );

      final FragmentTransaction transaction = getFragmentManager().beginTransaction();

      switch ( getWindowManager().getDefaultDisplay().getRotation() )
      {
        case Surface.ROTATION_90:
        case Surface.ROTATION_270:
          transaction.replace( R.id.viewContainer, itemView );
          break;
        default:
          transaction.replace( R.id.listContainer, itemView );
          break;
      }

      transaction.addToBackStack( "ItemView" );
      transaction.commit();
  • Run the application and test the interactions in both portrait and landscape mode.
  • Add a landscape test to the MainActivityTest suite
    @Test
    public void landscape()
    {
      final int index = 4;
      final Item item = Database.getInstance( activityTestRule.getActivity() ).getItem( index );


      activityTestRule.getActivity().setRequestedOrientation( SCREEN_ORIENTATION_LANDSCAPE );
      sleep( 500 );

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

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

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

      onView( withId( R.id.description ) )
          .check( matches( withText( item.getDescription() ) ) );
    }

Bug Fix

  • Modify the NumberAdapter class.
    • Modify ViewHolder inner class and make it public static since we will be accessing it from SelectionListener.
      • Add a private int field named position that will hold the position of the cell within the grid.
      • Add a getter method for the position field.
    • Modify the getView method to populate the position field in the ViewHolder with the passed in position.
  • Modify the SelectionListener class
    • Add a truePosition method that will calculate the position in the displayed grid cells for a specified position within the entire grid
      private int truePosition( final int position )
      {
        int index = 0;

        for ( ; index < gridView.getChildCount(); ++index )
        {
          final View child = gridView.getChildAt( index );
          final ViewHolder tag = (ViewHolder) child.getTag();
          if ( position == tag.getPosition() ) break;
        }

        return index;
      }
    • Modify the clear method and make it use the truePosition method
      for ( final int index : indices )
      {
        final View view = gridView.getChildAt( truePosition( index ) );
        if ( view != null )
        {
          view.setBackgroundColor( defaultColor );
          view.setSelected( false );
        }
      }

      indices.clear();