Cómo crear un navegador de catálogos

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.

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 BrowseFragment de la biblioteca Leanback te permite crear un diseño principal para la navegación por categorías y filas de elementos multimedia con codificación mínima. En el siguiente ejemplo, se muestra cómo crear un diseño que contenga el objeto BrowseFragment:

    <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 BrowseFragment completan la vista con los datos de video y elementos de IU. También establecen los parámetros de diseño, como el ícono o el título, y si están activados los encabezados de categorías.

La subclase de la aplicación que implementa los métodos BrowseFragment 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 : BrowseFragment(),
            LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> {
        ...
        override fun onActivityCreated(savedInstanceState: Bundle?) {
            super.onActivityCreated(savedInstanceState)

            loadVideoData()

            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 = resources.getColor(R.color.fastlane_background)
            // set search icon color
            searchAffordanceColor = resources.getColor(R.color.search_opaque)
        }

        private fun loadVideoData() {
            VideoProvider.setContext(activity)
            videosUrl = resources.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 BrowseFragment implements
            LoaderManager.LoaderCallbacks<HashMap<String, List<Movie>>> {
    }
    ...

        @Override
        public void onActivityCreated(Bundle savedInstanceState) {
            super.onActivityCreated(savedInstanceState);

            loadVideoData();

            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(getResources().getColor(R.color.fastlane_background));
            // set search icon color
            setSearchAffordanceColor(getResources().getColor(R.color.search_opaque));
        }

        private void loadVideoData() {
            VideoProvider.setContext(getActivity());
            videosUrl = getActivity().getResources().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 muchos de los métodos BrowseFragment para personalizar 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 string del título con el recurso de elemento de diseño, si también se llama a setTitle(). 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 a setBadgeDrawable().
  • setHeadersState() y setHeadersTransitionOnBackEnabled() 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.

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 contenedor de vistas 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 BrowseFragment 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 BrowseFragment.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 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 BrowseFragment 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.getContext().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 BrowseFragment 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))
            }
        }
        browseFragment.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));
        }

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