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

إنشاء تطبيقات أفضل باستخدام Compose
يمكنك إنشاء واجهات مستخدِم جميلة باستخدام الحد الأدنى من التعليمات البرمجية باستخدام Jetpack Compose لنظام التشغيل Android TV.

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

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

ملاحظة: يستخدم مثال التنفيذ الموضّح هنا 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();
            }
        }
    };
}

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