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

الإنشاء بشكل أفضل باستخدام 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();
            }
        }
    };
}

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