Chapters

Hide chapters

Android Apprentice

Fourth Edition · Android 11 · Kotlin 1.4 · Android Studio 4.1

Section II: Building a List App

Section 2: 7 chapters
Show chapters Hide chapters

Section III: Creating Map-Based Apps

Section 3: 7 chapters
Show chapters Hide chapters

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 scrambled text.

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:

  1. Create a search configuration XML file.
  2. Declare a searchable activity.
  3. Add an options menu.
  4. 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now