Una app de música que se ejecute en una TV debe permitir a los usuarios navegar por los contenidos que ofrece, seleccionarlos y reproducirlos. La experiencia de navegación por el contenido de estas apps debe ser intuitiva y simple, además de visualmente agradable y atractiva.
En esta lección, se explica cómo usar las clases que proporciona la biblioteca Leanback de AndroidX para implementar una interfaz de usuario que permita navegar por música y videos desde el catálogo multimedia de tu app.
Nota: En el ejemplo de implementación que se muestra aquí, se usa BrowseSupportFragment
en lugar de la clase BrowseFragment
obsoleta. BrowseSupportFragment
extiende la clase AndroidX Fragment
, lo que garantiza un comportamiento coherente en los diferentes dispositivos y versiones de Android.

Figura 1: El fragmento de navegación de la app de ejemplo de Leanback muestra datos de un catálogo de videos
Cómo crear un diseño para navegadores de medios
La clase BrowseSupportFragment
en la biblioteca Leanback te permite crear un diseño principal para la navegación por categorías y filas de elementos multimedia con el menor código posible. En el siguiente ejemplo, se muestra cómo crear un diseño que contenga el objeto BrowseSupportFragment
:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/main_frame" android:layout_width="match_parent" android:layout_height="match_parent"> <fragment android:name="com.example.android.tvleanback.ui.MainFragment" android:id="@+id/main_browse_fragment" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
La actividad principal de la app establece esta vista, tal como se muestra en el siguiente ejemplo:
Kotlin
class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.main) } ...
Java
public class MainActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); } ...
Los métodos BrowseSupportFragment
llenan la vista con los datos de video y elementos de la IU. También establecen los parámetros de diseño, como el ícono, el título y si están activados los encabezados de categorías.
- Consulta Cómo establecer elementos de IU para obtener más información sobre cómo configurar elementos de IU.
- Consulta Cómo ocultar o inhabilitar encabezados para obtener más información sobre cómo ocultar los encabezados.
La subclase de la aplicación que implementa los métodos BrowseSupportFragment
también configura objetos de escucha de eventos para acciones del usuario en los elementos de IU y prepara el administrador en segundo plano, como se muestra en el siguiente ejemplo:
Kotlin
class MainFragment : BrowseSupportFragment(), LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadVideoData() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) prepareBackgroundManager() setupUIElements() setupEventListeners() } ... private fun prepareBackgroundManager() { backgroundManager = BackgroundManager.getInstance(activity).apply { attach(activity?.window) } defaultBackground = resources.getDrawable(R.drawable.default_background) metrics = DisplayMetrics() activity?.windowManager?.defaultDisplay?.getMetrics(metrics) } private fun setupUIElements() { badgeDrawable = resources.getDrawable(R.drawable.videos_by_google_banner) // Badge, when set, takes precedent over title title = getString(R.string.browse_title) headersState = BrowseSupportFragment.HEADERS_ENABLED isHeadersTransitionOnBackEnabled = true // set headers background color brandColor = ContextCompat.getColor(requireContext(), R.color.fastlane_background) // set search icon color searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.search_opaque) } private fun loadVideoData() { VideoProvider.setContext(activity) videosUrl = getString(R.string.catalog_url) loaderManager.initLoader(0, null, this) } private fun setupEventListeners() { setOnSearchClickedListener { Intent(activity, SearchActivity::class.java).also { intent -> startActivity(intent) } } onItemViewClickedListener = ItemViewClickedListener() onItemViewSelectedListener = ItemViewSelectedListener() } ...
Java
public class MainFragment extends BrowseSupportFragment implements LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> { } ... @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); loadVideoData(); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); prepareBackgroundManager(); setupUIElements(); setupEventListeners(); } ... private void prepareBackgroundManager() { backgroundManager = BackgroundManager.getInstance(getActivity()); backgroundManager.attach(getActivity().getWindow()); defaultBackground = getResources() .getDrawable(R.drawable.default_background); metrics = new DisplayMetrics(); getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); } private void setupUIElements() { setBadgeDrawable(getActivity().getResources() .getDrawable(R.drawable.videos_by_google_banner)); // Badge, when set, takes precedent over title setTitle(getString(R.string.browse_title)); setHeadersState(HEADERS_ENABLED); setHeadersTransitionOnBackEnabled(true); // set headers background color setBrandColor(ContextCompat.getColor(requireContext(), R.color.fastlane_background)); // set search icon color setSearchAffordanceColor(ContextCompat.getColor(requireContext(), R.color.search_opaque)); } private void loadVideoData() { VideoProvider.setContext(getActivity()); videosUrl = getString(R.string.catalog_url); getLoaderManager().initLoader(0, null, this); } private void setupEventListeners() { setOnSearchClickedListener(new View.OnClickListener() { @Override public void onClick(View view) { Intent intent = new Intent(getActivity(), SearchActivity.class); startActivity(intent); } }); setOnItemViewClickedListener(new ItemViewClickedListener()); setOnItemViewSelectedListener(new ItemViewSelectedListener()); } ...
Cómo establecer elementos de IU
En el ejemplo anterior, el método privado setupUIElements()
llama a varios de los métodos BrowseSupportFragment
para personalizar el navegador del catálogo de contenido multimedia:
setBadgeDrawable()
coloca el recurso de elementos de diseño especificado en la esquina superior derecha del fragmento de navegación, como se muestra en las figuras 1 y 2. Este método reemplaza la string del título con el recurso de elementos de diseño si también se llama asetTitle()
. El recurso de elemento de diseño debería tener 52 dps de alto.setTitle()
establece la string del título en la esquina superior derecha del fragmento de navegación, a menos que se llame asetBadgeDrawable()
.setHeadersState()
ysetHeadersTransitionOnBackEnabled()
ocultan o inhabilitan los encabezados. Consulta Cómo ocultar o inhabilitar encabezados para obtener más información.setBrandColor()
establece el color de fondo para los elementos de IU en el fragmento de navegación (específicamente el color de fondo de la sección de encabezado, con el valor de color especificado).setSearchAffordanceColor()
establece el color del ícono de búsqueda con el valor de color especificado. El ícono de búsqueda aparece en la esquina superior izquierda del fragmento de navegación, como se muestra en las figuras 1 y 2.
Cómo personalizar las vistas de encabezados
En el fragmento de navegación que se muestra en la figura 1, se enumeran los nombres de categorías de video (los encabezados de filas) en el panel izquierdo. Las vistas de texto muestran estos nombres de categorías desde la base de datos de videos. Puedes personalizar el encabezado para que incluya vistas adicionales en un diseño más complejo. En las siguientes secciones, se explica cómo incluir una vista de imagen que muestre un ícono junto al nombre de la categoría, como se muestra en la figura 2.

Figura 2: Los encabezados de filas en el fragmento de navegación, con un ícono y una etiqueta de texto
El diseño para el encabezado de fila se define de la siguiente manera:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/header_icon" android:layout_width="32dp" android:layout_height="32dp" /> <TextView android:id="@+id/header_label" android:layout_marginTop="6dp" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout>
Usa un Presenter
para implementar los métodos abstractos a fin de crear, vincular y desvincular el contenedor de vistas. En el siguiente ejemplo, se muestra cómo vincular el viewholder con dos vistas, ImageView
y TextView
.
Kotlin
class IconHeaderItemPresenter : Presenter() { override fun onCreateViewHolder(viewGroup: ViewGroup): Presenter.ViewHolder { val view = LayoutInflater.from(viewGroup.context).run { inflate(R.layout.icon_header_item, null) } return Presenter.ViewHolder(view) } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, o: Any) { val headerItem = (o as ListRow).headerItem val rootView = viewHolder.view rootView.findViewById<ImageView>(R.id.header_icon).apply { rootView.resources.getDrawable(R.drawable.ic_action_video, null).also { icon -> setImageDrawable(icon) } } rootView.findViewById<TextView>(R.id.header_label).apply { text = headerItem.name } } override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { // no op } }
Java
public class IconHeaderItemPresenter extends Presenter { @Override public ViewHolder onCreateViewHolder(ViewGroup viewGroup) { LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext()); View view = inflater.inflate(R.layout.icon_header_item, null); return new ViewHolder(view); } @Override public void onBindViewHolder(ViewHolder viewHolder, Object o) { HeaderItem headerItem = ((ListRow) o).getHeaderItem(); View rootView = viewHolder.view; ImageView iconView = (ImageView) rootView.findViewById(R.id.header_icon); Drawable icon = rootView.getResources().getDrawable(R.drawable.ic_action_video, null); iconView.setImageDrawable(icon); TextView label = (TextView) rootView.findViewById(R.id.header_label); label.setText(headerItem.getName()); } @Override public void onUnbindViewHolder(ViewHolder viewHolder) { // no op } }
Tus encabezados deben poder enfocarse de manera que sea posible desplazarse por ellos con el pad direccional. Hay dos alternativas:
- Establecer la vista para que pueda enfocarse en
onBindViewHolder()
:Kotlin
override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, o: Any) { val headerItem = (o as ListRow).headerItem val rootView = viewHolder.view rootView.focusable = View.FOCUSABLE //... }
Java
@Override public void onBindViewHolder(ViewHolder viewHolder, Object o) { HeaderItem headerItem = ((ListRow) o).getHeaderItem(); View rootView = viewHolder.view; rootView.setFocusable(View.FOCUSABLE) // Allows the D-Pad to navigate to this header item //... }
- Establecer el diseño para que pueda enfocarse:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" ... android:focusable="true">
Por último, en la implementación de BrowseSupportFragment
que muestra el navegador del catálogo, usa el método setHeaderPresenterSelector()
para establecer el presentador como encabezado de fila, como se muestra en el siguiente ejemplo.
Kotlin
setHeaderPresenterSelector(object : PresenterSelector() { override fun getPresenter(o: Any): Presenter { return IconHeaderItemPresenter() } })
Java
setHeaderPresenterSelector(new PresenterSelector() { @Override public Presenter getPresenter(Object o) { return new IconHeaderItemPresenter(); } });
Para ver un ejemplo completo, consulta la app de muestra de Android Leanback en el repositorio de GitHub de Android TV.
Cómo ocultar o inhabilitar encabezados
En ocasiones, es posible que no quieras que aparezcan los encabezados de fila, por ejemplo, cuando no hay suficientes categorías como para que se necesite una lista desplazable. Llama al método BrowseSupportFragment.setHeadersState()
durante el método onActivityCreated()
del fragmento para ocultar o inhabilitar los encabezados de filas. El método setHeadersState()
establece el estado inicial de los encabezados en el fragmento de navegación con una de las siguientes constantes como un parámetro:
HEADERS_ENABLED
: Cuando se crea la actividad del fragmento de navegación, los encabezados están habilitados y se muestran de forma predeterminada. Los encabezados aparecen como se muestra en las figuras 1 y 2 de esta página.HEADERS_HIDDEN
: Cuando se crea la actividad del fragmento de navegación, los encabezados están habilitados y ocultos de forma predeterminada. La sección de encabezados de la pantalla está contraída, como se muestra en la figura 1 de Cómo proporcionar una vista de tarjetas. El usuario puede hacer clic en la sección de encabezado contraída para expandirla.HEADERS_DISABLED
: Cuando se crea la actividad del fragmento de navegación, los encabezados están inhabilitados de forma predeterminada y nunca se muestran.
Si se establece HEADERS_ENABLED
o HEADERS_HIDDEN
, puedes llamar a setHeadersTransitionOnBackEnabled()
para admitir el retroceso al encabezado de fila desde el elemento de contenido seleccionado en la fila. Esta opción está habilitada de manera predeterminada (si no llamas al método), pero si tú mismo quieres controlar el retroceso, debes transmitir el valor false
a setHeadersTransitionOnBackEnabled()
y, luego, implementar tu propia pila de retroceso.
Cómo mostrar listas de medios
La clase BrowseSupportFragment
te permite definir y mostrar elementos multimedia y categorías de contenido multimedia navegables desde un catálogo de medios mediante adaptadores y presentadores. Los adaptadores te permiten conectar con fuentes de datos locales o en línea que contengan la información de tu catálogo de medios.
Los adaptadores usan presentadores a fin de crear vistas y vincular datos a esas vistas para mostrar un elemento en pantalla.
En el siguiente ejemplo de código, se muestra la implementación de un Presenter
para mostrar datos de una string:
Kotlin
private const val TAG = "StringPresenter" class StringPresenter : Presenter() { override fun onCreateViewHolder(parent: ViewGroup): Presenter.ViewHolder { val textView = TextView(parent.context).apply { isFocusable = true isFocusableInTouchMode = true background = parent.resources.getDrawable(R.drawable.text_bg) } return Presenter.ViewHolder(textView) } override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) { (viewHolder.view as TextView).text = item.toString() } override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) { // no op } }
Java
public class StringPresenter extends Presenter { private static final String TAG = "StringPresenter"; public ViewHolder onCreateViewHolder(ViewGroup parent) { TextView textView = new TextView(parent.getContext()); textView.setFocusable(true); textView.setFocusableInTouchMode(true); textView.setBackground( parent.getResources().getDrawable(R.drawable.text_bg)); return new ViewHolder(textView); } public void onBindViewHolder(ViewHolder viewHolder, Object item) { ((TextView) viewHolder.view).setText(item.toString()); } public void onUnbindViewHolder(ViewHolder viewHolder) { // no op } }
Cuando hayas creado una clase de presentador para tus elementos multimedia, podrás crear un adaptador y adjuntarlo al BrowseSupportFragment
a fin de mostrar esos elementos en pantalla de manera que el usuario pueda navegar por ellos. En el siguiente ejemplo de código, se muestra cómo construir un adaptador para mostrar categorías y elementos en esas categorías mediante la clase StringPresenter
que se mostró en el ejemplo de código anterior:
Kotlin
private const val NUM_ROWS = 4 ... private lateinit var rowsAdapter: ArrayObjectAdapter override fun onCreate(savedInstanceState: Bundle?) { ... buildRowsAdapter() } private fun buildRowsAdapter() { rowsAdapter = ArrayObjectAdapter(ListRowPresenter()) for (i in 0 until NUM_ROWS) { val listRowAdapter = ArrayObjectAdapter(StringPresenter()).apply { add("Media Item 1") add("Media Item 2") add("Media Item 3") } HeaderItem(i.toLong(), "Category $i").also { header -> rowsAdapter.add(ListRow(header, listRowAdapter)) } } browseSupportFragment.adapter = rowsAdapter }
Java
private ArrayObjectAdapter rowsAdapter; private static final int NUM_ROWS = 4; @Override protected void onCreate(Bundle savedInstanceState) { ... buildRowsAdapter(); } private void buildRowsAdapter() { rowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); for (int i = 0; i < NUM_ROWS; ++i) { ArrayObjectAdapter listRowAdapter = new ArrayObjectAdapter( new StringPresenter()); listRowAdapter.add("Media Item 1"); listRowAdapter.add("Media Item 2"); listRowAdapter.add("Media Item 3"); HeaderItem header = new HeaderItem(i, "Category " + i); rowsAdapter.add(new ListRow(header, listRowAdapter)); } browseSupportFragment.setAdapter(rowsAdapter); }
Este ejemplo muestra la implementación estática de los adaptadores. Una app de navegación de medios típica usa datos de una base de datos en línea o de un servicio web. Para ver un ejemplo de una aplicación de navegación que use datos de la Web, consulta la app de ejemplo de Android Leanback en el repositorio de GitHub de Android TV.
Cómo actualizar el fondo
Para agregar interés visual a una app de navegación de medios en la TV, puedes actualizar la imagen de fondo a medida que los usuarios navegan por el contenido. Esta técnica puede lograr que la interacción con tu app sea más cinemática y atractiva.
La biblioteca de compatibilidad Leanback proporciona una clase BackgroundManager
para modificar el fondo de la actividad de tu app para TV. En el siguiente ejemplo, se muestra cómo crear un método simple para actualizar el fondo de la actividad de tu app para TV:
Kotlin
protected fun updateBackground(drawable: Drawable) { BackgroundManager.getInstance(this).drawable = drawable }
Java
protected void updateBackground(Drawable drawable) { BackgroundManager.getInstance(this).setDrawable(drawable); }
Muchas de las apps de navegación de medios existentes actualizan automáticamente el fondo a medida que el usuario navega por las listas de medios. Para ello, puedes configurar un objeto de escucha de selección a fin de que actualice automáticamente el fondo en función de la selección actual del usuario. En el siguiente ejemplo, se muestra cómo configurar una clase OnItemViewSelectedListener
para captar eventos de selección y actualizar el fondo:
Kotlin
protected fun clearBackground() { BackgroundManager.getInstance(this).drawable = defaultBackground } protected fun getDefaultItemViewSelectedListener(): OnItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ -> if (item is Movie) { item.getBackdropDrawable().also { background -> updateBackground(background) } } else { clearBackground() } }
Java
protected void clearBackground() { BackgroundManager.getInstance(this).setDrawable(defaultBackground); } protected OnItemViewSelectedListener getDefaultItemViewSelectedListener() { return new OnItemViewSelectedListener() { @Override public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { if (item instanceof Movie ) { Drawable background = ((Movie)item).getBackdropDrawable(); updateBackground(background); } else { clearBackground(); } } }; }
Nota: La implementación anterior se incluye a modo de ejemplo simplemente. Cuando crees esta función en tu propia app, deberías considerar ejecutar la acción de actualización del fondo en un subproceso diferente para obtener un mejor rendimiento. Además, si quieres actualizar el fondo a medida que el usuario navegue por los elementos, tendrás que agregar un tiempo para retrasar la actualización de la imagen de fondo hasta que el usuario se detenga en un elemento. Esta técnica evita que las imágenes de fondo se actualicen de manera excesiva.