إنشاء متصفِّح كتالوج

يجب أن يسمح تطبيق الوسائط الذي يتم تشغيله على التلفزيون للمستخدمين بتصفّح المحتوى الذي يقدّمه واختيار المحتوى الذي تريده ثم بدء تشغيل المحتوى. ويجب أن تكون تجربة تصفُّح المحتوى بسيطة وسهلة الاستخدام، كما يجب أن تكون ممتعة وجذّابة مرئيًا.

يناقش هذا الدليل كيفية استخدام الصفوف التي تقدمها مكتبة Leanback androidx لتنفيذ واجهة مستخدم لتصفّح الموسيقى أو الفيديوهات من كتالوج الوسائط في تطبيقك.

ملاحظة: يستخدم مثال التنفيذ الموضح هنا BrowseSupportFragment بدلاً من فئة BrowseFragment المتوقّفة نهائيًا. BrowseSupportFragment يوسِّع فئة AndroidX Fragment للمساعدة في ضمان سلوك متّسق على جميع الأجهزة وإصدارات Android.

الشاشة الرئيسية للتطبيق

الشكل 1. يعرض جزء تصفح نموذج Leanback بيانات كتالوج الفيديو.

إنشاء تنسيق تصفح الوسائط

تتيح لك الفئة BrowseSupportFragment في مكتبة Leanback إنشاء تنسيق أساسي لتصفح فئات وصفوف عناصر الوسائط باستخدام أقل رمز ممكن. يوضّح المثال التالي كيفية إنشاء تنسيق يحتوي على عنصر 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() المورد القابل للرسم المحدّد في أعلى يسار جزء التصفُّح، كما هو موضّح في الشكلَين 1 و2. تستبدل هذه الطريقة سلسلة العنوان بالمورد القابل للرسم، إذا تم استدعاء setTitle() أيضًا. يجب أن يكون طول المورد القابل للرسم 52 وحدة بكسل مستقلة الكثافة.
  • 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
    }
}

يجب أن تكون الرؤوس قابلة للتركيز بحيث يمكن استخدام لوحة التحكم للتنقل بينها. هناك طريقتان لإدارة ذلك:

  • يمكنك ضبط العرض بحيث يمكن التركيز عليه في 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 لعرض بيانات السلسلة:

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

ملاحظة: عملية التنفيذ السابقة هي مثال بسيط لأغراض التوضيح. عند إنشاء هذه الدالة في تطبيقك، عليك تشغيل إجراء التحديث في الخلفية في سلسلة محادثات منفصلة لتحقيق أداء أفضل. بالإضافة إلى ذلك، إذا كنت تنوي تعديل الخلفية استجابةً لطلبات المستخدمين التي يتصفّحون العناصر، أضِف وقتًا لتأجيل تحديث صورة الخلفية إلى أن يستقرّ المستخدم على عنصر. يتجنّب هذا الأسلوب التعديلات الزائدة لصور الخلفية.