21
Finding Podcasts
Written by Kevin D Moore
Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as
text.You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.
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:
<?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>
<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.
<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.
<?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>
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
}
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.
<activity android:name=".ui.PodcastActivity"
android:launchMode="singleTop">
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()}")
}
}
private fun handleIntent(intent: Intent) {
if (Intent.ACTION_SEARCH == intent.action) {
val query = intent.getStringExtra(SearchManager.QUERY) ?: return
performSearch(query)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
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.
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"
<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"/>
android:theme="@style/Theme.PodPlay.NoActionBar"
buildFeatures {
viewBinding true
}
<?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>
private lateinit var binding: ActivityPodcastBinding
binding = ActivityPodcastBinding.inflate(layoutInflater)
setContentView(binding.root)
private fun setupToolbar() {
setSupportActionBar(binding.toolbar)
}
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.
lifecycle_version = '2.3.0'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.activity:activity-ktx:1.2.0"
class SearchViewModel(application: Application) : AndroidViewModel(application) {
}
var iTunesRepo: ItunesRepo? = null
data class PodcastSummaryViewData(
var name: String? = "",
var lastUpdated: String? = "",
var imageUrl: String? = "",
var feedUrl: String? = "")
private fun itunesPodcastToPodcastSummaryView(
itunesPodcast: PodcastResponse.ItunesPodcast):
PodcastSummaryViewData {
return PodcastSummaryViewData(
itunesPodcast.collectionCensoredName,
itunesPodcast.releaseDate,
itunesPodcast.artworkUrl30,
itunesPodcast.feedUrl)
}
// 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()
}
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>
<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"/>
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.
implementation "com.github.bumptech.glide:glide:4.11.0"
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
}
}
Glide.with(parentActivity)
.load(searchView.imageUrl)
.into(holder.podcastImageView)
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
private fun setupViewModels() {
val service = ItunesService.instance
searchViewModel.iTunesRepo = ItunesRepo(service)
}
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
}
setupViewModels()
updateControls()
class PodcastActivity : AppCompatActivity(), PodcastListAdapter.PodcastListAdapterListener {
override fun onShowDetails(
podcastSummaryViewData: PodcastSummaryViewData) {
// Not implemented yet
}
private fun showProgressBar() {
databinding.progressBar.visibility = View.VISIBLE
}
private fun hideProgressBar() {
databinding.progressBar.visibility = View.INVISIBLE
}
private fun performSearch(term: String) {
showProgressBar()
GlobalScope.launch {
val results = searchViewModel.searchPodcasts(term)
withContext(Dispatchers.Main) {
hideProgressBar()
databinding.toolbar.title = term
podcastListAdapter.setSearchData(results)
}
}
}
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)
}
}
DateUtils.jsonDateToShortDate(itunesPodcast.releaseDate),
handleIntent(intent)
Key Points
- Android provides a nice search UI that can be used to provide search capabilities.
- Using
singleTop
for an Activity prevents the activity from being recreated. -
onNewIntent
is 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.