Tạo trình duyệt danh mục

Tạo dựng ứng dụng hiệu quả hơn bằng Compose
Tạo giao diện người dùng đẹp mắt với mã tối thiểu bằng Jetpack Compose cho hệ điều hành Android TV.

Ứng dụng đa phương tiện chạy trên TV cần cho phép người dùng duyệt xem các nội dung mà ứng dụng cung cấp, chọn nội dung và bắt đầu phát nội dung. Trải nghiệm duyệt xem nội dung phải đơn giản, trực quan, đồng thời hấp dẫn và bắt mắt.

Hướng dẫn này thảo luận về cách sử dụng các lớp do thư viện androidx.leanback không dùng nữa cung cấp để triển khai giao diện người dùng nhằm duyệt xem nhạc hoặc video trong danh mục nội dung nghe nhìn của ứng dụng.

Lưu ý: Ví dụ về cách triển khai được trình bày ở đây sử dụng BrowseSupportFragment thay vì lớp BrowseFragment không dùng nữa. BrowseSupportFragment mở rộng lớp AndroidX Fragment, giúp đảm bảo hành vi nhất quán trên các thiết bị và phiên bản Android.

Màn hình chính của ứng dụng

Hình 1. Mảnh duyệt xem của ứng dụng mẫu Leanback hiển thị dữ liệu danh mục video.

Tạo bố cục duyệt xem nội dung nghe nhìn

Lớp BrowseSupportFragment trong bộ công cụ Leanback UI cho phép bạn tạo bố cục chính để duyệt xem các danh mục và hàng của mục nội dung nghe nhìn với mã tối thiểu. Ví dụ sau đây minh hoạ cách tạo một bố cục chứa đối tượng 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>

Hoạt động chính của ứng dụng sẽ đặt khung hiển thị này, như minh hoạ trong ví dụ sau:

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

Các phương thức BrowseSupportFragment sẽ điền dữ liệu video và các phần tử trên giao diện người dùng vào khung hiển thị, đồng thời đặt các tham số bố cục như biểu tượng và tiêu đề, cũng như cho biết có bật tiêu đề danh mục hay không.

Để biết thêm thông tin về cách thiết lập các phần tử trên giao diện người dùng, hãy xem phần Thiết lập các phần tử trên giao diện người dùng. Để biết thêm thông tin về cách ẩn tiêu đề, hãy xem phần Ẩn hoặc tắt tiêu đề.

Lớp con của ứng dụng triển khai các phương thức BrowseSupportFragment cũng thiết lập trình nghe sự kiện cho các hành động của người dùng trên các phần tử giao diện người dùng và chuẩn bị trình quản lý ở chế độ nền, như minh hoạ trong ví dụ sau:

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

Đặt các phần tử trên giao diện người dùng

Trong mẫu trước đó, phương thức riêng tư setupUIElements() gọi một số phương thức BrowseSupportFragment để tạo kiểu cho trình duyệt danh mục nội dung nghe nhìn:

  • setBadgeDrawable() đặt tài nguyên có thể vẽ được đã chỉ định ở góc trên bên phải của mảnh duyệt qua, như minh hoạ trong hình 1 và 2. Phương thức này sẽ thay thế chuỗi tiêu đề bằng tài nguyên có thể vẽ, nếu setTitle() cũng được gọi. Tài nguyên có thể vẽ phải cao 52 dp.
  • setTitle() đặt chuỗi tiêu đề ở góc trên cùng bên phải của đoạn duyệt qua, trừ phi setBadgeDrawable() được gọi.
  • setHeadersState()setHeadersTransitionOnBackEnabled() ẩn hoặc tắt tiêu đề. Hãy xem phần Ẩn hoặc tắt tiêu đề để biết thêm thông tin.
  • setBrandColor() đặt màu nền cho các phần tử trên giao diện người dùng trong mảnh duyệt qua, cụ thể là màu nền của phần tiêu đề, với giá trị màu được chỉ định.
  • setSearchAffordanceColor() đặt màu cho biểu tượng tìm kiếm bằng giá trị màu đã chỉ định. Biểu tượng tìm kiếm xuất hiện ở góc trên bên trái của đoạn duyệt xem, như minh hoạ trong hình 1 và 2.

