Cómo crear un navegador de catálogos

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.

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 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.

Si deseas obtener más información para configurar elementos de la IU, consulta la sección Cómo establecer elementos de IU. Si quieres obtener más información para 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 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 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 muestra 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 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.