Criar um navegador de catálogo

Um app de mídia executado em uma TV precisa permitir que os usuários naveguem por ofertas de conteúdo, façam uma seleção e comecem a reproduzir conteúdo. A experiência de navegação em conteúdo para apps desse tipo precisa ser simples e intuitiva, além de visualmente agradável e envolvente.

Esta lição aborda como usar as classes fornecidas pela biblioteca de androidx Leanback para implementar uma interface do usuário para a navegação por músicas ou vídeos a partir do catálogo de mídia do app.

Observação: o exemplo de implementação mostrado aqui usa uma BrowseSupportFragment em vez da classe obsoleta BrowseFragment. A BrowseSupportFragment estende a classe Fragment do AndroidX, que garantirá um comportamento consistente em todos os dispositivos e versões do Android.

Tela principal do app

Figura 1. O fragmento de navegação do app de amostra da Leanback exibe dados do catálogo de vídeo.

Criar um layout de navegação em mídia

A classe BrowseSupportFragment da biblioteca Leanback permite a criação de um layout primário para a navegação em categorias e linhas de itens de mídia com pouquíssimo código. O exemplo a seguir mostra como criar um layout que contenha um 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>
    

A principal atividade do aplicativo define essa exibição, como mostrado no exemplo a seguir:

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);
        }
    ...
    

Os métodos BrowseSupportFragment preenchem a exibição com dados de vídeo e elementos de IU, definindo parâmetros do layout como ícone, título e se os cabeçalhos de categoria são ativados.

A subclasse do aplicativo que implementa os métodos BrowseSupportFragment também configura listeners de eventos para ações do usuário nos elementos da IU e prepara o gerenciador de segundo plano, como mostrado no seguinte exemplo:

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 headers 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 headers 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());
        }
    ...
    

Definir elementos da IU

Na amostra acima, o método privado setupUIElements() chama diversos métodos BrowseSupportFragment para estilizar o navegador de catálogo de mídia:

  • setBadgeDrawable() posiciona o recurso desenhável no canto superior direito do fragmento de navegação, como mostrado nas Figuras 1 e 2. Esse método substituirá a string do título pelo recurso desenhável se setTitle() também for chamado. O recurso drawable precisa ter 52 dps de altura.
  • setTitle() define a string do título no canto superior direito do fragmento de navegação, a não ser que setBadgeDrawable() seja chamado.
  • setHeadersState() e setHeadersTransitionOnBackEnabled() ocultam ou desativam os cabeçalhos. Consulte Ocultar ou desativar cabeçalhos para ver mais informações.
  • setBrandColor() define a cor de fundo para elementos de IU no fragmento de navegação, em particular, a cor de fundo da seção do cabeçalho com o valor da cor especificado.
  • setSearchAffordanceColor() define a cor do ícone de pesquisa com o valor da cor especificado. O ícone de pesquisa aparece no canto superior esquerdo do fragmento de navegação, como mostrado nas figuras 1 e 2.

Personalizar as visualizações de cabeçalho

O fragmento de navegação mostrado na figura 1 lista os nomes das categorias de vídeo (os cabeçalhos de linha) no painel esquerdo. As visualizações de texto mostram esses nomes de categoria do banco de dados de vídeos. O cabeçalho pode ser personalizado para incluir visualizações adicionais em um layout mais complexo. As seções a seguir mostram como incluir uma visualização de imagem que mostre um ícone ao lado do nome da categoria, como mostrado na figura 2.

Tela principal do app

Figura 2. Os cabeçalhos de linha no fragmento de navegação, com um ícone e um rótulo de texto.

O layout para o cabeçalho da linha é definido da seguinte forma:

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

Use um Presenter e implemente os métodos abstratos para criar, vincular e desvincular o fixador de visualização. O exemplo seguinte mostra como vincular o fixador de visualização com duas exibições, uma ImageView e uma 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
        }
    }
    

Seus cabeçalhos precisam ser focalizáveis para que o D-pad possa ser usado para navegar por eles. Existem duas alternativas:

  • Configure a visualização para ser focalizável em 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
            //...
        }
        
  • Configure o layout para ser focalizável:
    <?xml version="1.0" encoding="utf-8"?>
        <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
           ...
           android:focusable="true">

Por fim, na implementação de BrowseSupportFragment que exibe o navegador de catálogo, use o método setHeaderPresenterSelector() para definir o apresentador para o cabeçalho da linha, como mostrado no exemplo a seguir.

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 um exemplo completo, consulte o app de amostra Android Leanback no repositório da Android TV do GitHub (link em inglês).