Tuỳ chỉnh chế độ xem tiêu đề

Đoạn duyệt xem trong hình 1 hiển thị tên danh mục video (là tiêu đề hàng trong cơ sở dữ liệu video) ở dạng khung hiển thị văn bản. Bạn cũng có thể tuỳ chỉnh tiêu đề để thêm các khung hiển thị khác vào một bố cục phức tạp hơn. Các phần sau đây cho biết cách thêm một khung hiển thị hình ảnh để hiển thị biểu tượng bên cạnh tên danh mục, như minh hoạ trong hình 2.

Màn hình chính của ứng dụng

Hình 2. Tiêu đề hàng trong mảnh duyệt qua có cả biểu tượng và nhãn văn bản.

Bố cục cho tiêu đề hàng được xác định như sau:

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

Sử dụng Presenter và triển khai các phương thức trừu tượng để tạo, liên kết và huỷ liên kết trình giữ khung hiển thị. Ví dụ sau đây minh hoạ cách liên kết trình giữ chế độ xem với 2 chế độ xem, một ImageView và một 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
    }
}

Tiêu đề của bạn phải có thể lấy tiêu điểm để bạn có thể dùng nút định hướng để di chuyển qua các tiêu đề đó. Có hai cách để quản lý việc này:

  • Đặt chế độ xem của bạn thành có thể lấy tiêu điểm trong 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
        // ...
    }
  • Đặt bố cục để có thể lấy tiêu điểm:
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
       ...
       android:focusable="true">

Cuối cùng, trong quá trình triển khai BrowseSupportFragment hiển thị trình duyệt danh mục, hãy dùng phương thức setHeaderPresenterSelector() để đặt trình bày cho tiêu đề hàng, như minh hoạ trong ví dụ sau.

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

Để xem ví dụ hoàn chỉnh, hãy xem Ứng dụng mẫu Leanback .

Ẩn hoặc tắt tiêu đề

Đôi khi, bạn không muốn tiêu đề hàng xuất hiện, chẳng hạn như khi không có đủ danh mục để yêu cầu danh sách có thể di chuyển. Gọi phương thức BrowseSupportFragment.setHeadersState() trong phương thức onActivityCreated() của phân đoạn để ẩn hoặc tắt tiêu đề hàng. Phương thức setHeadersState() đặt trạng thái ban đầu của tiêu đề trong mảnh duyệt qua, cho trước một trong các hằng số sau đây làm tham số:

  • HEADERS_ENABLED: khi hoạt động của mảnh duyệt qua được tạo, tiêu đề sẽ được bật và hiển thị theo mặc định. Tiêu đề xuất hiện như minh hoạ trong hình 1 và 2 trên trang này.
  • HEADERS_HIDDEN: khi hoạt động của mảnh duyệt qua được tạo, tiêu đề sẽ được bật và ẩn theo mặc định. Phần tiêu đề của màn hình bị thu gọn, như minh hoạ trong một hình trong phần Cung cấp chế độ xem thẻ. Người dùng có thể chọn phần tiêu đề được thu gọn để mở rộng phần đó.
  • HEADERS_DISABLED: khi hoạt động của mảnh duyệt qua được tạo, tiêu đề sẽ bị tắt theo mặc định và không bao giờ xuất hiện.

Nếu bạn đặt HEADERS_ENABLED hoặc HEADERS_HIDDEN, bạn có thể gọi setHeadersTransitionOnBackEnabled() để hỗ trợ việc di chuyển trở lại tiêu đề hàng từ một mục nội dung đã chọn trong hàng. Chế độ này được bật theo mặc định nếu bạn không gọi phương thức. Để tự xử lý thao tác quay lại, hãy truyền false đến setHeadersTransitionOnBackEnabled() và triển khai quy trình xử lý ngăn xếp lui của riêng bạn.

