Sunday, March 24, 2013

ActionBarSherlock: Custom List Navigation

In this tutorial I'm going to cover activity navigation with ActionBarSherlock and custom navigation view, similar to Google Maps or Gmail - see full source links at the bottom of the post.



As you look at the source code, you will notice that most of the code is in this AbstractActivity class that extends SherlockActivity and provides consistent navigation across the three activities that extend it.

onCreate() method sets up the activity so that it displays an "up" icon, hides the title and uses the activity logos defined in AndroidManifest.xml for each activity - this way, the activity logo can be touched to go back to the previous one.
// Up Icon + Logo + Hide title...
getSupportActionBar().setDisplayHomeAsUpEnabled (true);
getSupportActionBar().setDisplayShowTitleEnabled(false);
getSupportActionBar().setDisplayUseLogoEnabled  (true);
The next thing it does it configures the ActionBar list navigation with a custom NavigationListAdapter and NavigationListListener, reading the resources (logo, title, subtitle) from typed-arrays as references. Just as well, that can be changed to say implement a custom NavigationListItem to store the three together in an object if you want.
// Custom navigation list adapter...
Context    context   = getSupportActionBar().getThemedContext();
TypedArray logos     = getResources().obtainTypedArray(R.array.activity_logos);
TypedArray titles    = getResources().obtainTypedArray(R.array.activity_titles);
TypedArray subtitles = getResources().obtainTypedArray(R.array.activity_subtitles);
NavigationListAdapter navigationListApdater = new NavigationListAdapter(context, logos, titles, subtitles);
        
// Custom navigation list listener... 
NavigationListListener navigationListListener = new NavigationListListener(this);

// Set navigation mode...
getSupportActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
getSupportActionBar().setListNavigationCallbacks(navigationListApdater, navigationListListener);

Then just to finish it off, it determines the position in the navigation list for current activity - this will be used to select current activity in the navigation list onResume() - for both initial run as well as when coming back from next activity - as well as used in the listener to avoid going into a loop. That is because when the activity starts it will select the first entry in the navigation list, causing an unintended activity run.
/*
 * (non-Javadoc)
 * @see android.app.Activity#onCreate(android.os.Bundle)
 */
protected void onCreate(Bundle savedInstanceState) {
  [...]
  m_currentNavigationItem = getCurrentNavigationItem(this, titles);
  [...]
}

/*
 * (non-Javadoc)
 * @see android.app.Activity#onResume()
 */
@Override
protected void onResume() {
  /*
   * Super...
   */
   super.onResume();
     
   /*
    * Select current title in navigation list (first start or on back)...
    */
   getSupportActionBar().setSelectedNavigationItem(m_currentNavigationItem);
}

/**
 * Get navigation list index for current activity.
 * 
 * @param p_activity
 * @param p_titles
 * @return
 */
private int getCurrentNavigationItem(Activity p_activity, TypedArray p_titles) {
  String title    = p_activity.getTitle().toString();
  int    position = 0;
     
  for (int i = 0, n = p_titles.length(); i < n; i++) {
    if (p_titles.getString(i).equals(title)) {
      position = i;
      break;
    }
  }
  
  return position;
}
The NavigationListAdapter implements a SpinnerAdapter and provides custom layouts for the top ActionBar item and drop-down items.

The top item doesn't have an icon (only title and subtitle), as explained above, the activity will provide it.
/*
 * (non-Javadoc)
 * @see android.widget.Adapter#getView(int, android.view.View, android.view.ViewGroup)
 */
