Chapters

Hide chapters

Android Apprentice

Third Edition · Android 10 · Kotlin 1.3 · Android Studio 3.6

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

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 Tom Blankenship

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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 support library 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.

Implementing search

To implement search capabilities, you need to:

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 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(); 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
}

Implementing the search

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)

  itunesRepo.searchByTerm(term) {
    Log.i(TAG, "Results = $it")
  }
}
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 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.1.0'
implementation "androidx.recyclerview:recyclerview:1.1.0"
<style name="AppTheme.NoActionBar">
  <item name="windowActionBar">false</item>
  <item name="windowNoTitle">true</item>
</style>

<style name="AppTheme.AppBarOverlay" 
    parent="ThemeOverlay.AppCompat.Dark.ActionBar"/>

<style name="AppTheme.PopupOverlay" 
    parent="ThemeOverlay.AppCompat.Light"/>
android:theme="@style/AppTheme.NoActionBar"
<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/AppTheme.AppBarOverlay">

  <androidx.appcompat.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    app:popupTheme="@style/AppTheme.PopupOverlay"/>

</com.google.android.material.appbar.AppBarLayout>
import kotlinx.android.synthetic.main.activity_podcast.*
private fun setupToolbar() {
  setSupportActionBar(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.2.0'
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.activity:activity-ktx:1.1.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
fun searchPodcasts(term: String, 
  callback: (List<PodcastSummaryViewData>) -> Unit) {
  // 2
  iTunesRepo?.searchByTerm(term) { results ->
    if (results == null) {
      // 3
      callback(emptyList())
    } else {
      // 4
      val searchViews = results.map { podcast ->
        itunesPodcastToPodcastSummaryView(podcast)
      }
      // 5
      callback(searchViews)
    }
  }
}

Results RecyclerView

First, 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"?>
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="horizontal"
    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_marginEnd="5dp"
      android:adjustViewBounds="true"
      android:scaleType="fitStart"/>

  <LinearLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_marginEnd="5dp"
      android:orientation="vertical">

    <TextView
        android:id="@+id/podcastNameTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="top"
        android:layout_marginBottom="5dp"
        android:textStyle="bold"
        tools:text="Name"/>

    <TextView
        android:id="@+id/podcastLastUpdatedTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="top"
        android:textSize="12sp"
        tools:text="Last updated"/>
  </LinearLayout>
</LinearLayout>

<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(v: View, 
      private val podcastListAdapterListener: 
          PodcastListAdapterListener) :
      RecyclerView.ViewHolder(v) {

    var podcastSummaryViewData: PodcastSummaryViewData? = null
    val nameTextView: TextView = v.podcastNameTextView
    val lastUpdatedTextView: TextView = v.podcastLastUpdatedTextView
    val podcastImageView: ImageView = v.podcastImage

    init {
      v.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(LayoutInflater.from(parent.context)
        .inflate(R.layout.search_item, 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() {
  podcastRecyclerView.setHasFixedSize(true)

  val layoutManager = LinearLayoutManager(this)
  podcastRecyclerView.layoutManager = layoutManager

  val dividerItemDecoration = DividerItemDecoration(
      podcastRecyclerView.context, layoutManager.orientation)      
  podcastRecyclerView.addItemDecoration(dividerItemDecoration)

  podcastListAdapter = PodcastListAdapter(null, this, this)
  podcastRecyclerView.adapter = podcastListAdapter
}
setupViewModels()
updateControls()
class PodcastActivity : AppCompatActivity(), 
    PodcastListAdapterListener {
override fun onShowDetails(
    podcastSummaryViewData: PodcastSummaryViewData) {
  // Not implemented yet
}
private fun showProgressBar() {
  progressBar.visibility = View.VISIBLE
}

private fun hideProgressBar() {
  progressBar.visibility = View.INVISIBLE
}
private fun performSearch(term: String) {
  showProgressBar()
  searchViewModel.searchPodcasts(term) { results ->
    hideProgressBar()
    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)

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now