Android Notes App: Master-Detail Walkthrough

I’ve been playing around with Android recently, most specifically to try and get some base applications that I can refer to for basic patterns. One of the patterns I want to play with is the master-detail pattern, which fortunately is a standard layout mechanisms that you can choose when you create a . The master-detail pattern is different from most of the samples in that it looks different on a tablet vs. a phone.

Let’s say you go through the Android Studio “Create a new Project”, select the Master-Detail project type, and set the item type to Note. You will have the following file structure:

This provides the UI components whereby the first page to come up is the NoteListActivity and that is configured such that if the user is on a phone, then it shows a list. Each item in the list is linked so that the NoteDetailActivity is shown. When the user has done with the detail pane, it goes back to the main list. On a tablet, however, something different happens. The left hand side of the page is the list and the right hand side of the page is handled by the NoteDetailFragment instead. To complete the class run-down, the DummyContent is a temporary class for handling the data and I am definitely going to replace that later on.

Let’s turn our attention to the layouts, which is the other big thing that one must deal with at this point. There are 6 layout files.

  • activity_note_detail.xml is associated with the NoteDetailActivity and is shown on a phone form-factor only.
  • activity_note_list.xml is the main (top-level) layout for the NoteListActivity.
  • There are two note_list.xml layouts – one for tablets and one for phones. This is included in the activity_note_list.xml file.
  • The note_detail.xml is the layout for the NoteDetailFragment, which is used only on tablets.
  • The note_list_content.xml is the layout for the individual rows of the Notes List (in both the tablet and the phone versions).

Open the activity_note_list.xml layout file. You can adjust the form factor right in the layout preview:

I generally use the Pixel and the Pixel C for my two form factors when designing layouts. In addition, I have created another AVD so I can run the app on an emulated Pixel C. To do this:

  1. Click the AVD Manager in the tool bar. It’s also in Tools > Android > AVD Manager.
  2. Click Create Virtual Device…
  3. Click Tablet, then Pixel C.
  4. Click Next.
  5. Select an appropriate API Level (I’m using API level 25 right now). Click Next.
  6. Landscape should be selected (which is what you want). Click Finish.

You can now compile the app and run it on both the Nexus 5X and the Pixel C emulators. I don’t recommend running both emulators together unless you have a high spec machine. My Mac Mini slows to a crawl when I start both emulators up at the same time.

Modifying the Layout for my app

I’m writing a Notes app. The Notes model has the following fields:

  • noteId: a GUID that uniquely identifies the note.
  • title: the title of the note.
  • content: the content of the note.
  • created: the UTC date that the note was created.
  • updated: the UTC date that the note was last updated.

This is encapsulated in the data/Note.java file. To generate this file, I created the class, added the fields as private fields and then used the Refactor > Encapsulate Fields… option to generate the getters and setters for me. I’m using Joda-Time for the date/time fields because it’s better than the default versions.

package com.shellmonger.notes.data;

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;

import java.util.UUID;

/**
 * The Note model
 */
public class Note {
    private String noteId;
    private String title;
    private String content;
    private DateTime created;
    private DateTime updated;

    /**
     * Create a new blank note
     */
    public Note() {
        setNoteId(UUID.randomUUID().toString());
        setTitle("");
        setContent("");
        setCreated(DateTime.now(DateTimeZone.UTC));
    }

    /**
     * Create a new note with a specific title and content
     * @param title the title
     * @param content the content
     */
    public Note(String title, String content) {
        setNoteId(UUID.randomUUID().toString());
        setTitle(title);
        setContent(content);
        setCreated(DateTime.now(DateTimeZone.UTC));
    }

    /**
     * Returns the noteId
     * @return the note ID
     */
    public String getNoteId() { return noteId; }

    /**
     * Sets the noteId
     * @param noteId the new note ID
     */
    void setNoteId(String noteId) {
        this.noteId = noteId;
    }

    /**
     * Returns the title
     * @return the title
     */
    public String getTitle() { return title; }

    /**
     * Sets the title
     * @param title the new title
     */
    public void setTitle(String title) {
        this.title = title;
    }

    /**
     * Returns the note content
     * @return the note content
     */
    public String getContent() { return content; }

    /**
     * Sets the note content
     * @param content the note content
     */
    public void setContent(String content) {
        this.content = content;
    }

    /**
     * Returns the created date
     * @return the created date
     */
    public DateTime getCreated() { return created; }

    /**
     * Sets the created date
     * @param created the created date
     */
    void setCreated(DateTime created) {
        this.created = created;
    }

    /**
     * Returns the last updated date
     * @return the last updated date
     */
    public DateTime getUpdated() { return updated; }

    /**
     * Sets the last updated date
     * @param updatedDate the new last updated date
     */
    public void setUpdated(DateTime updatedDate) {
        this.updated = updatedDate;
    }

