Android Programming: The Big Nerd Ranch Guide (2015)

Chapter 12. Dialogs

Dialogs demand attention and input from the user. They are useful for presenting a choice or important information. In this chapter, you will add a dialog in which users can change the date of a crime. Pressing the date button in CrimeFragment will present this dialog on Lollipop (Figure 12.1).

Figure 12.1  A dialog for picking the date of a crime

A dialog for picking the date of a crime

The dialog in Figure 12.1 is an instance of AlertDialog, a subclass of DialogAlertDialog is the all-purpose Dialog subclass that you will use most often.

When Lollipop was released, dialogs were given a visual makeover. AlertDialogs on Lollipop automatically use this new style. On earlier versions of Android, AlertDialog will fall back to the older style as seen on the left in Figure 12.2.

Figure 12.2  Old vs new

Old vs new

Rather than displaying the crusty old dialog style, it would be nice to always show the new dialog style, no matter which version of Android the user’s device is on. You can do this with the AppCompat library.

The AppCompat library is a compatibility library provided by Google that back ports some features of recent versions of Android to older devices. In this chapter, you will use the AppCompat library to create a consistent dialog experience on all of your supported versions of Android. InChapter 13 and Chapter 20, you will use some of the other features of the AppCompat library.

The AppCompat Library

To use the AppCompat library, you must first add it as a dependency. Depending on how your project was created, you may already have the AppCompat dependency.

Open the Project Structure window (File → Project Structure...), then select the app module and click on the Dependencies tab. If you do not see the AppCompat library listed, add it by clicking the + button and selecting the appcompat-v7 dependency from the list, as shown in Figure 12.3.

Figure 12.3  Selecting the AppCompat dependency

Selecting the AppCompat dependency

The AppCompat library includes its own AlertDialog class that you will use. This version of AlertDialog is very similar to the one included in the Android OS. The trick to using the right one is to make sure that you import the correct version of AlertDialog. You will useandroid.support.v7.app.AlertDialog.

Creating a DialogFragment

When using an AlertDialog, it is a good idea to wrap it in an instance of DialogFragment, a subclass of Fragment. It is possible to display an AlertDialog without a DialogFragment, but it is not recommended. Having the dialog managed by the FragmentManager gives you more options for presenting the dialog.

In addition, a bare AlertDialog will vanish if the device is rotated. On the other hand, if the AlertDialog is wrapped in a fragment, then the dialog will be re-created and put on screen after rotation.

For CriminalIntent, you are going to create a DialogFragment subclass named DatePickerFragment. Within DatePickerFragment, you will create and configure an instance of AlertDialog that displays a DatePicker widget. DatePickerFragment will be hosted by CrimePagerActivity.

Figure 12.4 shows you an overview of these relationships.

Figure 12.4  Object diagram for two fragments hosted by CrimePagerActivity

Object diagram for two fragments hosted by CrimePagerActivity

Your first tasks are:

·               creating the DatePickerFragment class

·               building an AlertDialog

·               getting the dialog on screen via the FragmentManager

Later in the chapter, you will wire up the DatePicker and pass the necessary data between CrimeFragment and DatePickerFragment.

Before you get started, add the string resource shown in Listing 12.1.

Listing 12.1  Adding string for dialog title (values/strings.xml)

<resources>

  ...

  <string name="crime_solved_label">Solved</string>

  <string name="date_picker_title">Date of crime:</string>

</resources>

Create a new class named DatePickerFragment and make its superclass DialogFragment. Be sure to choose the support library’s version of DialogFragmentandroid.support.v4.app.DialogFragment.

DialogFragment includes the following method:

    public Dialog onCreateDialog(Bundle savedInstanceState)

The FragmentManager of the hosting activity calls this method as part of putting the DialogFragment on screen.

In DatePickerFragment.java, add an implementation of onCreateDialog(…) that builds an AlertDialog with a title and one OK button. (You will add the DatePicker widget later.)

Be sure that the version of AlertDialog that you import is the AppCompat version: android.support.v7.app.AlertDialog.

Listing 12.2  Creating a DialogFragment (DatePickerFragment.java)

public class DatePickerFragment extends DialogFragment {

    @Override