@Override
public View getView(int p_position, View p_convertView, ViewGroup p_parent) {
 /*
  * View...
  */
 View view = p_convertView;
 if (view == null) {
  view = m_layoutInflater.inflate(R.layout.navigation_list_item, p_parent, false);
 }
 
 /*
  * Display...
  */
 // Title...
 TextView tv_title = (TextView) view.findViewById(R.id.title);
 tv_title.setText(m_titles.getString(p_position));
 
 // Subtitle...
 TextView tv_subtitle = ((TextView) view.findViewById(R.id.subtitle));
 tv_subtitle.setText      (m_subtitles.getString(p_position));
 tv_subtitle.setVisibility("".equals(tv_subtitle.getText()) ? View.GONE : View.VISIBLE);
 
 /*
  * Return...
  */
 return view;
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
 xmlns:android="http://schemas.android.com/apk/res/android"
 android:layout_width="wrap_content"
 android:layout_height="wrap_content"
 android:orientation="vertical"
 android:gravity="center_vertical|left">
 
 <TextView
  android:id="@+id/title"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:paddingLeft="0dp"
  android:singleLine="true"
  android:ellipsize="end"
  style="?attr/spinnerItemStyle" />
 
 <TextView
  android:id="@+id/subtitle"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:paddingLeft="0dp"
  android:singleLine="true"
  android:ellipsize="end"
  android:textAppearance="?attr/textAppearanceSmall"
  style="?attr/spinnerItemStyle" />
 
</LinearLayout>
The drop-down item has a logo, title and subtitle. Both allow for subtitles to be optional, hiding the subtitle when blank, vertically centering the title to take the space.
/*
 * (non-Javadoc)
 * @see android.widget.BaseAdapter#getDropDownView(int, android.view.View, android.view.ViewGroup)
 */
@Override
public View getDropDownView(int p_position, View p_convertView, ViewGroup p_parent) {
 /*
  * View...
  */
 View view = p_convertView;
 if (view == null) {
  view = m_layoutInflater.inflate(R.layout.navigation_list_dropdown_item, p_parent, false);
 }
 
 /*
  * Display...
  */

 // Icon...
 ImageView iv_logo = (ImageView) view.findViewById(R.id.logo);
 iv_logo.setImageDrawable(m_logos.getDrawable(p_position));
 
 // Title...
 TextView tv_title = (TextView) view.findViewById(R.id.title);
 tv_title.setText(m_titles.getString(p_position));
 
 // Subtitle...
 TextView tv_subtitle = ((TextView) view.findViewById(R.id.subtitle));
 tv_subtitle.setText      (m_subtitles.getString(p_position));
 tv_subtitle.setVisibility("".equals(tv_subtitle.getText()) ? View.GONE : View.VISIBLE);
 
 /*
  * Return...
  */
 return view;
}
<?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="?attr/dropdownListPreferredItemHeight"
 android:orientation="horizontal"
 style="?attr/spinnerDropDownItemStyle">
 
 <ImageView
  android:id="@+id/logo"
  android:layout_width="wrap_content"
  android:layout_height="match_parent"
  android:adjustViewBounds="true"/>
 
 <LinearLayout
  android:layout_width="wrap_content"
  android:layout_height="match_parent"
  android:orientation="vertical"
  android:gravity="center_vertical|left">
  
  <TextView 
   android:id="@+id/title"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"  
   android:singleLine="true"
   android:ellipsize="end"
   style="?attr/spinnerDropDownItemStyle" />
  
  <TextView 
   android:id="@+id/subtitle"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:singleLine="true"
   android:ellipsize="end"
   android:textAppearance="?attr/textAppearanceSmall"
   style="?attr/spinnerDropDownItemStyle" />
  
 </LinearLayout>

</LinearLayout>

Finally, NavigationListListener implements OnNavigationListener - an interesting note, it uses the item id - provided by NavigationListAdapter.getItemId() - which is in fact the title string id, to decide which activity to start. This is an alternative to using the position, which might just as well change during development, but this way you won't need to change the code to match.
/**
 * Custom navigation list listener.
 */
private class NavigationListListener implements OnNavigationListener {
 /**
  * Members
  */
 private AbstractActivity m_activity;
 
 /**
  * 
  * @param p_activity
  */
 NavigationListListener(AbstractActivity p_activity) {
  m_activity = p_activity;
 }
 
 /*
  * (non-Javadoc)
  * @see com.actionbarsherlock.app.ActionBar.OnNavigationListener#onNavigationItemSelected(int, long)
  */
 @Override
 public boolean onNavigationItemSelected(int p_itemPosition, long p_itemId) {
  /*
   * Ignore if selecting current...
   */
  if (p_itemPosition == m_activity.m_currentNavigationItem) {
   return true;
  }
  
  /*
   * Start new activity...
   */
  Intent intent = null;
  if (p_itemId == R.string.title_activity_main) {
   intent = new Intent(m_activity, MainActivity.class);
  }
  else if (p_itemId == R.string.title_activity_one) {
   intent = new Intent(m_activity, FirstActivity.class);
  }
  else if (p_itemId == R.string.title_activity_two) {
   intent = new Intent(m_activity, SecondActivity.class);
  }
  
  if (intent != null) {
   intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
   startActivity(intent);
  }
  
  return true;
 }
}
And that's about it - the activities will just need to provide a layout id and the generic AbstractActivity logic will take care of the work consistently across.

Source code (Google Code - SVN):
Resources:

15 comments :

  1. I have been working through your source code to implement this in my app. I am very very close to get it working 100%. One thing I am stuck on is the following:

    1) when I load my app, I have the option to select one of my activities from the list navigation (it shows the icons, titles and subtitles of my 3 activities (MainActivity, Activity2, Activity3)

    2)when I select one of the second or third activities from the list, the app does indeed go to the correct activity however the navigation list always reverts to the name of the MainActivity. THis also results in me not being able to navigate to the main activity because the navigation list already thinks I am there.

    Any thoughts on what could be causing this?

    Thank you in advance

    ReplyDelete
  2. Problem Solved:

    Since getCurrentNavigationItem Defaults to int position = 0 AND p_activity.getTitle().toString() was always returning my "App name" instead of my activity name (since I hadn't defined a label for each activity = to it's title in the activity title array) the navigation list always stayed at position 0.

    Thank you so much for this great tutorial. You have made my app better!

    ReplyDelete
  3. Yes, you just found it - was going to say that the navigation list always goes back to the first entry on an activity start, and you need to switch it through the code, but without triggering another activity start. The way I did it is not necessarily the best, but it works, you can refine it I guess.

    ReplyDelete
  4. hey i need that code as soon as possible like in .zip file. please mail on my id suthar.bha17@gmail.com

    ReplyDelete
  5. hey M@antu, the code is already available through SVN, see the links at the bottom of the post (see Checkout sub-tab in Google Code).

    Also search for Eclipse Subversion (available through Eclipse Updates > Collaboration) and how to import SVN projects into your workspace (File > New > Other... > SVN > Project from SVN).

    http://www.vogella.com/articles/EclipseSubversive/article.html

    ReplyDelete
  6. Hello Dan, great tutorial. One question, how do I turn this whole structure fragment?

    ReplyDelete
  7. It's a bit too much for the comment box, but if I were to do it, I would use SherlockFragmentActivity as a base class and in the NavigationListAdapter call getSupportFragmentManager() and the appropriate methods to create / show fragments - findFragmentByTag(), Fragment.instantiate(), add(), replace(), show() - to bring the fragments in view, instead of activities. If more people are interested, I might write a version that uses fragments.

    ReplyDelete
  8. Thanks for the reply Dan, it would be interesting if you did it: D

    Again, thank you for the post.

    ReplyDelete
  9. Dan, by code, could transform from Activity to Fragment. Follow the link

    Att,


    https://www.4shared.com/zip/4Ra8Pifu/ListNavigationFragments_1.html?
    Att,

    ReplyDelete
  10. Dan, thank you - great tutorial!

    I'm having the same problem as Reboots Ramblings and don't understand how it was solved. Could either of you help me out? Thanks

    ReplyDelete
  11. Thanks amck111.

    Now, your problem in short is this - when you start a new activity, the navigation list will default to first entry.

    What you want is to change it to reflect the current activity, BUT the problem is when you do that by calling getSupportActionBar().setSelectedNavigationItem() that will trigger a call in the NavigationListListener.

    So, if you look at my implementation, I'm trying to figure out which activity is currently runing (by title), store that in m_currentNavigationItem and change it in the navigation list. The same is used then in the listener to ignore the change and not trigger a new activity when the position matches the m_currentNavigationItem.

    Look, if you want to have a look at this together we could try using TeamViewer and have a look at it together - send me an email at dan.dar33 at gmail.com if you want to give it a go.

    ReplyDelete
  12. Thanks for getting back to me so quickly Dan.

    I got the problem fixed, it was simple a case of not labelling the activity in the manifest - a quick fix!

    Thanks again for the excellent tutorial

    ReplyDelete
  13. This is great. Thanks a million. I implemented it without a hitch. I have one question though. Is there an optimal size for the list icons (you call them logos)? Thanks, JP

    ReplyDelete
  14. You're welcome. I guess it depends how you look at it (android:icon or android:logo)
    http://developer.android.com/guide/topics/ui/actionbar.html#Logo

    I would probably follow the size of the app icon, providing one for each screen density so details are visible.

    http://developer.android.com/design/style/iconography.html

    ReplyDelete
  15. Great and nice article. its working 100% to me. thank you very much

    ReplyDelete