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

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

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

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

הערה: בדוגמה להטמעה שמוצגת כאן נעשה שימוש ב-BrowseSupportFragment במקום במחלקה BrowseFragment שהוצאה משימוש. ‫BrowseSupportFragment מרחיב את המחלקה AndroidX Fragment, ועוזר להבטיח התנהגות עקבית במכשירים ובגרסאות Android שונות.

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

איור 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());
    }
...

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

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

  • setBadgeDrawable() מציב את משאב ה-drawable שצוין בפינה השמאלית העליונה של קטע הגלישה, כמו שמוצג באיורים 1 ו-2. השיטה הזו מחליפה את מחרוזת הכותרת במשאב הניתן לציור, אם גם setTitle() נקרא. משאב ה-drawable צריך להיות בגובה 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 ומטמיעים את השיטות המופשטות כדי ליצור את מחזיק התצוגה, לקשור אותו ולבטל את הקשר שלו. בדוגמה הבאה אפשר לראות איך לקשר את ViewHolder עם שני תצוגות, 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
    }
}

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

  • כדי להגדיר את התצוגה כך שאפשר יהיה להתמקד בה ב-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() כדי להגדיר את המציג של כותרת השורה, כמו בדוגמה הבאה.

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() כדי לתמוך בחזרה לכותרת השורה מפריט תוכן שנבחר בשורה. ההגדרה הזו מופעלת כברירת מחדל אם לא קוראים לשיטה. כדי לטפל בעצמכם בתנועה אחורה, מעבירים את false אל setHeadersTransitionOnBackEnabled() ומטמיעים את הטיפול שלכם במחסנית החזרה.

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

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

עדכון הרקע

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

ערכת הכלים לבניית ממשק המשתמש של 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();
            }
        }
    };
}

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