Cómo crear un navegador de catálogos

Una app de música que se ejecuta en una TV debe permitir que los usuarios exploren su oferta de contenido, seleccionen contenido y comiencen a reproducirlo. 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 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, lo que ayuda a garantizar un comportamiento coherente en todos los dispositivos y las versiones de Android.

Pantalla principal de la app

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 de la biblioteca Leanback te permite crear un diseño principal para navegar 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, y establecen parámetros de diseño, como el ícono y el título, y si los encabezados de categorías están habilitados.

Si deseas obtener más información para configurar elementos de la IU, consulta la sección Cómo configurar elementos de IU. Para obtener más información sobre cómo ocultar los encabezados, consulta la sección Cómo ocultar o inhabilitar 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 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 a setTitle(). 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 a setBadgeDrawable().
  • setHeadersState() y setHeadersTransitionOnBackEnabled() 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 observa en la figura 2.

Pantalla principal de la app

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 viewport 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 puedas usar el pad direccional para desplazarte por ellos. Existen dos maneras de gestionar esto:

  • 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 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 Android Leanback en el repositorio de GitHub de Android TV.

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 requerir una lista desplazable. Llama al método BrowseSupportFragment.setHeadersState() durante el método onActivityCreated() del fragmento para ocultar o inhabilitar los encabezados de fila. 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 una figura de la sección Cómo proporcionar una vista de tarjetas. El usuario puede seleccionar la sección contraída de encabezado 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, pasa false a setHeadersTransitionOnBackEnabled() e implementa tu propio control de 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 desde un catálogo 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 una 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, 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 código de ejemplo, 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 aplicación típica de navegación multimedia usa datos de una base de datos en línea o 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 contenido multimedia en una TV, puedes actualizar la imagen de fondo a medida que los usuarios navegan por 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 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 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 de contenido multimedia actualizan automáticamente el fondo a medida que el usuario navega por las listas de contenido multimedia. Para ello, puedes configurar un objeto de escucha de selección a fin de que actualice 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 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: Por motivos de ejemplo, la implementación anterior es un ejemplo simple. 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 en respuesta a los usuarios que 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.