Android TV
Development: Using the Leanback Library
Published on 27 September 2023
This in-depth analysis of BrowseFragment's capabilities for Android TV development is based on the example of an online cinema application.
We will explore the functionalities of BrowseFragment, which, according to Google's concept, serves as the main screen of the application. In our case, it was an application for an online cinema under a large media holding.
In this article, I will cover:
· BrowseFragment and its core components: titleView, HeadersFragment, and RowsFragment.
· Configuring the titleView container and creating custom options.
· The fundamental setup of HeadersFragment and RowsFragment.
BrowseFragment and Its Components
BrowseFragment is a fragment designed for creating screens with lists of elements and titles.
Let's delve into each of these elements:
TitleView
**TitleView is a container with various elements. It serves the purpose of branding the TextView and ImageView within the application and includes the provision of the Search OrbView search button by default. To ensure the visibility of the search button, you need to set a listener. This can be accomplished by calling setOnSearchClickedListener {//your code} within the fragment.
There are several methods to customize the color scheme of the button:
• By setting searchAffordanceColor = context.getColorRes (R.color.search_opaque). With this approach, only the color of the circle will be altered.
• By setting searchAffordanceColors = SearchOrbView.Colors (context.getColor(R.color.search_opaque), context.getColor(R.color.search_opaque_bright), context.getColor(R.color.search_opaque_icon)). The SearchOrbView.Colors has an overloaded constructor that offers more flexible configuration options.
Colors(@ColorInt int color) — sets the color of the circle.
Colors(@ColorInt int color, @ColorInt int brightColor) — sets the color of the circle and the animation color of the circle.
Colors(@ColorInt int color, @ColorInt int brightColor, @ColorInt int iconColor) — sets the color of the circle, the animation color of the circle, and the icon color. The animation of the circle pertains to the flickering effect.
Within titleView, you can set either a text title with title = "Finch" or a logo with:
badgeDrawable = ContextCompat.getDrawable(context, R.drawable.app_icon_your_company).
Additionally, only one of these elements can be installed at a time, and the logo always takes precedence.
These elements are highly customizable, and it is advisable to adhere to Google's recommendations. If needed, you can create a custom titleView. For example, if you wish to replace the search button with a text field, you can follow these steps: [...]**
Design a layout for the new TitleView:
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/vTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_marginStart="40dp"
android:layout_marginEnd="24dp"
android:textAllCaps="true"
android:textSize="20sp"
android:textColor="@color/search_opaque"
android:visibility="visible" />
</merge>
Create a View that implements the TitleViewAdapter.Provider and then create the TitleViewAdapter itself:
class CustomTitleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr), TitleViewAdapter.Provider {
private valtitleViewAdapter = object : TitleViewAdapter() {
override fun getSearchAffordanceView(): View? {
return null
}
override fun setTitle(titleText: CharSequence?) {
this@CustomTitleView.setTitle(titleText)
}
}
init {
View.inflate(context, R.layout.view_custom_title, this)
}
private fun setTitle(title: CharSequence?) {
vTitle.text = title
}
override fun getTitleViewAdapter(): TitleViewAdapter {
return titleViewAdapter
}
}
Create another layout that contains only one element — CustomTitleView:
<fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/browse_title_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
</fm.finch.tv_test_project.extensions.browse.customTitle.CustomTitleView>
Then create a style for the activity:
<style name="BrowseCustomTitleTheme" parent="Leanback.CustomTitle" />
<style name="Leanback.CustomTitle" parent="@style/Theme.Leanback.Browse">
<item name="browseTitleViewLayout">@layout/title_view</item>
<item name="browseRowsMarginTop">60dp</item>
</style>
And set it as the theme of our activity in the manifest:
<activity android:name=".MainActivity"
android:theme="@style/BrowseCustomTitleTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
HeadersFragment & RowsFragment
BrowseSupportFragment is a fragment designed to render the elements of its adapter (ObjectAdapter) as rows in a vertical list. Visually, this fragment is divided into two parts: HeadersFragment, which displays a list of titles on the left side of the screen, and RowsFragment, which acts as a container for content on the right side of the screen. These fragments work together, and BrowseFragment delegates the rendering of its adapter elements to them.
All elements provided to the BrowseFragment's adapter must be subclasses of Row since this object carries information about both the header and content to display.
Header Fragment is intended for rendering and interacting with the HeaderItem, which is a part of the Row passed to the BrowseFragment's adapter.
RowsFragment is designed for rendering and interacting with the elements of its ObjectAdapter. It's not a BrowseFragment's adapter, and it doesn't belong to the Row passed to the BrowseFragment. Instead, it receives objects that describe the content.
I will illustrate this with an example. Let's create a Presenter that will pass text to a TextView:
class TextPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val view = TextView(parent.context)
view.layoutParams = ViewGroup.LayoutParams(300, 200)
view.isFocusable = true
view.isFocusableInTouchMode = true
view.setBackgroundColor(
ContextCompat.getColor(
parent.context,
android.R.color.background_light
)
)
view.gravity = Gravity.CENTER
view.setTextColor(Color.BLACK)
return Presenter.ViewHolder(view)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val textView = viewHolder.view as TextView
val str = item as String
textView.text = str
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val textView = viewHolder.view as TextView
textView.text = ""
}
}
Then we will create all the necessary adapters, populate them with data, and set up the BrowseFragment to display the data:
class MainFragment : BrowseSupportFragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
initView(savedInstanceState)
}
private fun initView(savedInstanceState: Bundle?) {
headersState = HEADERS_HIDDEN
title = "Finch"
val browseAdapter = ArrayObjectAdapter(ListRowPresenter())
val rowsAdapter = ArrayObjectAdapter(TextPresenter())
rowsAdapter.add("Element 1")
rowsAdapter.add("Element 2")
rowsAdapter.add("Element 3")
val firstHeader = HeaderItem("Heading 1")
val secondHeader = HeaderItem("Heading 2")
val thirdHeader = HeaderItem("Heading 3")
browseAdapter.add(ListRow(firstHeader, rowsAdapter))
browseAdapter.add(ListRow(secondHeader, rowsAdapter))
browseAdapter.add(ListRow(thirdHeader, rowsAdapter))
adapter = browseAdapter
}
}
We will assign a Presenter to each adapter to render the elements:
For the BrowseFragment, we utilize the standard implementation of ListRowPresenter. This Presenter can work with List Rows and visualizes them using a Horizontal Grid View placed in a ListRowView.
For the RowsFragment adapter, we use our custom TextPresenter() implementation since we pass String and TextPresenter() elements to it, and it knows how to display them.
Now we have a list attached to each title in the left half of the screen and content in the right half of the screen.
Let's delve deeper into the HeadersFragment.
By default, the HeadersFragment is immediately visible to the user. However, you can alter this behavior using the setHeadersState(int) method. You should access it during the onActivityCreated() call and pass one of the following states:
HEADERS_ENABLED: The fragment is visible to the user.
HEADERS_HIDDEN: The fragment is collapsed.
HEADERS_DISABLED: The fragment is completely hidden from the screen.
BrowseFragment handles onBackPressed() by default. If the HeadersFragment is minimized, pressing the "Back" button on the remote control or joystick will expand it and make it visible. This behavior can be disabled by passing false to the setHeadersTransitionOnBackEnabled() method.
To manually control the HeadersFragment state, you can use the startHeadersTransition(boolean) method. Depending on the input parameter, the header fragment will either be shown (if true) or collapsed (if false).
If you need to listen to the beginning and end of the animation of the HeadersFragment transition from the collapsed state to the active state and vice versa, you can set a listener using setBrowseTransitionListener(BrowseSupportFragment.BrowseTransitionListener).
There are several other Row implementations that specifically affect the Header Fragment:
DividerRow: Adds a divider between headers.
SectionRow: Introduces a subtitle.
Let's make the code changes as follows:
browseAdapter.add(ListRow(firstHeader, rowsAdapter))
browseAdapter.add(DividerRow())
browseAdapter.add(ListRow(secondHeader, rowsAdapter))
browseAdapter.add(DividerRow())
browseAdapter.add(ListRow(thirdHeader, rowsAdapter))
browseAdapter.add(SectionRow("Subtitle 1"))
What happened: Separators have been added between the elements in HeadersFragment, and the last element now has a subtitle.
Customization of Header Fragment
Suppose we want to modify the appearance of the headers and include icons. To achieve this, we must create a unique header item with a custom Presenter and also establish a PresenterSelector, which functions as a Presenter for each list item. Let's begin with the header element:
class IconHeaderItem(
name: String,
id: Long = -1,
val iconResId: Int = NO_ICON
) : HeaderItem(id, name) {
companion object {
val NO_ICON = -1
}
}
We have extended the standard HeaderItem to include the capability of setting icons from resources. Now, let's create the layout for the new title:
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:focusable="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:drawablePadding="16dp"
android:textSize="18sp"/>
Let's create our own presenter capable of rendering Icon Header Items:
class IconRowHeaderPresenter : RowHeaderPresenter() {
override fun onCreateViewHolder(viewGroup: ViewGroup): ViewHolder {
val view = LayoutInflater
.from(viewGroup.context)
.inflate(R.layout.item_icon_header, viewGroup, false)
val viewHolder = ViewHolder(view)
setSelectLevel(viewHolder, 0f)
return viewHolder
}
override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, data: Any) {
val row = data as? Row
val iconHeaderItem = row?.headerItem as? IconHeaderItem
iconHeaderItem?.let {
val tv = viewHolder
.view
.textView
if (iconHeaderItem.iconResId != IconHeaderItem.NO_ICON) {
tv.setCompoundDrawablesWithIntrinsicBounds(iconHeaderItem.iconResId, 0, 0, 0)
}
tv.text = iconHeaderItem.name
}
}
override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) {
val tv = viewHolder
.view
.textView
tv.text = ""
tv.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0)
}
}
Let's set up a new PresenterSelector for headers. To do this, use the setHeaderPresenterSelector method in BrowseFragment:
setHeaderPresenterSelector(object : PresenterSelector() {
val presenter = IconRowHeaderPresenter()
val defaultPresenterSelector = headersSupportFragment.presenterSelector
override fun getPresenter(data: Any): Presenter {
return if ((data as? Row)?.headerItem is IconHeaderItem)
presenter
else
defaultPresenterSelector.getPresenter(data)
}
})
Now let's focus on the Row header. If it's an IconHeaderItem, we'll return a Presenter that can handle it – in this case, the IconRowHeaderPresenter. Replace the headers with the new ones:
val firstHeader = IconHeaderItem("Heading 1", iconResId = R.drawable.ic_header_item)
val secondHeader = IconHeaderItem("Heading 2", iconResId = R.drawable.ic_header_item)
val thirdHeader = IconHeaderItem("Heading 3", iconResId = R.drawable.ic_header_item)
In the same manner, you can customize the subtitles in SectionRow or the separators in DividerRow.
Conclusion
We have explored the capabilities of BrowseFragment, which is part of the Leanback library. However, Leanback is not confined to this template. Its components are extensive enough to create a complete Android TV application while adhering to all the guidelines.
Recommended Tutorials
Stay updated with our latest articles, industry insights, and expert tips to keep your business informed and inspired.
We Anticipate Risks and Offer Simple Solutions.Software development encompasses...
Learn More
We might wish for a single technology that could create high-quality application...
Learn More