22
Podcast Details
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 user can find their favorite podcasts, you’re ready to add a podcast detail screen. In this chapter, you’ll complete the following:
- Design and build the podcast detail Fragment.
- Expand on the app architecture.
- Add a podcast detail Fragment.
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 PodPlay project 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.
You’ll start by designing a Layout for the podcast detail screen. The purpose of the detail screen is to give the user a quick overview of the podcast, including the title, description, album art, and a list of recent episodes. It will also provide a subscribe action.
The Layout will contain the album art and title at the top, a scrollable description below that, and a list of episodes below the description. Each episode will contain the title, description, published date, and length. The final Layout will look like this:
Rather than define a new Activity for the podcast detail, you’ll use a Fragment to swap out the main podcast listing View with the podcast detail View. The advantage of using Fragments will become more evident as you build out the full user interface in later chapters.
Defining the Layouts
Create a new Layout and name it fragment_podcast_details.xml. Then 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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#eeeeee"
android:maxHeight="300dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/feedImageView"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:src="@android:drawable/ic_menu_report_image"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/feedTitleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:maxHeight="100dp"
android:text=""
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@+id/feedImageView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/feedImageView"
app:layout_constraintTop_toTopOf="@+id/feedImageView"/>
<TextView
android:id="@+id/feedDescTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:maxHeight="100dp"
android:paddingBottom="8dp"
android:scrollbars="vertical"
android:text=""
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/feedImageView"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/episodeRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/headerView"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<?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="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginTop="8dp">
<TextView
android:id="@+id/titleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:layout_marginEnd="0dp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Title"/>
<TextView
android:id="@+id/releaseDateView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:layout_marginTop="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/descView"
tools:text="01/01/18"/>
<TextView
android:id="@+id/durationView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/descView"
tools:text="00:00"/>
<TextView
android:id="@+id/descView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:layout_marginTop="4dp"
android:maxLines="3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="Description"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<FrameLayout
android:id="@+id/podcastDetailsContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/app_bar"/>
Basic architecture
As in previous chapters, you need to define the basic architecture components consisting of a repository, a service, and a view model to display the podcast detail. There’s no need for any database layer at this point.
Podcast models
To store the podcast data, you need two models: one defines the detail for a single podcast episode, and the other is the podcast detail containing a list of episode models.
data class Episode (
var guid: String = "",
var title: String = "",
var description: String = "",
var mediaUrl: String = "",
var mimeType: String = "",
var releaseDate: Date = Date(),
var duration: String = ""
)
data class Podcast(
var feedUrl: String = "",
var feedTitle: String = "",
var feedDesc: String = "",
var imageUrl: String = "",
var lastUpdated: Date = Date(),
var episodes: List<Episode> = listOf()
)
Podcast repository
You’ll use a repo for retrieving the podcast details and returning it to the view model.
class PodcastRepo {
fun getPodcast(feedUrl: String): Podcast? {
return Podcast(feedUrl, "No Name","No description", "No image")
}
}
Podcast view model
Inside viewmodel, create a new file and name it PodcastViewModel.kt. Replace the contents with the following:
class PodcastViewModel(application: Application) : AndroidViewModel(application) {
var podcastRepo: PodcastRepo? = null
var activePodcastViewData: PodcastViewData? = null
data class PodcastViewData(
var subscribed: Boolean = false,
var feedTitle: String? = "",
var feedUrl: String? = "",
var feedDesc: String? = "",
var imageUrl: String? = "",
var episodes: List<EpisodeViewData>
)
data class EpisodeViewData (
var guid: String? = "",
var title: String? = "",
var description: String? = "",
var mediaUrl: String? = "",
var releaseDate: Date? = null,
var duration: String? = ""
)
}
private fun episodesToEpisodesView(episodes: List<Episode>): List<EpisodeViewData> {
return episodes.map {
EpisodeViewData(
it.guid,
it.title,
it.description,
it.mediaUrl,
it.releaseDate,
it.duration
)
}
}
private fun podcastToPodcastView(podcast: Podcast): PodcastViewData {
return PodcastViewData(
false,
podcast.feedTitle,
podcast.feedUrl,
podcast.feedDesc,
podcast.imageUrl,
episodesToEpisodesView(podcast.episodes)
)
}
// 1
fun getPodcast(podcastSummaryViewData: PodcastSummaryViewData): PodcastViewData? {
// 2
val repo = podcastRepo ?: return null
val feedUrl = podcastSummaryViewData.feedUrl ?: return null
// 3
val podcast = repo.getPodcast(feedUrl)
// 4
podcast?.let {
// 5
it.feedTitle = podcastSummaryViewData.name ?: ""
// 6
it.imageUrl = podcastSummaryViewData.imageUrl ?: ""
// 7
activePodcastViewData = podcastToPodcastView(it)
// 8
return activePodcastViewData
}
// 9
return null
}
Details Fragment
The detail Fragment is responsible for displaying the podcast details and it gets its data from PodcastViewModel
. This is also where the user can subscribe to a podcast. First, you need to add an action menu with a single Subscribe item.
<string name="subscribe">Subscribe</string>
<?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">
<item
android:id="@+id/menu_feed_action"
android:title="@string/subscribe"
app:showAsAction="ifRoom"/>
</menu>
class PodcastDetailsFragment : Fragment() {
private lateinit var databinding: FragmentPodcastDetailsBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 1
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
databinding = FragmentPodcastDetailsBinding.inflate(inflater, container, false)
return databinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
// 2
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.menu_details, menu)
}
}
implementation "androidx.fragment:fragment-ktx:1.3.0"
private val podcastViewModel: PodcastViewModel by activityViewModels()
private fun updateControls() {
val viewData = podcastViewModel.activePodcastViewData ?: return
databinding.feedTitleTextView.text = viewData.feedTitle
databinding.feedDescTextView.text = viewData.feedDesc
activity?.let { activity ->
Glide.with(activity).load(viewData.imageUrl).into(databinding.feedImageView)
}
}
updateControls()
companion object {
fun newInstance(): PodcastDetailsFragment {
return PodcastDetailsFragment()
}
}
Displaying details
Now it’s time to show the Fragment. Jump over to PodcastActivity
and wire it up.
companion object {
private const val TAG_DETAILS_FRAGMENT = "DetailsFragment"
}
private fun createPodcastDetailsFragment(): PodcastDetailsFragment {
// 1
var podcastDetailsFragment = supportFragmentManager
.findFragmentByTag(TAG_DETAILS_FRAGMENT) as PodcastDetailsFragment?
// 2
if (podcastDetailsFragment == null) {
podcastDetailsFragment = PodcastDetailsFragment.newInstance()
}
return podcastDetailsFragment
}
private lateinit var searchMenuItem: MenuItem
searchMenuItem = menu.findItem(R.id.search_item)
val searchView = searchMenuItem.actionView as SearchView
private fun showDetailsFragment() {
// 1
val podcastDetailsFragment = createPodcastDetailsFragment()
// 2
supportFragmentManager.beginTransaction().add(
R.id.podcastDetailsContainer,
podcastDetailsFragment, TAG_DETAILS_FRAGMENT)
.addToBackStack("DetailsFragment").commit()
// 3
databinding.podcastRecyclerView.visibility = View.INVISIBLE
// 4
searchMenuItem.isVisible = false
}
if (databinding.podcastRecyclerView.visibility == View.INVISIBLE) {
searchMenuItem.isVisible = false
}
private fun showError(message: String) {
AlertDialog.Builder(this)
.setMessage(message)
.setPositiveButton(getString(R.string.ok_button), null)
.create()
.show()
}
<string name="ok_button">OK</string>
private val podcastViewModel by viewModels<PodcastViewModel>()
podcastViewModel.podcastRepo = PodcastRepo()
override fun onShowDetails(podcastSummaryViewData:
SearchViewModel.PodcastSummaryViewData) {
// 1
val feedUrl = podcastSummaryViewData.feedUrl ?: return
// 2
showProgressBar()
// 3
val podcast = podcastViewModel.getPodcast(podcastSummaryViewData)
// 4
hideProgressBar()
if (podcast != null) {
// 5
showDetailsFragment()
} else {
// 6
showError("Error loading feed $feedUrl")
}
}
private fun addBackStackListener() {
supportFragmentManager.addOnBackStackChangedListener {
if (supportFragmentManager.backStackEntryCount == 0) {
databinding.podcastRecyclerView.visibility = View.VISIBLE
}
}
}
addBackStackListener()
if (supportFragmentManager.backStackEntryCount > 0) {
databinding.podcastRecyclerView.visibility = View.INVISIBLE
}
Key Points
Where to go from here?
Congratulations, you made a lot of progress! :] However, the detail screen is still missing some key information, including the list of podcast episodes and the ability to subscribe to the podcast.