21.
Finding Podcasts
Written by Kevin D Moore
Now that the groundwork for searching iTunes is complete, you’re ready to build out an interface that allows users to search for podcasts. Your goal is to provide a search box at the top of the screen where users can enter a search term. You’ll use the ItunesRepo you created in the last chapter to fetch the list of matching podcasts. From there, you’ll display the results in a RecyclerView, including the podcast artwork.
Although you can create a simple search interface by adding a text view that responds to the entered text, and then populating a RecyclerView with the results, the Android SDK provides a built-in search feature that helps future-proof your apps.
Android search
If you’re following along with your own app, open it and keep using it with this chapter. If not, don’t worry. Locate the projects folder for this chapter and open the PodPlay 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.
Android’s search functionality provides part of the search interface. You can display it either as a search dialog at the top of an Activity or as a search widget, which you can then place within an Activity or on the action bar. The way it works is like this: Android handles the user input and then passes the search query to an Activity. This makes it easy to add search capability to any Activity within your app, while only using a single dedicated Activity to display the results.
Some benefits to using Android search include:
- Displaying suggestions based on previous queries.
- Displaying suggestions based on search data.
- Having the ability to search by voice.
- Adding search suggestions to the system-wide Quick Search Box.
When running on Android 3.0 or later, Google suggests that you use a search widget instead of a search dialog, which is what you’ll do in PodPlay. In other words, you’ll use the search widget and insert it as an action view in the app bar.
An action view is a standard feature of the toolbar, that allows for advanced functionality within the app bar. When you add a search widget as an action view, it displays a collapsible search view — located in the app bar — and handles all of the user input.
The following illustrates an active search widget, which gets activated when the user taps the search icon. It includes an EditText with some hint text and a back arrow that’s used to close the search.
To implement search capabilities, you need to:
- Create a search configuration XML file.
- Declare a searchable activity.
- Add an options menu.
- Set the searchable configuration in
onCreateOptionsMenu.
You’ll go through all these steps in the following sections.
Search configuration file
The first step is to create a search configuration file. This file lets you define some details about the search behavior. It may contain several attributes, such as:
-
label: This should match the name of your app. -
hint: A hint that displays in the search field before any text is entered. -
inputType: The type of data expected for the search field.
There are also multiple settings to control the search like suggestion behavior, voice search behavior, Quick Search box settings, and more. The label is the only required attribute. Because you’re implementing a basic search for PodPlay, you’ll only define the label and hint attributes.
Note: The Android developer site has extensive documentation on the more advanced search options at https://developer.android.com/guide/topics/search/searchable-config.html.
By convention, you need to name the search configuration file searchable.xml, and you must store it in res/xml.
To create this file in the proper location, right-click on the res folder in the project manager and select New ▸ Android resource file. Set the values in the dialog as follows:
- File name: searchable
- Resource type: XML
- Root element: searchable
- Source set: main
- Directory name: xml
Click OK. This creates the file and the xml resource directory. Now, replace the contents of searchable.xml with this:
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android=
"http://schemas.android.com/apk/res/android"
android:label="@string/app_name"
android:hint="@string/search_hint" >
</searchable>
This displays an error for the missing @string/search_hint resource. To fix this, open res/values/strings.xml and add the following line:
<string name="search_hint">Enter podcast search</string>
Searchable activity
The next step is to designate a searchable Activity. The search widget will start this Activity using an Intent that contains the user’s search term. It’s the Activity’s responsibility to take the search term, look it up and display the results to the user.
In some cases, you may want to have a separate Activity display the search results. However, PodPlay is going to use a single Activity for the entire app, and you’ll use Fragments to display different Views. This makes adding the searchable Activity straightforward — you’ll designate PodcastActivity as the searchable Activity.
The searchable Activity is set on the <activity> element in the manifest file. There are two things you need to do to set up a searchable Activity:
- Add an Intent filter for action Intent.ACTION_SEARCH. This is a static property in the
Intentclass and is defined with the value “android.intent.action.SEARCH”. The value is required in the manifest, but you’ll use Intent.ACTION_SEARCH in code. - Specify the searchable configuration file that you defined earlier using a meta-data element.
Open app/manifests/AndroidManifest.xml and update the PodcastActivity element to match this:
<activity android:name=".ui.PodcastActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<action android:name="android.intent.action.SEARCH"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data android:name="android.app.searchable"
android:resource="@xml/searchable"/>
</activity>
Adding the options menu
Since you’ll show the search widget as an action view in the app bar, you need to define an options menu with a single search button item. To do this, right-click on the res folder in the project manager, then select New ▸ Android Resource File.
Set the resource type to Menu, which automatically sets the root element type to menu and the folder to menu. Name the file menu_search:
Click OK, then open res/menu/menu_search.xml and replace the existing contents with the following:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="com.raywenderlich.podplay.ui.PodcastActivity">
<item android:id="@+id/search_item"
android:title="@string/search"
android:icon="@android:drawable/ic_menu_search"
app:showAsAction="collapseActionView|ifRoom"
app:actionViewClass="androidx.appcompat.widget.SearchView"/>
</menu>
This defines an options menu with a single menu_search item that’s shown as an action view and uses the built-in ic_menu_search icon from the Android operating system.
The showAsAction pipe-separated options are set to collapse the action view by default and only display in the app bar if there’s room. The actionViewClass must be set as androidx.appcompat.widget.SearchView since you want your shiny search bar to be backward-compatible with older versions of Android.
Notice that you still need to define the value of the search resource string, which is indicated by the red text. You’ve already seen how to do that manually, but Android Studio offers another way to add a missing String resource directly from the code where you’ve tried to use it.
Place the cursor within the red @string/search text and press Option-Return on macOS or Alt-Enter on Windows to bring up the context menu, and select Create string value resource ‘search’:
In the dialog that appears, type Search for the Resource value and click OK.
This adds the appropriate line to strings.xml, and the menu file updates so that all of the text is a happy green, indicating that all of your resources exist.
Next, you need to load the options menu and configure it properly.
Loading the options menu
Open PodcastActivity.kt and override onCreateOptionsMenu() as follows. Note that you do not need to call super:
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// 1
val inflater = menuInflater
inflater.inflate(R.menu.menu_search, menu)
// 2
val searchMenuItem = menu.findItem(R.id.search_item)
val searchView = searchMenuItem?.actionView as SearchView
// 3
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
// 4
searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName))
return true
}
Note: Be sure to import import androidx.appcompat.widget.SearchView and not the non-support version to resolve the
SearchViewreference.
What’s happening in this code?
-
First, you inflate the options menu. If you had only these two lines, you would have a basic search view that activates when the action button is tapped. The rest of the method is what makes it a fully functioning search widget.
-
The search action menu item is found within the options menu, and the search view is taken from the item’s
actionViewproperty. -
The system
SearchManagerobject is loaded.SearchManagerprovides some key functionality when working with search services. It will be used later to load the searchable info XML file you created earlier. -
You use
searchManagerto load the search configuration and assign it to thesearchView.
Build and run the app, and you’ll see a search icon in the app bar.
Tap the search icon, and it expands into the search view. Notice the features built into the search widget.
-
A back arrow is displayed to cancel the search, hide the keyboard and return to the normal app bar.
-
The hint you included in the search configuration is shown in the search view.
- A clear button is added to clear out the search text after at least one character has been entered.
Enter a search phrase and hit return. The search view disappears, and nothing else happens! The search widget is knocking on the Activity’s door, but no one is answering. It’s now up to you to implement the actual search logic.
Handling the search intent
By default, the search widget starts the searchable Activity that you defined in the manifest, and it sends it an Intent with the search query as an extra data item on the Intent. In this case, the searchable Activity is already running, but you don’t want two copies of it on the Activity stack.
To get around this undesired behavior, you can set the android:launchMode of PodcastActivity to singleTop.
Open manifests/AndroidManifest.xml and update the PodcastActivity’s activity element to add this attribute:
<activity android:name=".ui.PodcastActivity"
android:launchMode="singleTop">
This tells the system to skip adding another PodcastActivity to the stack if it’s already on top. Now, instead of creating a new copy of PodcastActivity to receive the search Intent, a call is made to onNewIntent() on the existing PodcastActivity.
Open ui/PodcastActivity.kt and add the following method:
private fun performSearch(term: String) {
val itunesService = ItunesService.instance
val itunesRepo = ItunesRepo(itunesService)
GlobalScope.launch {
val results = itunesRepo.searchByTerm(term)
Log.i(TAG, "Results = ${results.body()}")
}
}
This method contains the same code that you had in onCreate(), except that the search term is not hard-coded. If the search code is still in onCreate(), remove it.
Next, add the following method to handle incoming intents:
private fun handleIntent(intent: Intent) {
if (Intent.ACTION_SEARCH == intent.action) {
val query = intent.getStringExtra(SearchManager.QUERY) ?: return
performSearch(query)
}
}
This method takes in an Intent and checks to see if it’s an ACTION_SEARCH. If so, it extracts the search query string and passes it to performSearch().
Finally, override onNewIntent so it can receive the updated Intent when a new search is performed:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
This method is called when the Intent is sent from the search widget. It calls setIntent() to make sure the new Intent is saved with the Activity. handleIntent() is called to perform the search.
Build and run the app. Then tap the search icon, enter a search term, and press return. The raw results of the search are written to the Logcat window:
Now that you’re getting the search results from iTunes, you’re finally ready to display those results to the user.
Displaying search results
You’ll display results using a standard RecyclerView, with one podcast per row. iTunes includes a cover image for each podcast, which you’ll display along with the podcast title and the last updated date. This will give the user a quick overview of each podcast.
Start by doing some housekeeping to replace the standard action bar with the appcompat version. This is the same technique you used earlier in PlaceBook. To save time, the dependencies are already set up, but there are still a few things that need to be done.
Appcompat app bar
Open the module’s build.gradle and the following new lines to the dependencies:
implementation 'com.google.android.material:material:1.3.0'
implementation "androidx.recyclerview:recyclerview:1.1.0"
A warning about changing Gradle files is shown at the top of the editor. Click on Sync Now.
Open /res/values/themes.xml and add the following:
<style name="Theme.PodPlay.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.PodPlay.AppBarOverlay"
parent="ThemeOverlay.AppCompat.Dark.ActionBar"/>
<style name="Theme.PodPlay.PopupOverlay"
parent="ThemeOverlay.AppCompat.Light"/>
The NoActionBar style is applied to the activity and tells the system not to include the system action bar since you will be using the Toolbar class instead. The AppBarOverlay is the style used for AppBarLayout and the PopupOverlay is for the Toolbar.
Open AndroidManifest.xml and add the following attribute to the PodcastActivity activity element:
android:theme="@style/Theme.PodPlay.NoActionBar"
Open app/build.gradle and add the following to the bottom of the android section:
buildFeatures {
viewBinding true
}
Then click on Sync Now. This will enable View Binding in your app.
Open res/layout/activity_podcast.xml and replace the contents with the following:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.raywenderlich.podplay.ui.PodcastActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
android:fitsSystemWindows="true"
android:theme="@style/Theme.PodPlay.AppBarOverlay">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:popupTheme="@style/Theme.PodPlay.PopupOverlay"/>
</com.google.android.material.appbar.AppBarLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
Open PodcastActivity.kt and add the data binding class at the top of PodcastActivity:
private lateinit var binding: ActivityPodcastBinding
Replace setContentView with:
binding = ActivityPodcastBinding.inflate(layoutInflater)
setContentView(binding.root)
Next, add the following method:
private fun setupToolbar() {
setSupportActionBar(binding.toolbar)
}
This is the same technique used in Chapter 17, “Detail Activity” to get ActionBar support for the Activity. setSupportActionBar() is a built-in method that makes the toolbar act as the ActionBar for this Activity.
Finally, call that method from the end of onCreate():
setupToolbar()
SearchViewModel
To display the results in the Activity, you need a view model first. Remember from previous architecture discussions that Views using Architecture Components only get data from view models. You’ll create a SearchViewModel and the PodcastActivity will use it to display the results.
SearchViewModel will inherit from AndroidViewModel, which is part of the lifecycle component of the Android architecture components.
Open the project’s build.gradle and add the following to the ext section:
lifecycle_version = '2.3.0'
Then open the app module’s build.gradle and add the following lines to the dependencies section:
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.activity:activity-ktx:1.2.0"
A warning about changing Gradle files is shown at the top of the editor. Click on Sync Now.
See Section 3: Creating Map-Based Apps for more details on these dependencies.
Right-click com.raywenderlich.podplay in the project manager, and create a new package named viewmodel to help keep your view models organized. Add a new empty Kotlin file inside viewmodel and name it SearchViewModel.kt.
Open it, and set up the initial search view model class:
class SearchViewModel(application: Application) : AndroidViewModel(application) {
}
The AndroidViewModel superclass requires the ‘application’ parameter. In fact, you can’t add additional parameters to this class’s constructor because of how it is provided through the Architecture components, so you must set up any additional properties separately.
In this case, add a property for an ItunesRepo, which will fetch the information:
var iTunesRepo: ItunesRepo? = null
This is optional and initialized to null since it’s expected that the caller — in this case, PodcastActivity — passes this object in before calling any method to fetch the data.
Next, define a data class within the view model that has only the data that’s necessary for the View, and that has default empty string values:
data class PodcastSummaryViewData(
var name: String? = "",
var lastUpdated: String? = "",
var imageUrl: String? = "",
var feedUrl: String? = "")
Next, add a helper method to convert from the raw model data to the view data:
private fun itunesPodcastToPodcastSummaryView(
itunesPodcast: PodcastResponse.ItunesPodcast):
PodcastSummaryViewData {
return PodcastSummaryViewData(
itunesPodcast.collectionCensoredName,
itunesPodcast.releaseDate,
itunesPodcast.artworkUrl30,
itunesPodcast.feedUrl)
}
Finally, define a method to perform the search, which eventually gets called by PodcastActivity:
// 1
suspend fun searchPodcasts(term: String): List<PodcastSummaryViewData> {
// 2
val results = iTunesRepo?.searchByTerm(term)
// 3
if (results != null && results.isSuccessful) {
// 4
val podcasts = results.body()?.results
// 5
if (!podcasts.isNullOrEmpty()) {
// 6
return podcasts.map { podcast ->
itunesPodcastToPodcastSummaryView(podcast)
}
}
}
// 7
return emptyList()
}
Going through the code of this method step-by-step:
- The first parameter is the search term. Since the iTunes repo’s search method runs asynchronously, this method needs the suspend keyword at the beginning of the method.
- iTunesRepo is used to perform the search asynchronously.
- Check if the results are not
nulland the call is successful. - Get the podcasts from the body.
- Check if the podcasts list is not empty.
- Map them to PodcastSummaryViewData objects. This follows the principle of providing the View with just enough data for presentation.
- If the results are
null, then you return an empty list.
Next, you need to add the RecyclerView to display the search results.
Results RecyclerView
First, you’ll define the Layout for a single search result item. Create a new resource layout file inside res/layout and name it search_item.xml. Then, set the contents to the following:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/searchItem"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingLeft="5dp"
android:paddingRight="5dp">
<ImageView
android:id="@+id/podcastImage"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginBottom="9dp"
android:layout_marginStart="5dp"
android:adjustViewBounds="true"
android:scaleType="fitStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/podcastNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/podcastImage"
app:layout_constraintTop_toTopOf="parent"
tools:text="Name" />
<TextView
android:id="@+id/podcastLastUpdatedTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/podcastImage"
app:layout_constraintTop_toBottomOf="@+id/podcastNameTextView"
tools:text="Last updated" />
</androidx.constraintlayout.widget.ConstraintLayout>
This Layout defines an image on the left, as well as a podcast name and last updated date on the right.
Next, open xml/layout/activity_podcast.xml and add the following below the closing tag of the AppBarLayout:
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/podcastRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="0dp"
android:layout_marginStart="0dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/app_bar"/>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible"/>
This defines a RecyclerView to hold the search results and a ProgressBar to display while the search is being performed.
Glide image loader
Before defining the Adapter for the RecyclerView, you need to consider the best way to display the cover art efficiently. The user may do many searches in a row, and each one can return up to 50 results.
If you pre-fetch the image for each one and store it locally or in memory, it won’t make for an enjoyable user experience; there could potentially be a considerable delay before any results would show up. You could try to get a little smarter about it and only load the images as they’re needed by the RecyclerView adapter, but this will result in clunky scrolling performance. Your next step to image loading nirvana might be to load the images on-demand in the background, so the scrolling remains smooth. At about this point in the development process, you’re probably thinking, “This sounds like a lot of work. There has to be a better way!” and fortunately there is. :]
There are several third-party libraries made to handle this exact situation. They perform on-demand loading in the background and do intelligent caching to keep the most recently loaded images ready for quick retrieval. One popular library Google recommends is Glide.
Glide was developed to make image scrolling as smooth as possible, but you can use it in any situation where you need to load images from a remote source.
Note: Coil is a newer Kotlin based image loading library that has gotten good reviews.
Using Glide is as simple as making a single chain of calls that specify a context, the remote image URL, and a View to place the image. Glide handles all of the details, including background loading and canceling the image load when the parent View disappears.
To use Glide, add the following to the dependencies section in the module’s build.gradle:
implementation "com.github.bumptech.glide:glide:4.11.0"
A warning about changing Gradle files is shown at the top of the editor. Click on Sync Now.
Create a new package inside com.raywenderlich.podplay and name it adapter. Add a new Kotlin file to this package and name it PodcastListAdapter.kt. Finally, update it with the following contents:
class PodcastListAdapter(
private var podcastSummaryViewList: List<PodcastSummaryViewData>?,
private val podcastListAdapterListener: PodcastListAdapterListener,
private val parentActivity: Activity
) : RecyclerView.Adapter<PodcastListAdapter.ViewHolder>() {
interface PodcastListAdapterListener {
fun onShowDetails(podcastSummaryViewData: PodcastSummaryViewData)
}
inner class ViewHolder(
databinding: SearchItemBinding,
private val podcastListAdapterListener: PodcastListAdapterListener
) : RecyclerView.ViewHolder(databinding.root) {
var podcastSummaryViewData: PodcastSummaryViewData? = null
val nameTextView: TextView = databinding.podcastNameTextView
val lastUpdatedTextView: TextView = databinding.podcastLastUpdatedTextView
val podcastImageView: ImageView = databinding.podcastImage
init {
databinding.searchItem.setOnClickListener {
podcastSummaryViewData?.let {
podcastListAdapterListener.onShowDetails(it)
}
}
}
}
fun setSearchData(podcastSummaryViewData: List<PodcastSummaryViewData>) {
podcastSummaryViewList = podcastSummaryViewData
this.notifyDataSetChanged()
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): PodcastListAdapter.ViewHolder {
return ViewHolder(SearchItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false),
podcastListAdapterListener)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val searchViewList = podcastSummaryViewList ?: return
val searchView = searchViewList[position]
holder.podcastSummaryViewData = searchView
holder.nameTextView.text = searchView.name
holder.lastUpdatedTextView.text = searchView.lastUpdated
//TODO: Use Glide to load image
}
override fun getItemCount(): Int {
return podcastSummaryViewList?.size ?: 0
}
}
Most of this code was covered in earlier chapters on RecyclerViews, so there’s no need to go over the details here. If you miss any aspect at this point, have a look at Chapter 7, RecyclerView.
Now, replace the //TODO: in onBindViewHolder with the following:
Glide.with(parentActivity)
.load(searchView.imageUrl)
.into(holder.podcastImageView)
This uses Glide’s fluent API to load the podcast image into the image view efficiently. The with() call can take an Activity, Fragment, View, or Context. By providing Glide with the parentActivity that was passed in with the constructor, it’ll be tied to the Activity Lifecycle and properly clean up image usage. The load() call specifies the remote URL of the image to be loaded. The into() call specifies the ImageView to place the image into once it’s loaded.
Glide also allows you to load images directly into Bitmap images instead of into a specified ImageView. You can add several other calls to the fluent API to control options and do image manipulation such as transformations and animated transitions.
You now have everything in place to display the data from the view model. It’s time to hook up the view model data to the RecyclerView.
Populating the RecyclerView
Open PodcastActivity.kt and add the following lines to the top of the class:
private val searchViewModel by viewModels<SearchViewModel>()
private lateinit var podcastListAdapter: PodcastListAdapter
Add the following method to set up the SearchViewModel:
private fun setupViewModels() {
val service = ItunesService.instance
searchViewModel.iTunesRepo = ItunesRepo(service)
}
This creates an instance of the ItunesService and then uses ViewModelProviders to get an instance of the SearchViewModel. It then creates a new ItunesRepo object with the ItunesService and assigns it to the SearchViewModel.
Next, add the following method to set up the RecyclerView with a PodcastListAdapter:
private fun updateControls() {
databinding.podcastRecyclerView.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(this)
databinding.podcastRecyclerView.layoutManager = layoutManager
val dividerItemDecoration = DividerItemDecoration(
databinding.podcastRecyclerView.context, layoutManager.orientation)
databinding.podcastRecyclerView.addItemDecoration(dividerItemDecoration)
podcastListAdapter = PodcastListAdapter(null, this, this)
databinding.podcastRecyclerView.adapter = podcastListAdapter
}
There will be an error on the constructor for PodcastListAdapter as the activity has not implemented the listener yet. Add the following lines calling the setup methods you just made to the end of onCreate():
setupViewModels()
updateControls()
Next, update the PodcastActivity declaration to implement PodcastListAdapterListener:
class PodcastActivity : AppCompatActivity(), PodcastListAdapter.PodcastListAdapterListener {
This is required by the PodcastListAdapter created in updateControls().
Now, add the following method to satisfy the PodcastListAdapterListener interface:
override fun onShowDetails(
podcastSummaryViewData: PodcastSummaryViewData) {
// Not implemented yet
}
This is called when the user taps on a podcast in the RecyclerView. You’ll complete the implementation in the next chapter.
Next, add the following helper methods to encapsulate showing and hiding the progress bar during searching:
private fun showProgressBar() {
databinding.progressBar.visibility = View.VISIBLE
}
private fun hideProgressBar() {
databinding.progressBar.visibility = View.INVISIBLE
}
The last thing you need to do in PodcastActivity.kt is update performSearch() to use the view model you set up:
private fun performSearch(term: String) {
showProgressBar()
GlobalScope.launch {
val results = searchViewModel.searchPodcasts(term)
withContext(Dispatchers.Main) {
hideProgressBar()
databinding.toolbar.title = term
podcastListAdapter.setSearchData(results)
}
}
}
This uses SearchViewModel to find the podcasts based on the search term. It displays the progress bar before the search starts and hides it as soon as it’s over. The search is launched on a background thread and then switches to the main thread to finish the UI changes. The toolbar title is updated to show the search term, and the RecyclerView Adapter is updated with the results.
Build and run the app. Tap the search icon and enter a search term. The results are displayed and you’ll see the cover art images load in after the main content is rendered. If your search returns enough results, scroll through the list as quickly as possible, and notice the movement remains smooth no matter how many results and images are loading.
That doesn’t look too bad, but the Last Updated Date is formatted more for computers than for humans. Time to fix that!
Date formatting
Create a new package inside com.raywenderlich.podplay and name it util. Next, add a new Kotlin file and name it DateUtils.kt with the following contents:
object DateUtils {
fun jsonDateToShortDate(jsonDate: String?): String {
//1
if (jsonDate == null) {
return "-"
}
// 2
val inFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
// 3
val date = inFormat.parse(jsonDate) ?: return "-"
// 4
val outputFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.getDefault())
// 6
return outputFormat.format(date)
}
}
Note: Be sure to import java.text.DateFormat and java.text.SimpleDateFormat rather than their android counterparts.
This defines a method named jsonDateToShortDate that converts the date returned from iTunes into a simple month, date, and year format using the user’s current locale.
- First, check that the
jsonDatestring coming in is notnull. If it is, return “-”, which doesn’t need to be translated (to avoid calling into Android Resources), indicating that no date was provided. - Define a
SimpleDateFormatto match the date format returned by iTunes. - Parse
jsonDatestring and place it into aDateobject nameddate. - The output format is defined as a short date to match the currently defined locale. By passing in the
Locale.getDefault(), Android will honor the locale and date settings set by the user. - The
dateis formatted and returned.
Open SearchViewModel.kt and in itunesPodcastToPodcastSummaryView(), replace the itunesPodcast.releaseDate line with the following:
DateUtils.jsonDateToShortDate(itunesPodcast.releaseDate),
Note: Be sure to import your project’s DateUtils rather than the android.text.format counterpart.
You’re calling jsonDateToShortDate() to convert the date before it’s returned from the SearchViewModel — that way the View never has to know that the date has been formatted, but it will still look much nicer to the user.
Build and run the app. Search for podcasts again and notice the date is now shown in a shorter format and based on the device language settings.
For instance, if you’re in the US, the date is formatted similar to the screenshots above, because en-US is most likely your default locale. If you’re in a country that uses Day/Month/Year formatting, such as the UK, then the date is formatted as 28/2/20 instead of 2/28/20.
Want to double-check? Go to Android’s Settings app and drill down to System ▸ Languages & Input ▸ Languages and add a language that uses a different date format — for example, if you’re from the US, add UK English, or if you’re from the UK, add US English. Drag the language you just added to the top of the list.
Now return to the app and you’ll see this:
Hey, what happened to the search results?
It turns out that when you changed the language settings, Android triggered a configuration change and restarted the PodcastActivity.
This is where saving the search Intent in newIntent() pays off. You can grab the saved Intent when the Activity restarts and then redo the search.
Open PodcastActivity.kt and add the following line to the end of onCreate():
handleIntent(intent)
This gets the saved Intent and passes it to the existing handleIntent() method.
Build and run the app. Search for some podcasts, and then change language settings again by dragging your primary language back up to the top.
This time, the changes are reflected immediately when you re-enter the application.
Any configuration change, including rotating the screen, is now handled correctly.
Key Points
- Android provides a nice search UI that can be used to provide search capabilities.
- Using
singleTopfor an Activity prevents the activity from being recreated. -
onNewIntentis used to handle updated intents. - Glide is a great library for loading and caching images.
- ViewModels provide the business logic for loading data.
- Handling language configuration changes can be handled with
onNewIntent.
Where to go from here?
In the next chapter, you’ll build out a detailed display for a single podcast and all of its episodes. You’ll also build out a data layer for subscribing to podcasts.