    public Dialog onCreateDialog(Bundle savedInstanceState) {

        return new AlertDialog.Builder(getActivity())

            .setTitle(R.string.date_picker_title)

            .setPositiveButton(android.R.string.ok, null)

            .create();

    }

}

In this implementation, you use the AlertDialog.Builder class that provides a fluent interface for constructing an AlertDialog instance.

First, you pass a Context into the AlertDialog.Builder constructor, which returns an instance of AlertDialog.Builder.

Next, you call two AlertDialog.Builder methods to configure your dialog:

    public AlertDialog.Builder setTitle(int titleId)

    public AlertDialog.Builder setPositiveButton(int textId,

        DialogInterface.OnClickListener listener)

This setPositiveButton(…) method accepts a string resource and an object that implements DialogInterface.OnClickListener. In Listing 12.2, you pass in an Android constant for OK and null for the listener parameter. You will implement a listener later in the chapter.

(A positive button is what the user should press to accept what the dialog presents or to take the dialog’s primary action. There are two other buttons that you can add to an AlertDialog: a negative button and a neutral button. These designations determine the positions of the buttons in the dialog.)

Finally, you finish building the dialog with a call to AlertDialog.Builder.create(), which returns the configured AlertDialog instance.

There is more that you can do with AlertDialog and AlertDialog.Builder, and the details are well covered in the developer documentation. For now, let’s move on to the mechanics of getting your dialog on screen.

Showing a DialogFragment

Like all fragments, instances of DialogFragment are managed by the FragmentManager of the hosting activity.

To get a DialogFragment added to the FragmentManager and put on screen, you can call the following methods on the fragment instance:

    public void show(FragmentManager manager, String tag)

    public void show(FragmentTransaction transaction, String tag)

The string parameter uniquely identifies the DialogFragment in the FragmentManager’s list. Whether you use the FragmentManager or FragmentTransaction version is up to you. If you pass in a FragmentTransaction, you are responsible for creating and committing that transaction. If you pass in aFragmentManager, a transaction will automatically be created and committed for you.

Here, you will pass in a FragmentManager.

In CrimeFragment, add a constant for the DatePickerFragment’s tag. Then, in onCreateView(…), remove the code that disables the date button and set a View.OnClickListener that shows a DatePickerFragment when the date button is pressed.

Listing 12.3  Showing your DialogFragment (CrimeFragment.java)

public class CrimeFragment extends Fragment {

    private static final String ARG_CRIME_ID = "crime_id";

    private static final String DIALOG_DATE = "DialogDate";

    ...

    @Override

    public View onCreateView(LayoutInflater inflater, ViewGroup container,

        Bundle savedInstanceState) {

        ...

        mDateButton = (Button) v.findViewById(R.id.crime_date);

        mDateButton.setText(mCrime.getDate().toString());

        mDateButton.setEnabled(false);

        mDateButton.setOnClickListener(new View.OnClickListener() {

            @Override

            public void onClick(View v) {

                FragmentManager manager = getFragmentManager();

                DatePickerFragment dialog = new DatePickerFragment();

                dialog.show(manager, DIALOG_DATE);

            }

        });

        mSolvedCheckBox = (CheckBox) v.findViewById(R.id.crime_solved);

        ...

        return v;

    }

    ...

}

Run CriminalIntent and press the date button to see the dialog (Figure 12.5).

Figure 12.5  An AlertDialog with a title and a button

An AlertDialog with a title and a button

Setting a dialog’s contents

Next, you are going to add a DatePicker widget to your AlertDialog using the following AlertDialog.Builder method:

    public AlertDialog.Builder setView(View view)

This method configures the dialog to display the passed-in View object between the dialog’s title and its button(s).

In the Project tool window, create a new layout resource file named dialog_date.xml and make its root element DatePicker. This layout will consist of a single View object – a DatePicker – that you will inflate and pass into setView(…).

Configure the DatePicker as shown in Figure 12.6.

Figure 12.6  DatePicker layout (layout/dialog_date.xml)

DatePicker layout (layout/dialog_date.xml)

In DatePickerFragment.onCreateDialog(…), inflate this view and then set it on the dialog.

