10.
Completing the Detail View
Written by Darryl Bayliss
In the last chapter, you set up a new Activity to display the contents of a list. At the moment, that Activity is empty.
In this chapter, you’ll add to that Activity using familiar components such as a RecyclerView to display the list, and a FloatingActionButton to add tasks to the list. You’ll also learn how to communicate back to the previous Activity using an Intent.
Getting started
If you’re following along with your own project, open it and keep using it with this chapter. If not, don’t worry. Locate the projects folder for this chapter and open the Listmaker app inside the starter folder.
The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.
Open ListDetailActivity.kt and review its contents.
Currently, you pass in a list from MainActivity.kt via an Intent and set the title of the Activity to the name of the list. That’s good, but this Activity needs to do more. For starters, it needs to let a user view all of the items in the list, as well as add new items.
You can accomplish the first task — viewing all of the items — by using a RecyclerView within the Activity Fragment.
Open list_detail_fragment.xml from the res/layout folder, and show the Design view in the Layout window if it’s not already selected.
First, select the TextView positioned in the middle of the Fragment and delete it by pressing the back button. In the Palette window, select the Common option from the left-hand list. You’ll see the RecyclerView available for selection in the right-hand list.
Click and drag the RecyclerView to the whitespace in the Layout shown on the right of the Layout Window.
With the RecyclerView added, you need to give it an ID and some dimensions. In the Attributes window, change the ID of the RecyclerView to list_items_recyclerview.
Next, update the layout_width and layout_height to 0dp match_constraint. This ensures the RecyclerView adheres to the constraints you’re about to set, and that it takes up the entire screen.
In the Constraint Widget, click the four + buttons around the square to add constraints to the RecyclerView. Change the margins for each constraint to 0.
With the RecyclerView set up in the layout, it’s time to use it in your code.
Coding the RecyclerView
Open ListDetailFragment.kt. At the top of the class, add a property to hold a reference to the ViewBinding for the Fragment:
lateinit var binding: ListDetailFragmentBinding
In onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?), replace the inflate call and create the ViewBinding for the Fragment:
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
// 1
binding = ListDetailFragmentBinding.inflate(inflater, container, false)
// 2
return binding.root
}
As a recap, here’s how it works:
- Android Studio generates a binding class for you. You use this to acquire the layout for the Fragment
- Using the binding, you return the root of the View for your Fragment.
You’re ready to create a new Adapter for the RecyclerView. In the Project navigator, right-click com.raywenderlich.listmaker.ui.detail.ui.detail.
In the popup that appears, navigate to New ▸ Kotlin File ▸ Class.
Name the class ListItemsRecyclerViewAdapter, and ensure Kind is set to Class. When you’re ready, press the return key.
Android Studio creates the new Kotlin class and opens it.
Before using the class, you need to make a few adjustments.
In ListDetailFragment.kt, you need to pass a list to the RecyclerView Adapter. Then, to use this list, the Adapter needs a constructor that accepts a TaskList.
You also need to make the class implement the RecyclerView.Adapter<ViewHolder> Interface, so the Adapter can create ViewHolders for the RecyclerView and reuse them.
Finally, you need to create a custom ViewHolder you can use to show the tasks in the list.
First, update the class definition to have a primary constructor that accepts a TaskList and have it conform to RecyclerView.Adapter<ListItemViewHolder>:
class ListItemsRecyclerViewAdapter(var list: TaskList) : RecyclerView.Adapter<ListItemViewHolder>() {
You’ll create the ListItemViewHolder shortly, so ignore the Unresolved reference here too.
Create another Kotlin class in the ui.detail folder. Set the name of the file to ListItemViewHolder, and set Kind to Class.
After Android Studio creates the class, update its definition so it has a primary constructor to pass in a binding for the ViewHolder. This will be generated once the layout for the ViewHolder is created. Also make it implement the RecyclerView.ViewHolder() interface:
class ListItemViewHolder(val binding: ListItemViewHolderBinding) : RecyclerView.ViewHolder(binding.root)
With the bare bones of the Adapter and ViewHolder set up, your next task is to instruct the Adapter how to work with the list of tasks.
Setting up the Adapter
Open ListItemsRecyclerViewAdapter.kt.
This Adapter has to implement the methods required by RecyclerView.Adapter so the RecyclerView knows how to present each task in the list.
To get started quickly, there’s a way to let Android Studio do most of the work for you. Click the class name (the part where the red squiggly line is) and press Option-Return (or Alt-Return if you’re not on a Mac).
In the popup that appears, you’ll see the first option highlighted is Implement Members. Press Return again, and Android Studio presents another window.
The window shows the methods you need to implement to conform to RecyclerView.Adapter, the Interface your class implemented. You need to implement all of these methods, so hold down Shift and click the bottom-most method.
This highlights all of the methods in blue, meaning you’ve selected all of the ones you want Android Studio to implement. To finish this set up, click OK.
With that, Android Studio automatically generates the chosen methods for you:
Next, you need to write the logic behind each method.
Begin with getItemCount(). This method tells the RecyclerView how many items to display. You want it to show all of the tasks in your list, so update the method so it returns the number of tasks it contains:
override fun getItemCount(): Int {
return list.tasks.size
}
Next, move onto creating the ViewHolder in onCreateViewHolder().
Because you haven’t created the Layout yet, you’ll add the code and then create the Layout. Update onCreateViewHolder() so it creates a View from the Layout using the binding for the ViewHolder:
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemViewHolder {
val binding = ListItemViewHolderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ListItemViewHolder(binding)
}
Don’t worry about the Unresolved reference for the binding. The binding will exist once the layout is created, you’ll do that next.
In the Project navigator, to the left of Android Studio, right-click layout inside res. Then, move the cursor to new, and click Layout Resource File.
The New Resource File window appears. Enter the file name as list_item_view_holder. Change the Root Element below the name from androidx.constraintlayout.widget.ConstraintLayout to LinearLayout.
This sets the Layout to use a LinearLayout. A LinearLayout allows you to stack Views in a vertical or horizontal direction. For simple Views like list_item_view_holder, a LinearLayout is easier to use than a ConstraintLayout.
Click OK, and let Android Studio create the new Layout.
You need to set the LinearLayout to be as tall as the content within it; Otherwise, every row will be set to the size of its entire parent - in this case, the RecyclerView, which takes up the whole screen!
Select LinearLayout in the Component Tree window. In the Attributes window, set the layout_height to wrap_content:
Now the layout is only as tall as whatever is inside of it. At this point, you might see its height shrink down to nothing in the Design tab as there’s nothing in it yet.
You can change this by adding the Widgets you want to use with the ViewHolder. In this case, you only need a TextView to hold a task in the list.
In the Palette window, click Common, and then drag a TextView into the LinearLayout via the Component Tree.
In the Attributes window, with the TextView selected, change the ID to textView_task, and set the layout_width and layout_height to wrap_content.
There’s one final tweak needed here, adding margin spacings so the TextView isn’t pushed to the edge of the ViewHolder.
To add margin spacings, you need to get to the larger list of attributes for the TextView. Scroll through the list of attributes until you find the layout_margin attribute.
Click the arrow next to layout_margin to expand the list. Then, in the layout_marginLeft and layout_marginTop text fields, enter 16dp.
Note: You’ll notice in addition to the left and right margins, there are also layout margins for “start” and “end”. These are used to ensure that your layout can handle both languages that read left to right, such as English, and those that read right to left, such as Arabic and Hebrew.
For now, to keep things simpler, we’ll use “right” and “left” instead of “start” and “end”, but this is a good thing to keep in mind if you’re potentially supporting any right-to-left languages.
With the Layout ready, give your app a run. Nothing will of changed but it’s good to know your code compiles. Next, it’s time to use the layout in your code.
Adding the ViewHolder
You now have a Layout for the ViewHolder, next, you need to hook up the data to the TextView using the ViewBinding.
Open ListItemsRecyclerViewAdapter.kt, and in onBindViewHolder(), bind the TextView to a specific task from the list depending on the position of the ViewHolder:
override fun onBindViewHolder(holder: ListItemViewHolder, position: Int) {
holder.binding.textViewTask.text = list.tasks[position]
}
Run the app on the emulator or a device and select one of the lists in the main Activity. It will run, but you won’t see much.
Currently, there’s no way to add tasks to the lists. That’s ok though, your next task is to add a button to add tasks.
Open list_detail_activity.xml. With the Design window open, navigate to the the palette, select Buttons and grab a FloatingActionButton.
Drag the FAB into the Layout using the Component Tree Window. Once the button is dropped into the layout, a window appears asking to select a resource for the action button.
In the Search bar, type add to filter the list of resources available. Click the ic_menu_add resource and click OK.
The button appears in the Layout, ready for you to position.
Select the button. Then, in the Attributes window, change the ID of the FAB to add_task_button. In the “All Attributes” view, scroll down to the layout_gravity section and make sure the layout_gravity for the bottom and right are added. Finally, set the layout_marginRight and layout_marginBottom to 10dp so the FAB has space from the screen.
While the layout is open, change the FrameLayout id from container to detail_container. This ensures the id of the layout doesn’t conflict with the FrameLayout in MainActivity. If two Views have the same id, ViewBinding is unable to work out which View to use.
With the button positioned correctly, your next task is to use the button to add tasks to your list. Open ListDetailActivity.kt, then add a new property to the top of the class to hold the reference for the ViewBinding:
lateinit var binding: ListDetailActivityBinding
At the top of onCreate(), remove the setContentView method call and inflate the layout using the generated binding. Assign it to the binding property. Then, set the content view with the root View. Finally, add a click listener to the button:
binding = ListDetailActivityBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
binding.addTaskButton.setOnClickListener {
showCreateTaskDialog()
}
In the click listener, you call a method to prompt the user for the task to add to the list. You’ll create that method shortly. First, though, you need to reference the ViewModel for your Activity. One was already created with ListDetailFragment, let’s reuse that.
In the class, above onCreate, add a field to store the ViewModel and a reference to ListDetailFragment:
lateinit var viewModel: ListDetailViewModel
lateinit var fragment: ListDetailFragment
Below the binding code, in onCreate(). Acquire the ViewModel for the scope of your Activity. Remember, the scope is the lifecycle of the ViewModel. In this case, the Activity. So long as the Activity remains, any data stored in the ViewModel will remain too. On the next line, set the TaskList of the ViewModel by passing it the TaskList from the intent.
viewModel = ViewModelProvider(this).get(ListDetailViewModel::class.java)
viewModel.list = intent.getParcelableExtra(MainActivity.INTENT_LIST_KEY)!!
With the viewModel managing the TaskList, the Activity no longer needs a TaskList property. Remove the TaskList property from the top of the class. Then, remove the assignment of the list from the passed in Intent.
list = intent.getParcelableExtra(MainActivity.INTENT_LIST_KEY)!!
Finally, update setting the Activity title by setting it using the list within the ViewModel:
title = viewModel.list.name
Don’t worry about the errors coming from viewModel.list for now, you’ll solve them in the next section. With the boilerplate done. It’s time to create the task and add it to the RecyclerView in the Fragment.
Passing the Task to the RecyclerView
Still in ListDetailActivity below onCreate(), add a new method. The methods purpose is to show a dialog to the user, asking for the task to add to the list:
private fun showCreateTaskDialog() {
//1
val taskEditText = EditText(this)
taskEditText.inputType = InputType.TYPE_CLASS_TEXT
//2
AlertDialog.Builder(this)
.setTitle(R.string.task_to_add)
.setView(taskEditText)
.setPositiveButton(R.string.add_task) { dialog, _ ->
// 3
val task = taskEditText.text.toString()
// 4
viewModel.addTask(task)
//5
dialog.dismiss()
}
//6
.create()
.show()
}
The code will look familiar to you. It’s similar to showCreateListDialog(), created in MainActivity.kt.
Here’s what’s happening:
- Create an EditText so you can receive text input from the user.
- Create an
AlertDialogBuilderand use method chaining to set up various aspects of theAlertDialog. Method chaining can happen when each method returns a value, which can then be used for the next method. Here, when any method is called on the Builder, it returns the builder instance, modified with whatever you just added. - In the Positive Button’s click listener, you access the EditText to grab the text input and create a task from the input.
- Still in the click listener, you notify the ViewModel a new item was added. This gives the ViewModel a chance to update the list and inform the Fragment to update the RecyclerView Adapter. You’ll update the ViewModel and Fragment to handle this soon.
- Once the ViewModel is aware, you close the dialog by dismissing it.
- Back outside the click listener, you continue to use method chaining to create and show the AlertDialog without needing to have the AlertDialogBuilder as a separate variable.
Android Studio will let you know some strings are missing, used for the title and positive button of the dialog. The ViewModel also needs to handle the list and update the RecyclerView Adapter. You’ll handle these now.
Open strings.xml and add the following new string elements between the resources tags:
<string name="task_to_add">What is the task you want to add?</string>
<string name="add_task">Add</string>
These are shown in the app when the user adds a new task.
Next, open ListDetailViewModel and add a lambda called onTaskAdded, this informs the Fragment when a new task is available. Then add a TaskList property into ListDetailViewModel.
class ListDetailViewModel() : ViewModel() {
lateinit var onTaskAdded: (() -> Unit)
lateinit var list: TaskList
}
Next, add a method into the ViewModel to add tasks to the list. The method also invokes the lambda added.
fun addTask(task: String) {
list.tasks.add(task)
onTaskAdded.invoke()
}
The next task is to setup the RecyclerView and notify ListDetailFragment about the added task. Open ListDetailFragment.kt and in onActivityCreated(savedInstanceState: Bundle?), remove the TODO and create a RecyclerAdapter and LinearLayoutManager. Assign them to listItemsRecyclerView through the view binding, then finally assign a callback to the lambda you added to the ViewModel.
val recyclerAdapter = ListItemsRecyclerViewAdapter(viewModel.list)
binding.listItemsRecyclerview.adapter = recyclerAdapter
binding.listItemsRecyclerview.layoutManager = LinearLayoutManager(requireContext())
viewModel.onTaskAdded = {
recyclerAdapter.notifyDataSetChanged()
}
Inside the lambda, you notify the adapter that the list of tasks has updated. This causes the RecyclerView to be redrawn, showing any new items.
It’s time to test your work! Run the app, create a list if you haven’t already, and tap on it to open the detail Activity. Then, tap the FAB to open the dialog. Enter a task of your choice.
The moment of truth. Tap the add button, the dialog will disappear and the task will be visible on your screen.
Your list can now have tasks added to it. You’re on the way to checking this chapter off! There one final thing to do, saving the tasks added to the list.
Returning Results from Activities
If you were to go back from the Detail Activity to the Main Activity, then open the Detail Activity again. Your newly added task would disappear! The reason for that is the scope of the ViewModel ends when the Activity disappears. Since the tasks aren’t being saved anywhere, they are lost.
You’ll solve that in this final section by passing the list back to the MainActivity, where you can save it to SharedPreferences.
Why not just save the list in the Detail Activity you might wonder? That would work, but it means you have two separate places in your app where data is saved. That gives you double the places where bugs could occur. To avoid that, passing the list back to the MainActivity and saving it there keeps things simple.
So how to pass back the list with its new tasks? One way to do this is by asking Activities to return values to other Activities, you’ll learn how to do that now.
Open MainActivity.kt and edit showListDetail() so it looks like this:
private fun showListDetail(list: TaskList) {
val listDetailIntent = Intent(this, ListDetailActivity::class.java)
listDetailIntent.putExtra(INTENT_LIST_KEY, list)
startActivityForResult(listDetailIntent, LIST_DETAIL_REQUEST_CODE)
}
The only change here is the final line. startActivity() has changed to startActivityForResult().
While this change seems small, the difference is important. This line starts the detail Activity, then MainActivity.kt will wait to hear back from ListDetailActivity.kt.
Think of it as asking someone to do something for you, then reporting back with the results when they’re finished. That’s what’s going on here: You want to hear back about that list you’re passing to ListDetailActivity.kt.
There’s also another parameter passed into startActivityForResult(). The second parameter is a request code that lets you know which result you’re dealing with.
Because you can deal with multiple Activities that pass back multiple results, having a unique way of identifying results is handy.
Add the request code in the companion object at the bottom of MainActivity.kt:
companion object {
const val INTENT_LIST_KEY = "list"
const val LIST_DETAIL_REQUEST_CODE = 123
}
Next, you need to handle the returned result. To do that, you need to override a new method in MainActivity named onActivityResult.
This method allows the Activity to receive the result of an Activity it starts. In this case, it looks for the result ListDetailActivity.kt provides once it finishes adding tasks to a list.
Add the following method to MainActivity.kt:
override fun onActivityResult(requestCode: Int, resultCode: Int, data:
Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// 1
if (requestCode == LIST_DETAIL_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
// 2
data?.let {
// 3
viewModel.updateList(data.getParcelableExtra(INTENT_LIST_KEY)!!)
viewModel.refreshLists()
}
}
}
Going through the method step-by-step:
-
You check the request code is the one you are expecting. You also make sure the
resultCodeisRESULT_OK. -
Once you know you’re dealing with the right request, you unwrap the data Intent passed in. It’s possible there isn’t any data at all here and it contains null.
-
Once you confirm there’s data, you save the list to MainViewModel using
updateList(list: TaskList)and then callrefreshLists(). You’ll create these two methods shortly.
Note: You may have noticed the
data?.letblock in the code snippet. The.letfunction is a shorthand method available in Kotlin. It allows you to only execute a block of code if the variable.letis used on is not null.This is what is meant by unwrapping the data Intent in the code snippet: You’re trying to unwrap the optional value to get at the actual value.
You can still use a null check like in Java, it’s all down to personal preference. All of this falls under the Null Safety paradigm of Kotlin, you can read more about it here: https://kotlinlang.org/docs/reference/null-safety.html.
Open MainViewModel.kt, then add the following methods:
fun updateList(list: TaskList) {
sharedPreferences.edit().putStringSet(list.name, list.tasks.toHashSet()).apply()
lists.add(list)
}
fun refreshLists() {
lists.clear()
lists.addAll(retrieveLists())
}
The first method, updateList(list: TaskList), writes the passed-in list to SharedPreferences. Any existing list with the same name will be overwritten. This is fine since the list passed back from the Detail Activity is the same list with added tasks.
The second method, refreshLists(), clears the list property of all values. Then, it adds the values from SharedPreferences by calling retrieveLists() and adding the return value to the list.
MainActivity is setup to receive the updated list. The last thing to do is pass the list back from ListDetailActivity. Open ListDetailActivity.kt, and at the bottom of the class, add a new override method named onBackPressed():
override fun onBackPressed() {
val bundle = Bundle()
bundle.putParcelable(MainActivity.INTENT_LIST_KEY, viewModel.list)
val intent = Intent()
intent.putExtras(bundle)
setResult(Activity.RESULT_OK, intent)
super.onBackPressed()
}
onBackPressed() gives you a chance to run code whenever the back button is tapped to get back to MainActivity. In this case, you literally bundle up the list in its current state, then put it into an Intent.
Finally, you set the result to RESULT_OK and pass in the Intent, informing the Activity that everything happened according to plan.
Time to test the app again. Click Run App at the top of Android Studio and select your device. Create a list if necessary, or select an existing list. Once inside the list, add a new task. Tap the back button, then tap into the list where you added a task, and you’ll see the newly added task. Well done!
Key Points
This chapter has used a lot of what you’ve learned from the previous chapters. It also introduced you to new concepts such as:
-
Passing values back from Activities using
startActivityForResult(). -
Hooking into the back button to run code.
Where to go from here?
In the next chapter, you’ll learn how to take your app and make it work on Android tablets, as well as on Android phones!