יצירת דפדפן של קטלוג

פיתוח טוב יותר באמצעות Compose
אפשר ליצור ממשקי משתמש יפים עם מינימום קוד באמצעות Jetpack Compose ל-Android TV OS.

באפליקציית מדיה שפועלת בטלוויזיה, המשתמשים צריכים להיות מסוגלים לעיין בתוכן המוצע, לבחור תוכן ולהתחיל להפעיל אותו. חוויית העיון בתוכן צריכה להיות פשוטה ואינטואיטיבית, וגם מושכת ומהנה מבחינה ויזואלית.

במדריך הזה מוסבר איך להשתמש במחלקות שסופקו על ידי ספריית androidx.leanback שהוצאה משימוש, כדי להטמיע ממשק משתמש לגלישה במוזיקה או בסרטונים מקטלוג המדיה של האפליקציה.

הערה: בדוגמה להטמעה שמוצגת כאן נעשה שימוש ב-BrowseSupportFragment ולא במחלקה BrowseFragment שהוצאה משימוש. ‫BrowseSupportFragment extends the AndroidX Fragment class, helping to ensure consistent behavior across devices and Android versions.

המסך הראשי של האפליקציה

איור 1. בקטע העיון של אפליקציית הדוגמה Leanback מוצגים נתונים של קטלוג סרטונים.

יצירת פריסה של דפדוף במדיה

המחלקות BrowseSupportFragment ב-Leanback UI toolkit מאפשרות ליצור פריסה ראשית לעיון בקטגוריות ובשורות של פריטי מדיה עם מינימום קוד. בדוגמה הבאה אפשר לראות איך ליצור פריסה שמכילה אובייקט 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>

הפעילות הראשית של האפליקציה מגדירה את התצוגה הזו, כמו שמוצג בדוגמה הבאה:

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

השיטות BrowseSupportFragment מאכלסות את התצוגה בנתוני הסרטון וברכיבי ממשק המשתמש, ומגדירות פרמטרים של פריסה כמו הסמל והכותרת, וגם אם כותרות הקטגוריות מופעלות.

מידע נוסף על הגדרת רכיבי ממשק משתמש זמין בקטע הגדרת רכיבי ממשק משתמש. מידע נוסף על הסתרת הכותרות זמין בקטע הסתרה או השבתה של כותרות.

מחלקת המשנה של האפליקציה שמטמיעה את BrowseSupportFragmentהשיטות גם מגדירה מאזינים לאירועים של פעולות משתמש ברכיבי ממשק המשתמש ומכינה את מנהל הרקע, כמו שמוצג בדוגמה הבאה:

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

הגדרת רכיבים בממשק המשתמש

בדוגמה הקודמת, ה-method הפרטי setupUIElements() קורא לכמה BrowseSupportFragment methods כדי לעצב את דפדפן קטלוג המדיה:

  • setBadgeDrawable() מציב את משאב ה-drawable שצוין בפינה השמאלית העליונה של קטע הגלישה, כמו שמוצג באיורים 1 ו-2. השיטה הזו מחליפה את מחרוזת הכותרת בפריט גרפי שניתן להזזה, אם קוראים גם ל-setTitle(). גובה המשאב הגרפי צריך להיות 52dp.
  • setTitle() מגדיר את מחרוזת הכותרת בפינה השמאלית העליונה של קטע הדפדוף, אלא אם מתבצעת קריאה ל-setBadgeDrawable().
  • setHeadersState() ו-setHeadersTransitionOnBackEnabled() מסתירים או משביתים את הכותרות. מידע נוסף זמין בקטע הסתרה או השבתה של כותרות.
  • setBrandColor() מגדיר את צבע הרקע של רכיבי ממשק המשתמש בקטע של הדפדפן, במיוחד את צבע הרקע של קטע הכותרת, עם ערך הצבע שצוין.
  • setSearchAffordanceColor() מגדיר את הצבע של סמל החיפוש עם ערך הצבע שצוין. סמל החיפוש מופיע בפינה הימנית העליונה של קטע הגלישה, כמו שמוצג באיורים 1 ו-2.

התאמה אישית של תצוגות הכותרת

קטע הגלישה שמוצג באיור 1 מציג את שמות קטגוריות הסרטונים, שהם כותרות השורות במסד הנתונים של הסרטונים, בתצוגות טקסט. אפשר גם להתאים אישית את הכותרת כדי לכלול תצוגות נוספות בפריסה מורכבת יותר. בקטעים הבאים מוסבר איך לכלול תצוגת תמונה שמציגה סמל לצד שם הקטגוריה, כמו שמוצג באיור 2.

המסך הראשי של האפליקציה

איור 2. כותרות השורות בקטע הגלישה עם סמל ותווית טקסט.

הפריסה של כותרת השורה מוגדרת כך:

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

משתמשים ב-Presenter ומטמיעים את השיטות המופשטות כדי ליצור את מחזיק התצוגה, לקשר אותו ולבטל את הקישור שלו. בדוגמה הבאה אפשר לראות איך לקשר את מחזיק התצוגה לשתי תצוגות, ImageView ו-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
    }
}

הכותרות צריכות להיות ניתנות למיקוד כדי שאפשר יהיה להשתמש בכפתורי החיצים (D-pad) כדי לגלול ביניהן. יש שתי דרכים לנהל את זה:

  • הגדרת התצוגה כך שאפשר יהיה להתמקד בה ב-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
        // ...
    }
  • הגדרת הפריסה כך שאפשר יהיה להתמקד בה:
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
       ...
       android:focusable="true">