Listing 12.4  Adding DatePicker to AlertDialog (DatePickerFragment.java)

@Override

public Dialog onCreateDialog(Bundle savedInstanceState) {

    View v = LayoutInflater.from(getActivity())

        .inflate(R.layout.dialog_date, null);

    return new AlertDialog.Builder(getActivity())

        .setView(v)

        .setTitle(R.string.date_picker_title)

        .setPositiveButton(android.R.string.ok, null)

        .create();

}

Run CriminalIntent. Press the date button to confirm that the dialog now presents a DatePicker. If you are running Lollipop, you will see a calendar picker (Figure 12.7).

Figure 12.7  Lollipop DatePicker

Lollipop DatePicker

The calendar picker in Figure 12.7 was introduced along with Material design. This version of the DatePicker widget ignores the calendarViewShown attribute you set in your layout. If you are running a previous version of Android, however, you will see the old spinner-based DatePickerversion which respects that attribute (Figure 12.8).

Figure 12.8  An AlertDialog with a DatePicker

An AlertDialog with a DatePicker

Either version works fine. The newer one sure is pretty, though.

You may be wondering why you went to the trouble of defining and inflating a layout when you could have created the DatePicker object in code, like this:

@Override

public Dialog onCreateDialog(Bundle savedInstanceState) {

    DatePicker datePicker = new DatePicker(getActivity());

    return new AlertDialog.Builder(getActivity())

        .setView(datePicker)

        ...

        .create();

}

Using a layout makes modifications easy if you change your mind about what the dialog should present. For instance, what if you wanted a TimePicker next to the DatePicker in this dialog? If you are already inflating a layout, you can simply update the layout file, and the new view will appear.

Also, notice that the selected date in the DatePicker is automatically preserved across rotation. (With the dialog open, select a date other than the default and press Fn+Control+F12/Ctrl+F1 to see this in action.) How does this happen? Remember that Views can save state across configuration changes, but only if they have an ID attribute. When you created the DatePicker in dialog_date.xml you also asked the build tools to generate a unique ID value for that DatePicker.

If you created the DatePicker in code, you would have to programmatically set an ID on the DatePicker for its state saving to work.

Your dialog is on screen and looks good. In the next section, you will wire it up to present the Crime’s date and allow the user to change it.

Passing Data Between Two Fragments

You have passed data between two activities, and you have passed data between two fragment-based activities. Now you need to pass data between two fragments that are hosted by the same activity – CrimeFragment and DatePickerFragment (Figure 12.9).

Figure 12.9  Conversation between CrimeFragment and DatePickerFragment

Conversation between CrimeFragment and DatePickerFragment

To get the Crime’s date to DatePickerFragment, you are going to write a newInstance(Date) method and make the Date an argument on the fragment.

To get the new date back to the CrimeFragment so that it can update the model layer and its own view, you will package up the date as an extra on an Intent and pass this Intent in a call to CrimeFragment.onActivityResult(…), as shown in Figure 12.10.

Figure 12.10  Sequence of events between CrimeFragment and DatePickerFragment

Sequence of events between CrimeFragment and DatePickerFragment

It may seem strange to call Fragment.onActivityResult(…), given that the hosting activity receives no call to Activity.onActivityResult(…) in this interaction. However, using onActivityResult(…) to pass data back from one fragment to another not only works, but it also offers some flexibility in how you present a dialog fragment, as you will see later in the chapter.

Passing data to DatePickerFragment

To get data into your DatePickerFragment, you are going to stash the date in DatePickerFragment’s arguments bundle, where the DatePickerFragment can access it.

Creating and setting fragment arguments is typically done in a newInstance() method that replaces the fragment constructor. In DatePickerFragment.java, add a newInstance(Date) method.

Listing 12.5  Adding a newInstance(Date) method (DatePickerFragment.java)

public class DatePickerFragment extends DialogFragment {

    private static final String ARG_DATE = "date";

    private DatePicker mDatePicker;

    public static DatePickerFragment newInstance(Date date) {

        Bundle args = new Bundle();

        args.putSerializable(ARG_DATE, date);

        DatePickerFragment fragment = new DatePickerFragment();

        fragment.setArguments(args);

        return fragment;

    }

    ...

}

