Una app de música que se ejecute en una TV debe permitir a los usuarios explorar su oferta de contenido, seleccionar una opción y comenzar a reproducir contenido. La experiencia de navegación por el contenido debe ser simple e intuitiva, así como visualmente agradable y atractiva.
En esta guía, se explica cómo usar las clases que proporciona la biblioteca de 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 Fragment
de AndroidX para garantizar un comportamiento coherente en todos los dispositivos y versiones de Android.
Cómo crear un diseño para navegadores de medios
La clase BrowseSupportFragment
de la biblioteca Leanback te permite crear un diseño principal para la navegación por categorías y filas de elementos multimedia con un código mínimo. En el siguiente ejemplo, se muestra cómo crear un diseño que contenga un 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
propagan la vista con los datos de video y los elementos de la IU. También establecen parámetros de diseño, como el ícono y el título, y si están habilitados los encabezados de categorías.
La subclase de la aplicación que implementa los métodos BrowseSupportFragment
también configura objetos de escucha de eventos para las acciones del usuario en los elementos de la 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 header 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 header 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 métodos BrowseSupportFragment
para diseñar el navegador del catálogo de contenido multimedia:
setBadgeDrawable()
coloca el recurso de elemento 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 cadena del título con el recurso de elementos de diseño si también se llama asetTitle()
. El recurso de elemento de diseño debe tener 52 dp de alto.setTitle()
establece la cadena de 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 la sección Cómo ocultar o inhabilitar encabezados para obtener más información.setBrandColor()
establece el color de fondo para los elementos de la 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
El fragmento de navegación que se muestra en la Figura 1 muestra los nombres de las categorías de videos, que son los encabezados de filas en la base de datos de videos, en vistas de texto. También puedes personalizar el encabezado para incluir vistas adicionales en un diseño más complejo. En las siguientes secciones, se indica 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.
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
e implementa los métodos abstractos para crear, vincular y desvincular el contenedor de vistas. En el siguiente ejemplo, se muestra cómo vincular el viewport con dos vistas, un ImageView
y un 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 ser enfocables para que puedas desplazarte por ellos con el pad direccional. Hay dos formas de administrar esto:
- Establece la vista para que sea enfocable 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 sea enfocable:
<?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 de 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 ejemplo de Leanback.
Cómo ocultar o inhabilitar encabezados
A veces, no deseas que aparezcan los encabezados de fila, como cuando no hay suficientes categorías como para que se requiera 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 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 del encabezado de la pantalla se contrae, como se muestra en la figura del artículo Cómo proporcionar una vista de tarjetas. El usuario puede seleccionar 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 configura HEADERS_ENABLED
o HEADERS_HIDDEN
, puedes llamar a setHeadersTransitionOnBackEnabled()
para admitir el regreso al encabezado de la fila desde un elemento de contenido seleccionado en la fila. Esta opción está habilitada de forma predeterminada si no llamas al método. Para controlar el movimiento de retroceso por tu cuenta, pasa false
a setHeadersTransitionOnBackEnabled()
e implementa tu propio control de la pila de actividades.
Cómo mostrar listas de medios
La clase BrowseSupportFragment
te permite definir y mostrar elementos multimedia y categorías de contenido multimedia explorables de un catálogo de contenido multimedia mediante adaptadores y presentadores. Los adaptadores te permiten conectarte a fuentes de datos locales o en línea que contienen la información de tu catálogo de medios.
Los adaptadores usan presentadores para crear vistas y vincular datos a esas vistas para mostrar un elemento en pantalla.
En el siguiente código de ejemplo, se muestra la implementación de un Presenter
para mostrar datos de cadena:
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 } }
Una vez que hayas creado una clase de presentador para tus elementos multimedia, puedes crear un adaptador y adjuntarlo al BrowseSupportFragment
para mostrar esos elementos en pantalla de modo que el usuario pueda navegar por ellos. En el siguiente código de ejemplo, se muestra cómo construir un adaptador para mostrar categorías y elementos en esas categorías con 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 aplicación típica de navegación de medios 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 Leanback.
Cómo actualizar el fondo
Para agregar interés visual a una app de navegación de contenido multimedia en la TV, puedes actualizar la imagen de fondo a medida que los usuarios exploran el contenido. Esta técnica puede hacer que la interacción con tu app sea más cinemática y agradable.
La biblioteca de compatibilidad Leanback proporciona una clase BackgroundManager
para cambiar 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 dentro 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 apps de navegación multimedia actualizan automáticamente el fondo a medida que el usuario navega por las fichas de contenido multimedia. Para ello, puedes configurar un objeto de escucha de selección a fin de actualizar automáticamente el fondo según la selección actual del usuario. En el siguiente ejemplo, se muestra cómo configurar una clase OnItemViewSelectedListener
para detectar 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 es un ejemplo simple a modo de ilustración. Cuando crees esta función en tu propia app, ejecuta la acción de actualización en segundo plano en un subproceso separado para obtener un mejor rendimiento. Además, si planeas actualizar el fondo cuando los usuarios se desplazan por los elementos, agrega 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.