So far, the blog list example has assumed a single layout for all devices and orientations. Of course, we know that some phones are much larger than others, and tablets are larger than phones, and landscape vs portait orientations provide for very different amounts of screen real estate to work with. Having a single layout accounts for none of this, and inevitably forces the design to the lowest common denominator (a small phone in portrait orientation).
Providing different layouts means having reusable UI components becomes very important. In Android, these are called fragments. The Blog List app follows the typical example for which fragments are useful: a list of items that provide additional detail about a selected item.
In the Blog List app, so far we have just had two separate activities (and thus two separate screens): the blog list, and the blog detail view. On a large tablet in landscape mode, a better design would be to split the screen in half and display the list on the left, and the details for the selected item on the right.
NOTE: the Blog List app is now on github, so you can download the source and see how it evolves as I learn android.
NOTE 2: I primarily used two tutorials in developing/learning fragments. They are:
http://developer.android.com/guide/components/fragments.html
http://www.vogella.com/articles/AndroidFragments/article.html
In addition to introducing fragments in this version of the Blog List, I’m going to make the list sortable by each of the column headings. This required me to redo the way I was providing the header row in a way that I think it a lot simpler and less hacky.
Let’s start by looking at the XML in the res folder:
I’ve added a new layout file for the detail fragment at layout/detailviewfrag.xml:
But…in portrait mode, we want to only display the list and display an item without the list when an item is clicked – no split screen. Given the blogviewfrag.xml, how could we get back to the list without a back button?
Android accounts for this with a directory structure in the res directory that essentially prioritizes what resource file gets loaded. The layout folder is actually the fallback default folder for layout resources. We can override that for specific situations. In our case, we want an override for when in portrait mode so the detail view can have a back button:
layout-port/blogviewfrag.xml:
As you can see, the only difference between this blogviewfrag.xml and the one in the layout directory is the inclusion of the back button.
layout/blogview.xml just includes the blog view fragment:
The BlogViewActivity class now barely does anything (it is not even used in landscape oriention). It just passes the BlogPost passed into it via an Intent to the detail fragment:
public class BlogViewActivity extends Activity {
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.blogview);
BlogPost blogPost = (BlogPost) getIntent().getSerializableExtra("blogPost");
BlogDetailFragment detailFragment = (BlogDetailFragment) getFragmentManager().findFragmentById(R.id.blogDetailFrag);
if (detailFragment != null && detailFragment.isInLayout()) {
detailFragment.update(blogPost);
} else {
throw new RuntimeException("Something is very wrong. How can the detail activity not have the detail fragment!?!? Busted!");
}
}
}
Now the BlogDetailFragment handles all the work the BlogViewActivity used to handle:
public class BlogDetailFragment extends Fragment {
public static final DateTimeFormatter dtf = DateTimeFormat.forPattern("MM/dd/yyyy hh:mm:ss");
@InjectView(R.id.blogviewtitle) EditText title;
@InjectView(R.id.blogviewdate) TextView date;
@InjectView(R.id.blogviewcontent) EditText content;
@Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.blogviewfrag, container, false);
Views.inject(this, view);
return view;
}
public void update(BlogPost blogPost) {
title.setText(blogPost.content);
date.setText(dtf.print(blogPost.date));
content.setText(blogPost.content);
}
@OnClick(R.id.saveButton)
public void onSaveClick(View view) {
Log.d("onSaveClick", "Saved blog!");
}
@Optional
@OnClick(R.id.backButton)
public void onBackClick(View view) {
Intent i = new Intent(view.getContext(), ListBlogsActivity.class);
startActivity(i);
getActivity().finish();
}
}
Note the use of the @Optional annotation above the @OnClick annotation for the onBackClick. This tells Butterknife that we don’t know if this button will exist or not (because in landscape mode, it won’t; everything will be presented on one screen with nothing to go back to). Without the @Optional tag, this would throw an error in landscape orientation, which might look something like this:
Caused by: butterknife.Views$UnableToInjectException: Unable to inject views for BlogDetailFragment{a50e8f08 #1 id=0x7f050008}
The list view for portrait only includes the list fragment.
layout-port/main.xml:
And the layout for the list view:
layout/bloglistfrag.xml:
Note that the header row no longer is a part of the list view, and instead is it’s own static thing external to the list view. I had to do this for sorting; a list view only gives you what row you clicked, not which column. A positive side effect of this change is that the BlogPostRow interface is no longer needed, and our List blogPosts no longer have to have dummy header row data in them. Much preferred.
In landscape mode, our list fragment somehow has to update our detail fragment. To do this, the BlogListActivity becomes a intermediary between the two fragments.
However, the list fragment should not be aware of what activity is including it. So how can it talk to it’s parent activity in a custom way without know what kind of activity it is? The list fragment defines and interface that any Activity that wants to include it must implement:
public class BlogListFrag extends Fragment {
@Inject ObjectMapper mapper;
@InjectView(R.id.blogposts) ListView blogPostsListView;
public static final String SORT_COL_KEY = "sortCol";
public static final String SORT_ASC_KEY = "sortAsc";
public static final String SELECTED_IDX_KEY = "selectedBlogIndex";
private RequestQueue mRequestQueue;
private BlogClickListener listener;
private static final Map<SortColumn, Comparator> blogSorters = new HashMap<SortColumn, Comparator>();
private boolean sortAsc = true;
private SortColumn sortCol = DATE;
private int selectedBlogIndex = 0;
private List blogPosts;
private BlogListAdapter blogListAdapter;
static {
blogSorters.put(DATE, new DateComparator());
blogSorters.put(SUMMARY, new ContentComparator());
blogSorters.put(TITLE, new TitleComparator());
}
@Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.bloglistfrag, container, false);
ObjectGraph.create(new ObjectMappingModule()).inject(this);
Views.inject(this, view);
if (savedInstanceState != null && savedInstanceState.get(SORT_COL_KEY) != null) {
sortCol = (SortColumn) savedInstanceState.getSerializable(SORT_COL_KEY);
}
if (savedInstanceState != null && savedInstanceState.get(SORT_ASC_KEY) != null) {
sortAsc = savedInstanceState.getBoolean(SORT_ASC_KEY);
}
if (savedInstanceState != null && savedInstanceState.get(SELECTED_IDX_KEY) != null) {
selectedBlogIndex = savedInstanceState.getInt(SELECTED_IDX_KEY);
}
//Initiate the Volley request queue
mRequestQueue = Volley.newRequestQueue(getActivity());
blogListAdapter = new BlogListAdapter(this.getActivity(), new ArrayList());
blogPostsListView.setAdapter(blogListAdapter);
initBlogListClickListener();
getBlogPosts();
return view;
}
@Override public void onAttach(Activity activity) {
super.onAttach(activity);
if (activity instanceof BlogClickListener) {
listener = (BlogClickListener) activity;
} else {
throw new RuntimeException("activity must be a BlogClickListener!");
}
}
@Override public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable(SORT_COL_KEY, sortCol);
outState.putBoolean(SORT_ASC_KEY, sortAsc);
outState.putInt(SELECTED_IDX_KEY, selectedBlogIndex);
}
@Override public void onStop() {
super.onStop();
mRequestQueue.cancelAll(this);
}
@OnClick(R.id.dateHeading)
public void sortByDate(View view) {
sortBlogs(DATE);
}
@OnClick(R.id.titleHeading)
public void sortByTitle(View view) {
sortBlogs(TITLE);
}
@OnClick(R.id.summaryHeading)
public void sortBySummary() {
sortBlogs(SUMMARY);
}
private void getBlogPosts() {
mRequestQueue.add(new JsonArrayRequest(getString(R.string.rest_base_url) + "/BlogPosts.json",
new Response.Listener() {
@Override public void onResponse(JSONArray response) {
try {
blogPosts = mapper.readValue(response.toString(), new TypeReference<List>() {});
sort(blogPosts, sortAsc ? blogSorters.get(sortCol) : reverseOrder(blogSorters.get(sortCol)));
blogListAdapter.setData(blogPosts);
if (getActivity().getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
blogPostsListView.setSelection(selectedBlogIndex);
listener.onClick(blogPosts.get(selectedBlogIndex));
}
} catch (Exception e) {
throw new RuntimeException("Failed!", e);
}
}
},
new Response.ErrorListener() {
@Override public void onErrorResponse(VolleyError error) {
Log.e("ListBlogsActivity.onCreate()", "Volley failed to get BlogPosts! " + error.toString());
}
}
));
}
private void initBlogListClickListener() {
blogPostsListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
//Position 0 is the header. Don't do anything if that is clicked.
Log.d("ItemClickListener", view.toString());
//subtract 1 because the header is the first one, which is not accounted for in the blogPosts list.
BlogPost bp = blogPosts.get(position);
listener.onClick(bp);
}
});
}
public interface BlogClickListener {
void onClick(BlogPost blogPost);
}
private void sortBlogs(SortColumn selectedColumn) {
if (sortCol == selectedColumn) {
//reverse the sort
sortAsc = !sortAsc;
} else {
sortCol = selectedColumn;
sortAsc = true;
}
sort(blogPosts, sortAsc ? blogSorters.get(sortCol) : reverseOrder(blogSorters.get(sortCol)));
blogListAdapter.setData(blogPosts);
}
public static enum SortColumn {
DATE, TITLE, SUMMARY
}
static class DateComparator implements Comparator {
@Override public int compare(BlogPost lhs, BlogPost rhs) {
return lhs.date.compareTo(rhs.date);
}
}
static class TitleComparator implements Comparator {
@Override public int compare(BlogPost lhs, BlogPost rhs) {
return lhs.title.compareTo(rhs.title);
}
}
static class ContentComparator implements Comparator {
@Override public int compare(BlogPost lhs, BlogPost rhs) {
return lhs.content.compareTo(rhs.content);
}
}
}
The BlogClickListener is the interface that gets called when a blog is clicked in the list. The ListBlogsActivity must now implement this interface:
public class ListBlogsActivity extends Activity implements BlogListFrag.BlogClickListener {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
}
@Override public void onClick(BlogPost blogPost) {
BlogDetailFragment detailFrag = (BlogDetailFragment) getFragmentManager().findFragmentById(R.id.blogDetailFrag);
if (detailFrag != null && detailFrag.isInLayout()) {
detailFrag.update(blogPost);
} else {
Intent i = new Intent(this, BlogViewActivity.class);
i.putExtra("blogPost", blogPost);
startActivity(i);
finish();
}
}
}
Most of the functionality of this class has moved to the BlogListFrag class, and it is now just serving as an intermediary between the list fragment and the detail fragment. Note that if it cannot find the detail fragment when a blog entry is clicked, it starts the BlogViewActivity. If it can find the detail fragment, it just updates it with the selected blog entry.
The most interesting thing about the sorting implementation was that I removed the dummy header rows from the blog post list and separated it from the ListView. This simplified the adapter for the list view quite a bit.
public class BlogListAdapter extends BindingAdapter<BlogPost, BlogListAdapter.DetailViewHolder> {
private static final DateTimeFormatter dtf = DateTimeFormat.forPattern("MM/dd");
private static final int MAX_SUMMARY_LEN = 100;
private List<BlogPost> blogPostRows = new ArrayList<BlogPost>();
public BlogListAdapter(Context context, List<BlogPost> blogPostRows) {
super(context);
this.blogPostRows = blogPostRows;
}
public void setData(List<BlogPost> data) {
if (blogPostRows != null) {
blogPostRows.clear();
} else {
blogPostRows = new ArrayList<BlogPost>();
}
if (data != null) {
blogPostRows.addAll(data);
}
notifyDataSetChanged();
}
@Override public View newView(LayoutInflater inflater, int position, ViewGroup container) {
return inflater.inflate(R.layout.blogpostdetail, null);
}
@Override public DetailViewHolder buildViewHolder(View view, int position) {
return new DetailViewHolder(view);
}
@Override public void bindView(BlogPost item, int position, View view, DetailViewHolder vh) {
vh.blogDate.setText(dtf.print(item.date));
vh.blogTitle.setText(item.title);
String summary = item.content.substring(0, Math.min(MAX_SUMMARY_LEN, item.content.length()));
vh.blogSummary.setText(summary);
}
@Override
public int getCount() {
return blogPostRows.size();
}
@Override
public BlogPost getItem(int i) {
return blogPostRows.get(i);
}
@Override
public long getItemId(int i) {
return i;
}
static class DetailViewHolder {
TextView blogDate;
TextView blogTitle;
TextView blogSummary;
DetailViewHolder(View view) {
blogDate = (TextView) view.findViewById(R.id.blogdate);
blogTitle = (TextView) view.findViewById(R.id.blogtitle);
blogSummary = (TextView) view.findViewById(R.id.blogsummary);
}
}
}