In CrimeFragment, remove the call to the DatePickerFragment constructor and replace it with a call to DatePickerFragment.newInstance(Date).

Listing 12.6  Adding call to newInstance() (CrimeFragment.java)

@Override

public View onCreateView(LayoutInflater inflater,

        ViewGroup parent, Bundle savedInstanceState) {

    ...

    mDateButton = (Button)v.findViewById(R.id.crime_date);

    mDateButton.setOnClickListener(new View.OnClickListener() {

        public void onClick(View v) {

            FragmentManager manager = getActivity()

                    .getSupportFragmentManager();

            DatePickerFragment dialog = new DatePickerFragment();

            DatePickerFragment dialog = DatePickerFragment

                .newInstance(mCrime.getDate());

            dialog.show(manager, DIALOG_DATE);

        }

    });

    return v;

}

DatePickerFragment needs to initialize the DatePicker using the information held in the Date. However, initializing the DatePicker requires integers for the month, day, and year. Date is more of a timestamp and cannot provide integers like this directly.

To get the integers you need, you must create a Calendar object and use the Date to configure the Calendar. Then you can retrieve the required information from the Calendar.

In onCreateDialog(…), get the Date from the arguments and use it and a Calendar to initialize the DatePicker.

Listing 12.7  Extracting the date and initializing DatePicker (DatePickerFragment.java)

@Override

public Dialog onCreateDialog(Bundle savedInstanceState) {

    Date date = (Date) getArguments().getSerializable(ARG_DATE);

    Calendar calendar = Calendar.getInstance();

    calendar.setTime(date);

    int year = calendar.get(Calendar.YEAR);

    int month = calendar.get(Calendar.MONTH);

    int day = calendar.get(Calendar.DAY_OF_MONTH);

    View v = LayoutInflater.from(getActivity())

            .inflate(R.layout.dialog_date, null);

    mDatePicker = (DatePicker) v.findViewById(R.id.dialog_date_date_picker);

    mDatePicker.init(year, month, day, null);

    return new AlertDialog.Builder(getActivity())

            .setView(v)

            .setTitle(R.string.date_picker_title)

            .setPositiveButton(android.R.string.ok, null)

            .create();

}

Now CrimeFragment is successfully telling DatePickerFragment what date to show. You can run CriminalIntent and make sure that everything works as before.

Returning data to CrimeFragment

To have CrimeFragment receive the date back from DatePickerFragment, you need a way to keep track of the relationship between the two fragments.

With activities, you call startActivityForResult(…), and the ActivityManager keeps track of the parent-child activity relationship. When the child activity dies, the ActivityManager knows which activity should receive the result.

Setting a target fragment

You can create a similar connection by making CrimeFragment the target fragment of DatePickerFragment. This connection is automatically reestablished after both CrimeFragment and DatePickerFragment are destroyed and re-created by the OS. To create this relationship, you call the followingFragment method:

    public void setTargetFragment(Fragment fragment, int requestCode)

This method accepts the fragment that will be the target and a request code just like the one you send in startActivityForResult(…). The target fragment can use the request code later to identify which fragment is reporting back.

The FragmentManager keeps track of the target fragment and request code. You can retrieve them by calling getTargetFragment() and getTargetRequestCode() on the fragment that has set the target.

In CrimeFragment.java, create a constant for the request code and then make CrimeFragment the target fragment of the DatePickerFragment instance.

Listing 12.8  Setting target fragment (CrimeFragment.java)

public class CrimeFragment extends Fragment {

    private static final String ARG_CRIME_ID = "crime_id";

    private static final String DIALOG_DATE = "DialogDate";

    private static final int REQUEST_DATE = 0;

    ...

    @Override

    public View onCreateView(LayoutInflater inflater, ViewGroup parent,

            Bundle savedInstanceState) {

        ...

        mDateButton.setOnClickListener(new View.OnClickListener() {

            public void onClick(View v) {

                FragmentManager manager = getFragmentManager();

                DatePickerFragment dialog = DatePickerFragment

                        .newInstance(mCrime.getDate());

                dialog.setTargetFragment(CrimeFragment.this, REQUEST_DATE);

                dialog.show(manager, DIALOG_DATE);

            }

        });

        return v;

    }