Hiển thị danh sách nội dung nghe nhìn

Lớp BrowseSupportFragment cho phép bạn xác định và hiển thị các danh mục nội dung đa phương tiện có thể duyệt xem và các mục nội dung đa phương tiện trong danh mục nội dung đa phương tiện bằng cách sử dụng các bộ chuyển đổi và trình bày. Bộ chuyển đổi cho phép bạn kết nối với các nguồn dữ liệu tại địa phương hoặc trực tuyến có chứa thông tin danh mục nội dung nghe nhìn của bạn. Trình chuyển đổi sử dụng trình trình bày để tạo các khung hiển thị và liên kết dữ liệu với các khung hiển thị đó để hiển thị một mục trên màn hình.

Đoạn mã ví dụ sau đây cho thấy cách triển khai Presenter để hiển thị dữ liệu chuỗi:

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

Sau khi tạo một lớp trình bày cho các mục nội dung nghe nhìn, bạn có thể tạo một bộ chuyển đổi và đính kèm bộ chuyển đổi đó vào BrowseSupportFragment để hiển thị các mục đó trên màn hình cho người dùng duyệt xem. Đoạn mã ví dụ sau đây minh hoạ cách tạo một bộ chuyển đổi để hiển thị các danh mục và mặt hàng trong những danh mục đó bằng cách sử dụng lớp StringPresenter xuất hiện trong đoạn mã ví dụ trước:

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

Ví dụ này cho thấy một cách triển khai tĩnh của các bộ chuyển đổi. Một ứng dụng duyệt qua nội dung nghe nhìn thông thường sử dụng dữ liệu từ cơ sở dữ liệu trực tuyến hoặc dịch vụ web. Để biết ví dụ về một ứng dụng duyệt web sử dụng dữ liệu được truy xuất từ web, hãy xem ứng dụng mẫu Leanback.

Cập nhật nền

Để tăng tính hấp dẫn về mặt thị giác cho một ứng dụng duyệt xem nội dung nghe nhìn trên TV, bạn có thể cập nhật hình nền khi người dùng duyệt xem nội dung. Kỹ thuật này có thể giúp hoạt động tương tác với ứng dụng của bạn trở nên điện ảnh và thú vị hơn.

Bộ công cụ giao diện người dùng Leanback cung cấp một lớp BackgroundManager để thay đổi nền của hoạt động ứng dụng truyền hình. Ví dụ sau đây cho thấy cách tạo một phương thức đơn giản để cập nhật nền trong hoạt động của ứng dụng truyền hình:

Kotlin

protected fun updateBackground(drawable: Drawable) {
    BackgroundManager.getInstance(this).drawable = drawable
}

Java

protected void updateBackground(Drawable drawable) {
    BackgroundManager.getInstance(this).setDrawable(drawable);
}

Nhiều ứng dụng duyệt xem nội dung nghe nhìn tự động cập nhật nền khi người dùng di chuyển qua danh sách nội dung nghe nhìn. Để làm việc này, bạn có thể thiết lập một trình nghe lựa chọn để tự động cập nhật nền dựa trên lựa chọn hiện tại của người dùng. Ví dụ sau đây cho thấy cách thiết lập một lớp OnItemViewSelectedListener để nắm bắt các sự kiện chọn và cập nhật nền:

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

Lưu ý: Cách triển khai trước đó là một ví dụ đơn giản chỉ nhằm mục đích minh hoạ. Khi tạo hàm này trong ứng dụng của riêng bạn, hãy chạy thao tác cập nhật trong nền ở một luồng riêng biệt để có hiệu suất tốt hơn. Ngoài ra, nếu bạn dự định cập nhật nền để phản hồi thao tác cuộn của người dùng qua các mục, hãy thêm thời gian để trì hoãn việc cập nhật hình nền cho đến khi người dùng chọn một mục. Kỹ thuật này giúp tránh việc cập nhật hình ảnh nền quá mức.