Ocultar ou desativar cabeçalhos

Algumas vezes, você não quer que os cabeçalhos de linha apareçam, por exemplo, quando não há categorias suficientes para exigir uma lista de rolagem. Chame o método BrowseSupportFragment.setHeadersState() durante o método onActivityCreated() do fragmento para ocultar ou desativar os cabeçalhos de linha. O método setHeadersState() define o estado inicial dos cabeçalhos no fragmento de navegação dada uma das seguintes constantes como um parâmetro:

  • HEADERS_ENABLED: quando a atividade de fragmento de navegação é criada, os cabeçalhos são ativados e mostrados por padrão. Os cabeçalhos aparecem como mostrado nas figuras 1 e 2 desta página.
  • HEADERS_HIDDEN: quando a atividade de fragmento de navegação é criada, os cabeçalhos são ativados e ocultados por padrão. A seção do cabeçalho da tela é recolhida, como mostrado na Figura 1 de Como oferecer uma visualização de card. O usuário pode selecionar a seção do cabeçalho recolhida para expandi-la.
  • HEADERS_DISABLED: quando a atividade de fragmento de navegação é criada, os cabeçalhos são desativados por padrão e nunca são exibidos.

Se HEADERS_ENABLED ou HEADERS_HIDDEN forem definidos, você pode chamar setHeadersTransitionOnBackEnabled() para oferecer compatibilidade à movimentação de retorno do cabeçalho de um item de conteúdo selecionado na linha. Isso é ativado por padrão (se você não chamar o método), mas, se quiser gerenciar o movimento de retorno, transmita o valor false a setHeadersTransitionOnBackEnabled() e implemente seu tratamento da pilha de retorno.

Exibir listas de mídia

A classe BrowseSupportFragment permite definir e exibir categorias de conteúdo de mídia navegáveis, além de itens de um catálogo de mídia usando adaptadores e apresentadores. Adaptadores permitem a conexão a recursos de dados locais ou on-line que contêm as informações do catálogo de mídia. Adaptadores usam apresentadores para criar exibições e vincular dados a elas para exibir um item na tela.

O código a seguir mostra a implementação de um Presenter para exibir dados da 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.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
        }
    }
    

Depois de construir uma classe de apresentador para os itens de mídia, você poderá construir um adaptador e anexá-lo ao BrowseSupportFragment para exibir esses itens na tela para navegação pelo usuário. O código a seguir demonstra como construir um adaptador para exibir categorias e itens usando a classe StringPresenter, mostrada no 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 exemplo mostra uma implementação estática dos adaptadores. Um aplicativo típico de navegação em mídia usa dados de um banco de dados on-line ou serviço da Web. Para ver um exemplo de aplicativo de navegação que usa dados recuperados da Web, consulte o app de amostra Android Leanback no repositório da Android TV do GitHub (link em inglês).

Atualizar a imagem de plano de fundo

Para agregar interesse visual a um app de navegação de mídia na TV, a imagem de plano de fundo pode ser atualizada conforme os usuários navegam pelo conteúdo. Essa técnica pode tornar a interação com o app mais cinemática e agradável.

A Biblioteca de Suporte Leanback oferece uma classe BackgroundManager para mudar o plano de fundo da atividade do app para TV. O exemplo a seguir mostra como criar um método simples para atualizar a imagem de plano de fundo dentro da atividade do 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);
    }
    

Diversos apps de navegação em mídia atualizam automaticamente a imagem de plano de fundo conforme o usuário navega por listagens de mídia. Para fazer isso, você pode definir um listener de seleção para atualizar automaticamente a imagem de plano de fundo com base na seleção atual do usuário. O exemplo a seguir mostra como definir uma classe OnItemViewSelectedListener para capturar eventos selecionados e atualizar a imagem de plano de fundo:

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();
                }
            }
        };
    }
    

Observação: a implementação acima é um exemplo simples mostrado para fins de ilustração. Para melhor desempenho, ao criar essa função no app, recomendamos considerar a execução da ação de atualização da imagem de plano de fundo em uma linha de execução separada. Além disso, se estiver planejando atualizar a imagem de plano de fundo em resposta à rolagem dos usuários pelos itens, considere adicionar um tempo para retardar a atualização até que o usuário pare em um item. Essa técnica evita o excesso de atualizações de imagens de plano de fundo.