    ...

}

Sending data to the target fragment

Now that you have a connection between CrimeFragment and DatePickerFragment, you need to send the date back to CrimeFragment. You are going to put the date on an Intent as an extra.

What method will you use to send this intent to the target fragment? Oddly enough, you will have DatePickerFragment pass it into CrimeFragment.onActivityResult(int, int, Intent).

Activity.onActivityResult(…) is the method that the ActivityManager calls on the parent activity after the child activity dies. When dealing with activities, you do not call Activity.onActivityResult(…) yourself; that is the ActivityManager’s job. After the activity has received the call, the activity’sFragmentManager then calls Fragment.onActivityResult(…) on the appropriate fragment.

When dealing with two fragments hosted by the same activity, you can borrow Fragment.onActivityResult(…) and call it directly on the target fragment to pass back data. It has exactly what you need:

·               a request code that matches the code passed into setTargetFragment(…) to tell the target what is returning the result

·               a result code to determine what action to take

·               an Intent that can have extra data

In DatePickerFragment, create a private method that creates an intent, puts the date on it as an extra, and then calls CrimeFragment.onActivityResult(…).

Listing 12.9  Calling back to your target (DatePickerFragment.java)

public class DatePickerFragment extends DialogFragment {

    public static final String EXTRA_DATE =

            "com.bignerdranch.android.criminalintent.date";

    private static final String ARG_DATE = "date";

    ...

    @Override

    public Dialog onCreateDialog(Bundle savedInstanceState) {

        ...

    }

    private void sendResult(int resultCode, Date date) {

        if (getTargetFragment() == null) {

            return;

        }

        Intent intent = new Intent();

        intent.putExtra(EXTRA_DATE, date);

        getTargetFragment()

                .onActivityResult(getTargetRequestCode(), resultCode, intent);

    }

}

Now it is time to make use of this new sendResult method. When the user presses the positive button in the dialog, you want to retrieve the date from the DatePicker and send the result back to CrimeFragment. In onCreateDialog(…), replace the null parameter of setPositiveButton(…) with an implementation of DialogInterface.OnClickListener that retrieves the selected date and calls sendResult.

Listing 12.10  Are you OK? (DatePickerFragment.java)

@Override

public Dialog onCreateDialog(Bundle savedInstanceState) {

    ...

    return new AlertDialog.Builder(getActivity())

        .setView(v)

        .setTitle(R.string.date_picker_title)

        .setPositiveButton(android.R.string.ok, null);

        .setPositiveButton(android.R.string.ok,

                new DialogInterface.OnClickListener() {

                    @Override

                    public void onClick(DialogInterface dialog, int which) {

                        int year = mDatePicker.getYear();

                        int month = mDatePicker.getMonth();

                        int day = mDatePicker.getDayOfMonth();

                        Date date = new GregorianCalendar(year, month, day).getTime();

                        sendResult(Activity.RESULT_OK, date);

                    }

        })

        .create();

}

In CrimeFragment, override onActivityResult(…) to retrieve the extra, set the date on the Crime, and refresh the text of the date button.

Listing 12.11  Responding to the dialog (CrimeFragment.java)

public class CrimeFragment extends Fragment {

    ...

    @Override

    public View onCreateView(LayoutInflater inflater, ViewGroup container,

                             Bundle savedInstanceState) {

        ...

    }

    @Override

    public void onActivityResult(int requestCode, int resultCode, Intent data) {

        if (resultCode != Activity.RESULT_OK) {

            return;

        }

        if (requestCode == REQUEST_DATE) {

            Date date = (Date) data

                    .getSerializableExtra(DatePickerFragment.EXTRA_DATE);

            mCrime.setDate(date);

            mDateButton.setText(mCrime.getDate().toString());

        }

    }

}

The code that sets the button’s text is identical to code you call in onCreateView(…). To avoid setting the text in two places, encapsulate this code in a private updateDate() method and then call it in onCreateView(…) and onActivityResult(…).

You could do this by hand or you can have Android Studio do it for you. Highlight the entire line of code that sets mDateButton’s text. Right-click and select Refactor → Extract → Method... (Figure 12.11).

