Criar um navegador de catálogo

Criar da melhor forma com o Compose
Crie interfaces incríveis com o mínimo de código usando o Jetpack Compose para o SO do Android TV.

Um app de mídia executado em uma TV precisa permitir que os usuários naveguem pelas ofertas de conteúdo, e começar a reproduzir o conteúdo. A experiência de navegação de conteúdo deve ser simples e intuitivo, além de visualmente agradável e envolvente.

Este guia discute como usar as classes fornecidas pela biblioteca androidx.androidx para implementar uma interface do usuário e navegar por músicas ou vídeos no catálogo de mídia do app.

Observação:o exemplo de implementação mostrado aqui usa BrowseSupportFragment em vez do BrowseFragment descontinuado . BrowseSupportFragment estende o AndroidX classe Fragment, ajudando a 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 exemplo do YouTube Leanback exibe dados do catálogo de vídeo.

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

O BrowseSupportFragment classe no kit de ferramentas de interface do Leanback no kit de ferramentas de interface do usuário permite criar um layout primário para navegar em categorias e linhas de itens de mídia com um mínimo de código. O exemplo a seguir mostra como criar um layout que contém 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 visualização com as dados de vídeo e elementos da interface do usuário e definir parâmetros de layout, como ícone e título e se os cabeçalhos de categoria estão ativados.

Para mais informações sobre a configuração de elementos de interface do usuário, consulte a seção Definir interface de linha do tempo. Para obter mais informações sobre como ocultar os cabeçalhos, consulte a Seção Ocultar ou desativar cabeçalhos.

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

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

Definir elementos da IU

Na amostra anterior, o método particular setupUIElements() chama várias BrowseSupportFragment métodos para estilizar o navegador de catálogo de mídia:

  • setBadgeDrawable() coloca o recurso desenhável especificado no canto superior direito do fragmento de navegação, conforme como mostrado nas figuras 1 e 2. Esse método substitui a string do título pelo recurso drawable, se setTitle() também for chamado. O recurso drawable precisa ter 52 dp de altura.
  • setTitle() define a string do título no canto superior direito do fragmento de navegação, a menos que setBadgeDrawable() seja chamado.
  • setHeadersState() e setHeadersTransitionOnBackEnabled() ocultam ou desativam os cabeçalhos. Consulte a seção Ocultar ou desativar cabeçalhos para mais informações.
  • setBrandColor() define a cor do plano de fundo para elementos da interface no fragmento de navegação, especificamente o cabeçalho cor de fundo da seção, com o valor de 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 mostra os nomes das categorias de vídeo, que são os cabeçalhos de linha no banco de dados de vídeos, em visualizações de texto. Também é possível personalizar para incluir visualizações adicionais em um layout mais complexo. As seções a seguir mostram como inclua 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 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 métodos abstratos para criar, vincular e desvincular o armazenador de visualização. O seguinte mostra como vincular o fixador de visualização com duas visualizações, uma ImageView e um 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
    }
}

Os cabeçalhos devem ser focalizáveis de modo que o D-pad possa ser usado para navegar por elas. Há duas maneiras de gerenciar isso:

  • Configure sua 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 mostra o navegador de catálogo, use o comando 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 um exemplo completo, consulte a App de amostra do Leanback ,

Ocultar ou desativar cabeçalhos

Às vezes, você não quer que os cabeçalhos das linhas apareçam, por exemplo, quando não há espaço exigirem uma lista rolável. Chamar BrowseSupportFragment.setHeadersState() método durante onActivityCreated() do fragmento para ocultar ou desativar os cabeçalhos das linhas. O setHeadersState() define o estado inicial dos cabeçalhos no fragmento de navegação, considerando um dos seguintes constantes como 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 em uma figura em Oferecer uma visualização de card. A 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 nunca são exibidos.

Se HEADERS_ENABLED ou HEADERS_HIDDEN estiver definido, será possível chamar setHeadersTransitionOnBackEnabled() para oferecer suporte ao retorno do cabeçalho de um item de conteúdo selecionado na linha. Isso é ativado pela se você não chamar o método. Para lidar com o movimento para trás, Transmitir false para setHeadersTransitionOnBackEnabled() e implementar seu próprio gerenciamento da backstack.

Exibir listas de mídia

O BrowseSupportFragment permite que você definir e exibir categorias de conteúdo de mídia navegáveis e itens de mídia do um catálogo de mídia usando adaptadores e apresentadores. Adaptadores permitem que você se conecte a fontes de dados locais ou on-line que contêm as informações do seu catálogo de mídia. Adaptadores usam apresentadores para criar visualizações e vincular dados a elas para mostrando um item na tela.

O código de exemplo abaixo mostra uma implementação de Presenter para mostrar 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, é possível criar um adaptador e o anexar ao BrowseSupportFragment. para exibir esses itens na tela para navegação pelo usuário. O exemplo a seguir o código demonstra como construir um adaptador para exibir categorias e itens dessas categorias usando a classe StringPresenter mostrada no exemplo 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 exemplo mostra uma implementação estática dos adaptadores. Um aplicativo típico de navegação de mídia usa dados de um banco de dados on-line ou serviço da Web. Para um exemplo de um aplicativo de navegação que usa dados recuperados da Web, consulte a App de amostra do Leanback ,

Atualizar a imagem de plano de fundo

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

O kit de ferramentas de interface do Leanback oferece um BackgroundManager para alterar o plano de fundo da atividade do app para TV. O exemplo abaixo mostra como crie um método simples para atualizar o plano de fundo na 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);
}

Muitos apps de navegação de mídia atualizam automaticamente o plano de fundo à medida que o usuário navega usando listagens de mídia. Para fazer isso, você pode configurar um listener de seleção para automaticamente atualiza o plano de fundo com base na seleção atual do usuário. O exemplo a seguir mostra como para configurar uma classe OnItemViewSelectedListener para capturar eventos selecionados e atualizar o 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 anterior é um exemplo simples para os fins de ilustração. Ao criar essa função no seu app, execute o ação de atualização em segundo plano em uma linha de execução separada para melhor desempenho. Além disso, se você planeja atualizar o plano de fundo em resposta à rolagem dos usuários pelos itens, adicionar um tempo para atrasar a atualização da imagem de plano de fundo até que o usuário se adapte a um item. Essa técnica evita atualizações excessivas de imagem de plano de fundo.