לבסוף, בהטמעה של BrowseSupportFragment שבה מוצג דפדפן הקטלוג, משתמשים בשיטה setHeaderPresenterSelector() כדי להגדיר את רכיב ה-presenter עבור כותרת השורה, כמו שמוצג בדוגמה הבאה.

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

דוגמה מלאה זמינה ב אפליקציה לדוגמה של Leanback.

הסתרה או השבתה של כותרות

לפעמים לא רוצים שהכותרות של השורות יופיעו, למשל כשאין מספיק קטגוריות כדי שיהיה צורך ברשימה שאפשר לגלול בה. מבצעים קריאה ל-BrowseSupportFragment.setHeadersState() במהלך השיטה onActivityCreated() של הפריט כדי להסתיר או להשבית את כותרות השורות. השיטה setHeadersState() מגדירה את המצב הראשוני של הכותרות בקטע של הדפדפן, בהינתן אחת מהקבועים הבאים כפרמטר:

  • HEADERS_ENABLED: כשיוצרים את פעילות הגלישה של קטע, הכותרות מופעלות ומוצגות כברירת מחדל. הכותרות יופיעו כמו באיורים 1 ו-2 בדף הזה.
  • HEADERS_HIDDEN: כשיוצרים את פעילות הגלישה, הכותרות מופעלות ומוסתרות כברירת מחדל. הקטע של הכותרת במסך מכווץ, כמו שמוצג בדמות במאמר הצגת תצוגת כרטיסי מיקום. המשתמש יכול לבחור את קטע הכותרת המכווץ כדי להרחיב אותו.
  • HEADERS_DISABLED: כשפעילות של קטע גלישה נוצרת, הכותרות מושבתות כברירת מחדל והן אף פעם לא מוצגות.

אם אחת מהאפשרויות HEADERS_ENABLED או HEADERS_HIDDEN מוגדרת, אפשר להתקשר אל setHeadersTransitionOnBackEnabled() כדי לתמוך בחזרה לכותרת השורה מפריט תוכן שנבחר בשורה. ההגדרה הזו מופעלת כברירת מחדל אם לא מפעילים את ה-method. כדי לטפל בעצמכם בתנועה אחורה, מעבירים את false אל setHeadersTransitionOnBackEnabled() ומטמיעים את הטיפול שלכם במקבץ הפעילויות הקודמות (back stack).

הצגת רשימות של מדיה

BrowseSupportFragment class מאפשר להגדיר ולהציג קטגוריות של תוכן מדיה ופריטי מדיה מקטלוג מדיה באמצעות מתאמים ורכיבי הצגה. מתאמים מאפשרים לכם להתחבר למקורות נתונים מקומיים או באינטרנט שמכילים את פרטי קטלוג המדיה שלכם. המתאמים משתמשים ברכיבי presenter כדי ליצור תצוגות ולקשר נתונים לתצוגות האלה, כדי להציג פריט במסך.

בדוגמת הקוד הבאה מוצגת הטמעה של Presenter להצגת נתוני מחרוזת:

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

אחרי שיוצרים מחלקה של פריטים להצגה עבור פריטי המדיה, אפשר ליצור מתאם ולצרף אותו ל-BrowseSupportFragment כדי להציג את הפריטים האלה על המסך לצורך עיון של המשתמש. בדוגמת הקוד הבאה מוצג אופן השימוש במחלקה StringPresenter שמופיעה בדוגמת הקוד הקודמת כדי ליצור מתאם להצגת קטגוריות ופריטים בקטגוריות האלה:

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

בדוגמה הזו מוצגת הטמעה סטטית של המתאמים. אפליקציה טיפוסית לעיון במדיה משתמשת בנתונים ממסד נתונים אונליין או משירות אינטרנט. אפליקציה לדוגמה לגלישה באינטרנט שמשתמשת בנתונים שנשלפים מהאינטרנט זמינה בכתובת Leanback sample app .

עדכון הרקע

כדי להוסיף עניין חזותי לאפליקציה לעיון במדיה בטלוויזיה, אפשר לעדכן את תמונת הרקע בזמן שהמשתמשים מעיינים בתוכן. הטכניקה הזו יכולה להפוך את האינטראקציה עם האפליקציה שלכם לקולנועית ומהנה יותר.

ערכת הכלים לבניית ממשק המשתמש של Leanback מספקת מחלקה BackgroundManager לשינוי הרקע של פעילות באפליקציית טלוויזיה. בדוגמה הבאה אפשר לראות איך ליצור שיטה פשוטה לעדכון הרקע בפעילות של אפליקציית הטלוויזיה:

Kotlin

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

Java

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

באפליקציות רבות לעיון במדיה, הרקע מתעדכן באופן אוטומטי כשהמשתמש עובר בין רשימות של מדיה. כדי לעשות את זה, אפשר להגדיר מאזין לבחירה שיעדכן אוטומטית את הרקע על סמך הבחירה הנוכחית של המשתמש. בדוגמה הבאה אפשר לראות איך מגדירים מחלקה OnItemViewSelectedListener כדי לתעד אירועי בחירה ולעדכן את הרקע:

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

הערה: ההטמעה הקודמת היא דוגמה פשוטה למטרות המחשה. כשיוצרים את הפונקציה הזו באפליקציה שלכם, מריצים את פעולת העדכון ברקע בשרשור נפרד כדי לשפר את הביצועים. בנוסף, אם אתם מתכננים לעדכן את הרקע בתגובה לגלילה של המשתמשים בין הפריטים, כדאי להוסיף זמן להשהיית עדכון תמונת הרקע עד שהמשתמש יתמקד בפריט מסוים. הטכניקה הזו מונעת עדכונים מוגזמים של תמונות הרקע.