Figure 12.11  Extracting a method with Android Studio

Extracting a method with Android Studio

Make the method private and name it updateDate. Click OK and Android Studio will tell you that it has found one other place where this line of code was used. Click Yes to allow Android Studio to update the other reference, then verify that your code is now extracted to a single updateDatemethod as shown in Listing 12.12.

Listing 12.12  Cleaning up with updateDate() (CrimeFragment.java)

public class CrimeFragment extends Fragment {

    ...

    @Override

    public View onCreateView(LayoutInflater inflater, ViewGroup container,

                             Bundle savedInstanceState) {

        View v = inflater.inflate(R.layout.fragment_crime, container, false);

        ...

        mDateButton = (Button) v.findViewById(R.id.crime_date);

        updateDate();

        ...

    }

    @Override

    public void onActivityResult(int requestCode, int resultCode, Intent data) {

        if (resultCode != Activity.RESULT_OK) {

            return;

        }

        if (requestCode == REQUEST_DATE) {

            Date date = (Date) data

                    .getSerializableExtra(DatePickerFragment.EXTRA_DATE);

            mCrime.setDate(date);

            updateDate();

        }

    }

    private void updateDate() {

        mDateButton.setText(mCrime.getDate().toString());

    }

}

Now the circle is complete. The dates must flow. He who controls the dates controls time itself. Run CriminalIntent to ensure that you can, in fact, control the dates. Change the date of a Crime and confirm that the new date appears in CrimeFragment’s view. Then return to the list of crimes and check the Crime’s date to ensure that the model layer was updated.

More flexibility in presenting a DialogFragment

Using onActivityResult(…) to send data back to a target fragment is especially nice when you are writing an app that needs lots of input from the user and more room to ask for it – and you want the app working well on phones and tablets.

On a phone, you do not have much screen real estate, so you would likely use an activity with a full-screen fragment to ask the user for input. This child activity would be started by a fragment of the parent activity calling startActivityForResult(…). On the death of the child activity, the parent activity would receive a call to onActivityResult(…), which would be forwarded to the fragment that started the child activity (Figure 12.12).

Figure 12.12  Inter-activity communication on phones

Inter-activity communication on phones

On a tablet, where you have plenty of room, it is often better to present a DialogFragment to the user to get the same input. In this case, you set the target fragment and call show(…) on the dialog fragment. When dismissed, the dialog fragment calls onActivityResult(…) on its target (Figure 12.13).

Figure 12.13  Inter-fragment communication on tablets

Inter-fragment communication on tablets

The fragment’s onActivityResult(…) will always be called, whether the fragment started an activity or showed a dialog. So you can use the same code for different presentations.

When setting things up to use the same code for a full-screen fragment or a dialog fragment, you can override DialogFragment.onCreateView(…) instead of onCreateDialog(…) to prepare for both presentations.

Challenge: More Dialogs

Write another dialog fragment named TimePickerFragment that allows the user to select what time of day the crime occurred using a TimePicker widget. Add another button to CrimeFragment that will display a TimePickerFragment.

Challenge: A Responsive DialogFragment

For a more involved challenge, modify the presentation of the DatePickerFragment.

The first stage of this challenge is to supply the DatePickerFragment’s view by overriding onCreateView instead of onCreateDialog. When setting up a DialogFragment in this way, your dialog will not be presented with the built-in title area and button area on the top and bottom of the dialog. You will need to create your own OK button in dialog_date.xml.

Once DatePickerFragment’s view is created in onCreateView, you can present DatePickerFragment as a dialog or embedded in an activity. For the second stage of this challenge, create a new subclass of SingleFragmentActivity and host DatePickerFragment in that activity.

When presenting DatePickerFragment in this way, you will use the startActivityForResult mechanism to pass the date back to CrimeFragment. In DatePickerFragment, if the target fragment does not exist, use the setResult(int, intent) method on the hosting activity to send the date back to the fragment.

For the final step of this challenge, modify CriminalIntent to present the DatePickerFragment as a full-screen activity when running on a phone. When running on a tablet, present the DatePickerFragment as a dialog. You may need to read ahead in Chapter 17 for details on how to optimize your app for multiple screen sizes.