    /**
     * Updates the note
     * @param title the new title
     * @param content the new content
     */
    public void updateNote(String title, String content) {
        setTitle(title);
        setContent(content);
        setUpdated(DateTime.now(DateTimeZone.UTC));
    }

    /**
     * The string version of the class
     * @return the class unique descriptor
     */
    @Override
    public String toString() {
        return String.format("[note#%s] %s", noteId, title);
    }
}

I can now update the dummy/DummyContent.java to use the Note instead of the DummyItem class:

package com.shellmonger.notes.dummy;

import com.shellmonger.notes.data.Note;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Helper class for providing sample content for user interfaces created by
 * Android template wizards.
 * <p>
 * TODO: Replace all uses of this class before publishing your app.
 */
public class DummyContent {

    /**
     * An array of sample (dummy) items.
     */
    public static final List<Note> ITEMS = new ArrayList<>();

    /**
     * A map of sample (dummy) items, by ID.
     */
    public static final Map<String, Note> ITEM_MAP = new HashMap<>();

    private static final int COUNT = 25;

    static {
        // Add some sample items.
        for (int i = 1; i <= COUNT; i++) {
            addItem(createDummyItem(i));
        }
    }

    private static void addItem(Note item) {
        ITEMS.add(item);
        ITEM_MAP.put(item.getNoteId(), item);
    }

    private static Note createDummyItem(int position) {
        StringBuilder builder = new StringBuilder();
        builder.append("Details about Item: ").append(position);
        for (int i = 0; i < position; i++) {
            builder.append("\nMore details information here.");
        }
        return new Note("Item " + position, builder.toString());
    }
}

This shockingly does the same thing as the old version; it’s just creating Note objects instead of DummyItem objects. The next task in the list is to update the UI to use the accessors for the Note object. Let’s start with the list, which is defined in the NoteListActivity.java class. The majority of the work about the list is in the RecyclerView. A RecyclerView is a more flexible kind of ListView, which is the one I learnt when I was first starting Android development. It uses a ViewHolder to hold the UI elements of each list item, all in the name of increasing UI performance. To assist in structuring my classes, I removed the ViewHolder from the NoteListActivity and RecyclerView and placed it in its own file data/NoteViewHolder.java:

package com.shellmonger.notes.data;

import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.widget.TextView;

import com.shellmonger.notes.R;

public class NoteViewHolder extends RecyclerView.ViewHolder {
    private final View mView;        // The containing view

    // The components for this view
    private final TextView mIdView;
    private final TextView mContentView;

    // The data it is linked to
    private Note mItem;

    public NoteViewHolder(View view) {
        super(view);
        mView = view;

        // Get the view components
        mIdView = (TextView) view.findViewById(R.id.list_id);
        mContentView = (TextView) view.findViewById(R.id.list_title);
    }

    @Override
    public String toString() {
        return super.toString() + " '" + mContentView.getText() + "'";
    }

    public View getView() {
        return mView;
    }

    public Note getNote() {
        return mItem;
    }

    public void setNote(Note note) {
        mItem = note;
        mIdView.setText(note.getNoteId());
        mContentView.setText(note.getTitle());
    }
}

The RecyclerView in the NoteListActivity.java now is slightly simpler thanks to the encapsulation I’ve just done:

    public class SimpleItemRecyclerViewAdapter extends RecyclerView.Adapter<NoteViewHolder> {

        private final List<Note> mValues;

        public SimpleItemRecyclerViewAdapter(List<Note> items) {
            mValues = items;
        }

        @Override
        public NoteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
            View view = LayoutInflater.from(parent.getContext())
                    .inflate(R.layout.note_list_content, parent, false);
            return new NoteViewHolder(view);
        }

        @Override
        public void onBindViewHolder(final NoteViewHolder holder, int position) {
            holder.setNote(mValues.get(position));
            holder.getView().setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (mTwoPane) {
                        Bundle arguments = new Bundle();
                        arguments.putString(NoteDetailFragment.ARG_ITEM_ID, holder.getNote().getNoteId());
                        NoteDetailFragment fragment = new NoteDetailFragment();
                        fragment.setArguments(arguments);
                        getSupportFragmentManager()
                                .beginTransaction()
                                .replace(R.id.note_detail_container, fragment)
                                .commit();
                    } else {
                        Context context = v.getContext();
                        Intent intent = new Intent(context, NoteDetailActivity.class);
                        intent.putExtra(NoteDetailFragment.ARG_ITEM_ID, holder.getNote().getNoteId());
                        context.startActivity(intent);
                    }
                }
            });
        }

        @Override
        public int getItemCount() {
            return mValues.size();
        }
    }

Let’s look at the other classes for activities. The NoteDetailActivity.java instantiates the NoteDetailFragment.java and adds that to the UI. As a result, I don’t need to do anything to the NoteDetailActivity.java file for the update. All the detail processing is in the NoteDetailFragment.java file. As to the details, I’m going to initially set the title to be the title of the note and the contents to be the contents of the note:

package com.shellmonger.notes;

import android.app.Activity;
import android.support.design.widget.CollapsingToolbarLayout;
import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import com.shellmonger.notes.data.Note;
import com.shellmonger.notes.dummy.DummyContent;

/**
 * A fragment representing a single Note detail screen.
 * This fragment is either contained in a {@link NoteListActivity}
 * in two-pane mode (on tablets) or a {@link NoteDetailActivity}
 * on handsets.
 */
public class NoteDetailFragment extends Fragment {
    /**
     * The fragment argument representing the item ID that this fragment
     * represents.
     */
    public static final String ARG_ITEM_ID = "item_id";

    /**
     * The dummy content this fragment is presenting.
     */
    private Note mItem;

    /**
     * Mandatory empty constructor for the fragment manager to instantiate the
     * fragment (e.g. upon screen orientation changes).
     */
    public NoteDetailFragment() {
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (getArguments().containsKey(ARG_ITEM_ID)) {
            // Load the dummy content specified by the fragment
            // arguments. In a real-world scenario, use a Loader
            // to load content from a content provider.
            mItem = DummyContent.ITEM_MAP.get(getArguments().getString(ARG_ITEM_ID));

            Activity activity = this.getActivity();
            CollapsingToolbarLayout appBarLayout = (CollapsingToolbarLayout) activity.findViewById(R.id.toolbar_layout);
            if (appBarLayout != null) {
                appBarLayout.setTitle(mItem.getTitle());
            }
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View rootView = inflater.inflate(R.layout.note_detail, container, false);

        // Show the dummy content as text in a TextView.
        if (mItem != null) {
            ((TextView) rootView.findViewById(R.id.note_detail)).setText(mItem.getContent());
        }

        return rootView;
    }
}

If I run this version, then the list has become a problem. The ID is now a GUID instead of an integer. I want a GUID because I intend the notes to be transferable to other devices, so the ID must uniquely identify the note. A quick redesign of the note_list_content.xml layout is required for this. Here is what I came up with:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="5dp"
    app:cardCornerRadius="4dp">

    <android.support.constraint.ConstraintLayout
        android:layout_height="match_parent"
        android:layout_width="match_parent"
        tools:layout_editor_absoluteY="0dp"
        tools:layout_editor_absoluteX="0dp">

        <TextView
            android:id="@+id/list_id"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:text="9c39a5c2-14d8-4a7d-96fa-f19da5f958da"
            android:textAppearance="@style/TextAppearance.AppCompat.Small"
            android:textColor="@android:color/darker_gray"
            android:textSize="12sp"
            app:layout_constraintLeft_toLeftOf="parent"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/list_title" />

        <TextView
            android:id="@+id/list_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="-25dp"
            android:text="Sample Title"
            android:textAppearance="@android:style/TextAppearance.Material.Title"
            app:layout_constraintTop_toBottomOf="@+id/textView"
            android:layout_marginLeft="8dp"
            app:layout_constraintLeft_toLeftOf="parent" />
    </android.support.constraint.ConstraintLayout>
</android.support.v7.widget.CardView>

Of course, I produced this with the designer because I cannot produce ConstraintLayout definitions on my own, even if they do produce easier, flatter layouts. Note the id fields in this case match what is in the NoteViewHolder from earlier. Now the list is a reasonable layout – the ID is in light gray and non-obtrusive and the title is the important part; just like a real note taking app.

There is just a little more I need to do to the UI:

  • The Floating Action Button on the detail page needs to go – I don’t have any use for it.
  • There needs to be an add button instead of an email button on the list page.

The Floating Action Button on the detail page is in activity_note_detail.xml and can be easily removed. Remove the linkage in the NoteDetailActivity.java and NoteListActivity.java as well though.

The add button on the list page is defined in the activity_note_list.xml file. To create an add button, I did the following:

  • Right-click the res folder.
  • Select New > Vector Asset.
  • Click the Icon.
  • Enter add in the search bar, then click on add.
  • Click Next.
  • Click *Finish**.
  • Open res/drawable/ic_add_black_24dp.xml.
  • Change the android:fillColor to “@android:color/darker_gray”.

Once that is done, I changed the FAB in activity_note_list.xml to the following:

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/addNoteButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="@dimen/fab_margin"
        app:srcCompat="@drawable/ic_add_black_24dp" />

Again, don’t forget to remove the handler code from the NoteListActivity.java class.

Wrap Up

This is just the first part of my quest towards the perfect notes app and I am liking how my UI has turned out. Of course, it can always be better and I will likely tune it over the next couple of days. In the next post, I’ll discuss how to appropriately store data on the local device with a ContentProvider and the Loader interface. Until then, you can find the code on my GitHub repository.