Cómo crear un navegador de catálogos

Compila mejor con Compose
Crea IU atractivas con muy poco código usando Jetpack Compose para el SO Android TV.

Una aplicación multimedia que se ejecute en una TV debe permitir a los usuarios explorar sus ofertas de contenido, hacer una y comenzar a reproducir contenido. La experiencia de navegación de 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 AndroidX.Leanback para implementar una interfaz de usuario que permita explorar música o videos del catálogo multimedia de tu app.

Nota: En el ejemplo de implementación que se muestra aquí, se usan BrowseSupportFragment en lugar de la versión obsoleta de BrowseFragment . BrowseSupportFragment extiende AndroidX. clase Fragment, lo que ayuda a 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

El BrowseSupportFragment en el kit de herramientas de la IU de Leanback te permite crear un diseño principal para la navegación por categorías y filas de elementos multimedia con una mínimo de código. En el siguiente ejemplo, se muestra cómo crear un diseño que contenga un elemento 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 el datos de video y elementos de la interfaz de usuario, y configurar parámetros de diseño, como el ícono, el título y si los encabezados de categoría están habilitados.

Para obtener más información sobre cómo configurar elementos de la IU, consulta Cómo establecer la IU Elementos de seguridad. Para obtener más información sobre cómo ocultar los encabezados, consulta la Oculta o inhabilita encabezados.

La subclase de la aplicación que implementa BrowseSupportFragment también configuran 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 varias BrowseSupportFragment métodos para definir el estilo del 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 en las figuras 1 y 2. Este método reemplaza la cadena del título por el elemento de diseño, si también se llama a setTitle(). El recurso de elemento de diseño debe ser de 52 dp. alto.
  • setTitle() establece la cadena de título en la esquina superior derecha del fragmento de navegación, a menos que Se llama 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 encabezado. sección, 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 las filas en la base de datos de videos, en vistas de texto. También puedes personalizar el para incluir vistas adicionales en un diseño más complejo. En las siguientes secciones, se muestra cómo incluir una vista de imagen que muestre un ícono junto al nombre de la categoría, como puede verse 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 y, luego, implementa métodos abstractos para crear, vincular y desvincular el contenedor de vistas. Lo siguiente ejemplo muestra cómo vincular el viewholder con dos vistas, un 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 el pad direccional pueda usarse para lo siguiente: te desplazas por ellas. Existen dos maneras de administrar esto:

  • Establece 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 elemento navegador de catálogos, usa el 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 de Google Cloud.

Cómo ocultar o inhabilitar encabezados

A veces no quieres que aparezcan los encabezados de fila, por ejemplo, cuando no hay suficientes categorías requieren una lista desplazable. Llama al BrowseSupportFragment.setHeadersState() durante el método onActivityCreated() del fragmento para ocultar o inhabilitar los encabezados de fila. El setHeadersState() establece el estado inicial de los encabezados en el fragmento de navegación, según una de las siguientes opciones: constantes como un parámetro:

  • HEADERS_ENABLED: Cuando se crea la actividad del fragmento de navegación, los encabezados están habilitados y los 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 encabezado de la pantalla está contraída, como se muestra en una figura en Proporciona una vista de tarjetas. El usuario puede seleccionar la sección contraída del encabezado para expandirla.
  • HEADERS_DISABLED: Cuando se crea la actividad del fragmento de navegación, los encabezados están inhabilitados de forma predeterminada y se nunca se muestra.

Si se establece 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. Esto lo habilita la configuración predeterminada si no llamas al método. Para controlar el movimiento de espalda, pasar false a setHeadersTransitionOnBackEnabled() e implementar tu propio manejo de la pila de actividades.

Cómo mostrar listas de medios

El BrowseSupportFragment te permite definir y mostrar elementos multimedia y categorías de contenido multimedia explorables desde un catálogo de medios con adaptadores y presentadores. Los adaptadores te permiten conectarte a fuentes de datos locales o en línea que contengan 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, puedes crear un adaptador y conéctalo al BrowseSupportFragment para mostrar esos elementos en la pantalla para que el usuario navegue. El siguiente ejemplo código demuestra cómo construir un adaptador para mostrar categorías y elementos en esas categorías con la clase StringPresenter que se muestra 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. Veamos un ejemplo de una aplicación de navegación que usa datos recuperados de la Web, consulta el App de ejemplo de Leanback de Google Cloud.

Cómo actualizar el fondo

Para agregar interés visual a una app de navegación de contenido multimedia en la TV, puedes actualizar el fondo mientras los usuarios navegan por el contenido. Esta técnica puede hacer que la interacción con tu app sea más cinematográfico y agradable.

El kit de herramientas de la IU de Leanback proporciona un BackgroundManager. para cambiar el fondo de la actividad de tu aplicación para TV. En el siguiente ejemplo, se muestra cómo Crea 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 apps de navegación de contenido multimedia actualizan automáticamente el fondo a medida que el usuario navega. a través de listas de medios. Para ello, puedes configurar un objeto de escucha de selección para que automáticamente actualizar el fondo en función de la selección actual del usuario. En el siguiente ejemplo, se muestra cómo para configurar una clase OnItemViewSelectedListener 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 para los fines de ilustración. Cuando crees esta función en tu propia app, ejecuta el actualización del fondo en un subproceso independiente para mejorar el rendimiento Además, si planeas actualizar el fondo en respuesta a los usuarios que se desplazan por los elementos, agregar un tiempo para retrasar la actualización de una imagen de fondo hasta que el usuario se detenga en un elemento. Con esta técnica, se evita actualizaciones excesivas de imágenes de fondo