هنگامی که اینترنت قطع باشه و بخواهیم به کاربر خطایی نمایش بدیم، یا صفحه ای که قرار هست باز بشه به هر دلیلی با مشکلی مواجه بشه، در حالت عادی در اپلیکیشن موبایل اندروید یک صفحه یا فرگمنت نمایش خطا ایجاد می کنیم و این صفحه در مواقع ضروری باز میشه. جالب اینجاست که LeanBack حتی برای این بخش هم کار ما رو راحت کرده، توسط ErrorFragment

مثل قبل اول یک اکتیویتی به نام ErrorActivity ایجاد می کنم:

class ErrorActivity : FragmentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_error)
        val mErrorFragment = ErrorViewFragment()
        supportFragmentManager.beginTransaction().add(R.id.fragment_error_root, mErrorFragment)
            .commit()
    }
}

 با layout زیر (R.layout. activity_error):

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/fragment_error_root"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

همینطور من یک ErrorViewFragment ایجاد کردم که قرار هست داخلش پیغام خطای مورد نظرم نمایش داده بشه.

تنها متدی که در این فرگمنت باید بازنویسی بشه onCreate هست که داخل آن می توانم پیغام خطا، آیکون و سایر موارد رو مقدار دهی کنم:

class ErrorViewFragment : ErrorSupportFragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        title = "Oooops :("
        imageDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.lb_ic_sad_cloud)
        message = "NO Internet connection!"
        setDefaultBackground(false)
        buttonText = "Back to home"
        buttonClickListener = View.OnClickListener {
            activity?.finish()
        }
    }
}

با تغییر کد زیر در MainFragment، در صورتی که آیتم کلیک شده از نوع Movie نباشد، صفحه خطا را باز می کنم:

onItemViewClickedListener = OnItemViewClickedListener { _, item, _, _ ->
    if (item is Movie){
        startActivity(Intent(activity, MovieDetailsActivity::class.java).putExtra(MovieDetailsFragment.EXTRA_MOVIE
            , item))
    }else{
        startActivity(Intent(activity, ErrorActivity::class.java))
    }
}

و خروجی:

که با زدن دکمه BackToHome، به صفحه اصلی برمیگردم.

طبق قول کدها قرار هست داخل گیت قرار بگیرند. پس من داخل ریپوزیتوری گیتهابی که به این پروژه تخصیص دادم، روی برنچ iodroid_tv/add_error_fragmentکد این بخش از آموزش رو قرار دادم. (طبیعتا کد نهایی هم روی برنج master موجود هست)
برای دسترسی به این شاخه از پروژه گیتهاب کلیک کنید 🙂

در اپلیکیشن های تلوزیون اندروید، نمایش دادن دیالوگ های معمولی اندرویدی خیلی مرسوم نیست. برای اینکه امکان انتخاب بین چند گزینه یا پرسیدن سوال از کاربر رو داخل اپلیکیشن قرار بدهیم، می توانیم از فرگمنتی که خود کتابخانه LeanBack در اختیارمون قرار داده استفاده کنیم. به اسم GuidedStepFragment

ابتدا یک اکتیویتی بسازید به نام FragmentActivity. این اکتیویتی نیازی به layout ندارد، تنها باید از کلاس AppCompatActivity ارث ببرد.

 بعد داخل MovieDetailsFragment این اکتیویتی رو با کلیک روی Action Watch این اکتیویتی رو باز می کنیم. برای اینکار داخل متد setMovieDetailActions در MovieDetailsFragment که دکمه های Action رو ساختیم، یک setOnItemViewClickedListener به آداپتر اکشن ها اضافه می کنیم:

private fun setMovieDetailActions() {
        val actionsAdapter = ArrayObjectAdapter().apply {
            add(Action(1, "Watch").apply {
                icon =
                    ContextCompat.getDrawable(requireContext(), R.drawable.ic_play_arrow_white_24dp)
            })
            add(Action(2, "78 People", "Liked the Movie").apply {
                icon =
                    ContextCompat.getDrawable(requireContext(), R.drawable.ic_thumb_up_white_24dp)
            })
            add(Action(3, "Watch Trailer"))

            setOnItemViewClickedListener { itemViewHolder, item, rowViewHolder, row ->
                if (item is Action){
                    if (item.id == 1L){
                        startActivity(Intent(requireContext(), GuidedStepActivity::class.java))
                    }
                }
            }
        }
        detailOverviewRow.actionsAdapter = actionsAdapter
    }

همینطور کلاس زیر رو ایجاد میکنم (در اصل فرگمنتی که قرار هست دیالوگ را نشان بدهد):

class ChooseStartPositionFragment: GuidedStepSupportFragment() {}

و بعد داخل اکتیویتی GuidedStepActivity  ، این فرگمنت را نمایش می دهم.

class GuidedStepActivity : FragmentActivity () {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (null == savedInstanceState) {
            GuidedStepSupportFragment.add(supportFragmentManager, ChooseStartPositionFragment())
        }
    }
}

ساختار GuidedStepFragment :

ساختار GuidedStepFragment

این فرگمنت در اصل ساختاری به شکل بالا دارد. که با Override کردن چند متد می توانیم آن را ایجاد کنیم:

onCreateGuidance(Bundle): برای ایجاد View سمت چپ (guidance view) و مقداردهی به بخش title، description و icon

onCreateActions(List, Bundle): برای ایجاد قسمت گزینه های انتخابی کاربر که view سمت راست را ایجاد می کند. (َActions view)

onGuidedActionClicked(GuidedAction): که برای مقدار دهی به کلیک شدن آیتم های Actions View صدا زده میشود.

پیاده سازی این فرگمنت خیلی ساده است و در زیر کدش رو میبینیم:

class ChooseStartPositionFragment: GuidedStepSupportFragment() {
    override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
        return GuidanceStylist.Guidance("Don you wanna continue watching?", "You have watched the movie before, start from beginning or continue?",
            null, ContextCompat.getDrawable(requireContext(), R.drawable.ghost))
    }

    override fun onCreateActions(actions: MutableList<GuidedAction>, savedInstanceState: Bundle?) {
        actions.add(0, GuidedAction.Builder(requireContext()).id(0).icon(R.drawable.ic_watch_before).title("Continue watching").build())
        actions.add(1, GuidedAction.Builder(requireContext()).id(1).icon(R.drawable.ic_play_arrow_black_24dp).title("Start from beginning").build())
    }

    override fun onGuidedActionClicked(action: GuidedAction?) {
        when(action?.id){
             0L -> Toast.makeText(requireContext(), "You will continue watching", Toast.LENGTH_LONG).show()
             1L -> Toast.makeText(requireContext(), "You will watch from beginning", Toast.LENGTH_LONG).show()
        }
    }
}

خروجی:

یا حتی با دادن یک checkSetId واحد به Action هایی که می خواهیم حالت radioButton داشته باشند، خروجی زیر را خواهیم داشت:

سفارشی سازی ظاهر GuidedStepFragment:

بطور کل برای Custom کردن صفحات دیفالت LeanBack دو راه داریم.

راه اول تغییر style مرتبط با بخش مورد نظرمون هست. برای GudedStep ها، تم زیر در LeanBack تعریف شده: Theme.Leanback.GuidedStep

برای دیدن item هایی که این theme دار می توانیم به این لینک(https://android.googlesource.com/platform/frameworks/support/+/e220922/v17/leanback/res/values/themes.xml) و یا کد خود این کتابخانه مراجعه کنید.

با اضافه کردن یک تم به styles.xml پروژه که از Theme.Leanback.GuidedStep ارث برده باشد، و با تغییر item هایی که میخواهیم، می توانیم این صفحه را سفارشی کنیم. بطور مثال style زیر را در styles.xml تعریف می کنم:

<style name="Theme.Custom.Leanback.GuidedStep" parent="Theme.Leanback.GuidedStep">
    <item name="guidedStepBackground">#0C394E</item>
</style>

و بعد آن را در manifest به اکتیویتی مربوطه می دهم:

<activity android:name=".views.activities.GuidedStepActivity" 
    android:theme="@style/Theme.Custom.Leanback.GuidedStep"></activity>

که guidedStepBackground  دقیقا نامی هست که در LeanBack بعنوان رنگ پس زمینه GuidaceView اعمال می شود. و خروجی این تغییر:

راه دوم ویرایش layout ای است که برای هر قسمتی از LeanBack که میخواهیم سفارشی کنیم استفاده شده. البته قطعا ما توانایی تغییر کد اصلی LeanBack رو نداریم، فقط دقیقا یک layout با نام مشابهی که در leanback استفاده شده ایجاد می کنیم، و با رعایت نگهداری id هایی که به view ها داده، آن ها را ویرایش می کنیم. البته می توانیم view اضافه کنیم، ولی در صورت حذف یک view احتمال زیاد با خطا یا crash مواجه می شیم!

بطور مثال برای GuidanceStepFragment در لین بک، layout زیر ساخته شده(lb_guidedstep_fragment.xml):

<?xml version="1.0" encoding="utf-8"?>
<androidx.leanback.app.GuidedStepRootLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/guidedstep_root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="bottom"
    android:weightSum="2">

    <FrameLayout
        android:id="@+id/guidedstep_background_view_root"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="?attr/guidedStepHeightWeight">

        <LinearLayout
            android:id="@+id/content_frame"
            android:orientation="horizontal"
            android:baselineAligned="false"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <androidx.leanback.widget.NonOverlappingFrameLayout
                android:id="@+id/content_fragment"
                android:layout_width="0dp"
                android:layout_weight="1"
                android:layout_height="match_parent" />

            <androidx.leanback.widget.NonOverlappingFrameLayout
                android:id="@+id/action_fragment_root"
                android:transitionName="action_fragment_root"
                android:transitionGroup="false"
                android:orientation="horizontal"
                android:clipToPadding="false"
                android:clipChildren="false"
                android:paddingStart="@dimen/lb_guidedactions_section_shadow_width"
                android:layout_width="0dp"
                android:layout_weight="?attr/guidedActionContentWidthWeight"
                android:layout_height="match_parent" >

                <androidx.leanback.widget.NonOverlappingView
                    android:id="@+id/action_fragment_background"
                    android:transitionName="action_fragment_background"
                    android:orientation="horizontal"
                    android:outlineProvider="paddedBounds"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:background="?attr/guidedActionsBackground"
                    android:elevation="?attr/guidedActionsElevation" />

                <androidx.leanback.widget.NonOverlappingLinearLayout
                    android:id="@+id/action_fragment"
                    android:focusable="true"
                    android:descendantFocusability="afterDescendants"
                    android:transitionName="action_fragment"
                    android:transitionGroup="false"
                    android:orientation="horizontal"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:elevation="?attr/guidedActionsElevation" />
            </androidx.leanback.widget.NonOverlappingFrameLayout>

        </LinearLayout>

    </FrameLayout>

</androidx.leanback.app.GuidedStepRootLayout>

من با ساختن layout دقیقا با همین نام، خیلی راحت آن را بازنویسی می کنم. در اینجا هدف من تغییر رنگ پس زمینه Actions View هست، پس تگ زیر رو تغییر می دهم:

<androidx.leanback.widget.NonOverlappingLinearLayout
    android:id="@+id/action_fragment"
    android:focusable="true"
    android:descendantFocusability="afterDescendants"
    android:transitionName="action_fragment"
    android:transitionGroup="false"
    android:background="#3FA9B8"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:elevation="?attr/guidedActionsElevation" />

و خروجی:

طبق قول کدها قرار هست داخل گیت قرار بگیرند. پس من داخل ریپوزیتوری گیتهابی که به این پروژه تخصیص دادم، روی برنچ iodroid_tv/add_guidance_fragmentکد این بخش از آموزش رو قرار دادم. (طبیعتا کد نهایی هم روی برنج master موجود هست)
برای دسترسی به این شاخه از پروژه گیتهاب کلیک کنید 🙂

صفحه جزئیات فیلم (MovieDetailFragment) در اندروید تی وی، ظاهری به شکل عکس بالا خواهد داشت. البته این صفحه برای اپلیکیشن ما قرار هست توضیحات فیلم رو نشون بده، درکل می تونه برای نمایش دیتای هر نوع مدیایی استفاده بشه، آهنگ، هر نوع ویدیو، فیلم یا….

 عملا دو بخش اصلی در هر صفحه جزئیات فیلم هست:

DetailsOverviewRow : که قرار هست دیتا فیلم رو نشون بده، مثل عکس فیلم، عکس کاور، توضیحات، نام فیلم و یک سری دیتا که قرار هست فیلم یا ویدیو رو توصیف کنه.

ListRow: که در صفحه MainFragment هم استفاده کردیم و قرار هست یک لیست از سایر مدیاها نمایش بده. مثلا فیلم های مشابه، سایر ویدیوهای همین کانال، آهنگ های دیگر از همین آلبوم یا….

به احتمال زیاد در این صفحه شما یک DetailsOverviewRow دارید و یک یا چند ListRow برای نمایش یک سری دیتای بیشتر. و این مجموعه (همانطور که در پیاده سازی MainFragment) دیدیم داخل یک ArrayObjectAdapter قرار می¬گیرند. پس ساختار این صفحه به صورت زیر خواهد بود:

ساختار صفحه MovieDetailFragment در اندروید تی وی

برای بخش DetailsOverviewRow دو Presenter آماده توسط خود LeanBack ارائه شده.

  • DetailsOverviewRowPresenter: در حال حاضر deprecate شده ولی بخاطر دیزاین خاصی که داره هنوز هم استفاده میشه پس باهم یاد میگیریمش 😊

Edit My Profile

نحوه نمایش DetailsOverviewRowPresenter در اندروید تی وی
نحوه نمایش FullWidthDetailsOverviewRowPresenter در اندروید تی وی

پس در ادامه به هر دو روش DetailsOverviewRow رو پیاده سازی می­کنیم:

روش پیاده سازی اول: با استفاده از FullWidthDetailsOverviewRowPresenter :

این Presenter از سه بخش اصلی تشکیل شده:

  • یک Logo view که برای نمایش عکس اصلی مدیا هست و البته با پیاده سازی DetailsOverViewLogoPresenter قابل سفارشی کردن هم هست. در غیر این صورت با تابع detailOverviewRow.setImageBitmap  یا detailOverviewRow.setImageDrawable  عکس مدیا را می توان در بخش در نظر گرفته شده نمایش داد.

در آموزش های بعدی می بینیم که چطور می توان برخی از کلاس ها، presenterها و یا viewهای اندروید تی وی رو سفارشی سازی کرد.

  • Action list view که یک دیتای کوتاه یا دکمه های اکشن رو داخلش قرار میدیم و بصورت یک لیست افقی قابل اسکرول نمایش داده می شوند. این دکمه ها قابلیت پیاده سازی اکشن کلیک هم دارند.
  • Detailed description view که باید با پیاده سازی AbstractDetailsDescriptionPresenter برای پروژه شما شخصی سازی بشه و بخش عنوان اصلی، عنوان فرعی و توضیحات فیلم در این قسمت قرار می گیره.

برای ایجاد این صفحه اول یک اکتیویتی به نام MovieDetailsActivity : FragmentActivity()می سازیم با یک layout به نام activity_detail.xml. همینطور یک فرگمنت به اسم MovieDetailsFragment میسازیم که از DetailsSupportFragment ارث برده باشه. توسط کد داخل activity_detail.xml زیر اکتیویتی رو به فرگمنت متصل میکنم.

<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools" android:id="@+id/details_fragment"
android:name="ir.iodroid.androidtvsample.views.fragments.MovieDetailFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".views.activities.MovieDetailsActivity" tools:deviceIds="tv" />

از آنجایی که قرار هست فیلم انتخاب شده توسط کاربر به این صفحه پاس داده بشه، بعد از Parcelable کردن کلاس Movie، با تعریف کد زیر دقیقا همانطور که onItemViewClickedListener رو تعریف کرده بودیم داخل MainFragment، صفحه MovieDetailsActivity  رو باز میکنیم:

onItemViewClickedListener = OnItemViewClickedListener { _, item, _, _ ->
            if (item is Movie){
                startActivity(Intent(activity, MovieDetailsActivity::class.java).putExtra(MovieDetailsFragment.EXTRA_MOVIE
                    , item))
            }
        }

و کد اکتیویتی و فرگمنت مربوط به نمایش جزئیات فیلم، به صورت زیر هست:

class MovieDetailsActivity : FragmentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_detail)
    }
}

class MovieDetailsFragment : DetailsSupportFragment() {

    companion object {
        const val EXTRA_MOVIE = "EXTRA_MOVIE"
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        val selectedMovie =
            activity?.intent?.getParcelableExtra<Parcelable>(EXTRA_MOVIE) as Movie
    }
}

حالا به ترتیب بخش های پایین رو انجام میدیم:
1- ایجاد کلاس MovieDetailsDescriptionPresenter برای نمایش اطلاعات متنی فیلم شامل عنوان، عنوان فرعی و توضیحات:

class MovieDetailsDescriptionPresenter : AbstractDetailsDescriptionPresenter() {
    override fun onBindDescription(
        viewHolder: ViewHolder,
        item: Any
    ) {
        val movie =
            item as? Movie
        if (movie != null) {
            viewHolder.title.text = movie.title
            viewHolder.subtitle.text = movie.director
            viewHolder.body.text = movie.description
        }
    }
}

البته در نظر بگیرید که چون بخش توضیحاتی که قبلا داشتیم برای کلاس Movie دیتای زیادی نداشت، من این کلاس رو به شکل زیر تغییر دادم از قبل:

data class Movie(val title: String, val director: String, val coverUrl: String, val description: String): Parcelable{
    constructor(parcel: Parcel) : this(
        parcel.readString(),
        parcel.readString(),
        parcel.readString(),
        parcel.readString()
    ) {
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(title)
        parcel.writeString(director)
        parcel.writeString(coverUrl)
        parcel.writeString(description)
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator<Movie> {
        override fun createFromParcel(parcel: Parcel): Movie {
            return Movie(parcel)
        }

        override fun newArray(size: Int): Array<Movie?> {
            return arrayOfNulls(size)
        }
    }
}

در ادامه داخل MainFragment کارهای اصلی ما انجام میگیره.

  1. تعریف مقدارهای اولیه در بالای فرگمنت:
companion object {
    private const val DETAIL_THUMB_WIDTH = 300
    private const val DETAIL_THUMB_HEIGHT = 400
    const val EXTRA_MOVIE = "EXTRA_MOVIE"
}

private val detailFullOverviewPresenter =
    FullWidthDetailsOverviewRowPresenter(MovieDetailsDescriptionPresenter())

lateinit var detailOverviewRow: DetailsOverviewRow

3-خواندن مقدار کلاس Movie در زمان ساخته شدن این صفحه و سپس مقدار دهی سایر مقادیر بر اساس این فیلم:

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)

    val selectedMovie =
        activity?.intent?.getParcelableExtra<Parcelable>(EXTRA_MOVIE) as Movie

    detailOverviewRow = DetailsOverviewRow(selectedMovie)

    setMovieThumbnail(selectedMovie)
    setMovieDetailActions()
    setMovieDetailAdapter()
}

4) تابع setMovieThumbnail:
داخل این تابع بعد از لود کردن عکس فیلم توسط Glide آن را به detailOverviewRow پاس میدهم. البته دلیل اینکار این هست که detailOverviewRow فقط Bitmap یا Drawable قبول می کند:

private fun setMovieThumbnail(selectedMovie: Movie) {
    Glide.with(activity!!)
        .asBitmap()
        .load(selectedMovie.coverUrl)
        .apply(RequestOptions().override(DETAIL_THUMB_WIDTH, DETAIL_THUMB_HEIGHT).centerCrop())
        .into(object : CustomTarget<Bitmap>() {
            override fun onLoadCleared(placeholder: Drawable?) {}
            override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
                detailOverviewRow.setImageBitmap(activity, resource)
            }
        })
}

5) تابع setMovieDetailActions
در این متد من اکشن هایی که قرار هست در بالای صفحه فیلم لیست شوند را ایجاد می کنم. این مقادیر می توانند دکمه ای با اکشن مشخص باشند یا صرفا داده ای را نشان دهند. همینطور چهار مقدار میگیرند: شناسه(id) که در صورت تعریف اکشن کلیک استفاده می شوند پس باید منحصر بفرد باشد، عنوان اول، عنوان دوم، آیکون
• در صورت نداشتن هرکدام از این آیکون ها، از گیت پروژه می تونید دانلود کنید یا اینکه برای خودتون یکی ایجاد کنید.

private fun setMovieDetailActions() {
    val actionsAdapter = ArrayObjectAdapter().apply {
        add(Action(1, "Watch").apply {
            icon =
                ContextCompat.getDrawable(requireContext(), R.drawable.ic_play_arrow_white_24dp)
        })
        add(Action(2, "78 People", "Liked the Movie").apply {
            icon =
                ContextCompat.getDrawable(requireContext(), R.drawable.ic_thumb_up_white_24dp)
        })
        add(Action(3, "Watch Trailer"))
    }
    detailOverviewRow.actionsAdapter = actionsAdapter
}

6) تابع generateRelatedMoviesRow برای ایجاد داده هایی در بخش “فیلم های مرتبط”
همینطور که در صفحه MainFragment چهارفیلم قرار دادیم، در اینجا هم دقیقا به همان روش باید یک ListRow ایجاد کنیم که داخلش به کمک MovieViewPresenter چهار فیلم و یک HeaderItem قرار دارد.

private fun generateRelatedMoviesRow(): ListRow {
    val movieViewPresenter = MovieViewPresenter(requireContext())
    val listRowAdapter = ArrayObjectAdapter(movieViewPresenter).apply {
        add(
            Movie(
                "Sleeping Beauty",
                "Directed by: Clyde Geronimi",
                "https://i1.wp.com/www.tor.com/wp-content/uploads/2015/07/Sleeping2-740x360.jpg?fit=740%2C%209999&crop=0%2C0%2C100%2C360px",
                "Sleeping Beauty is a 1959 American animated musical fantasy film produced by Walt Disney based on The Sleeping Beauty by Charles Perrault. The 16th Disney animated feature film, it was released to theaters on January 29, 1959, by Buena Vista Distribution. This was the last Disney adaptation of a fairy tale for some years because of its initial mixed critical reception and underperformance at the box office; the studio did not return to the genre until 30 years later, after Walt Disney died in 1966, with the release of The Little Mermaid (1989)."
            )
        )
        add(
            Movie(
                "Arthur Christmas",
                "Directed by: Sarah Smith, Barry Cook",
                "https://www.gannett-cdn.com/-mm-/b2b05a4ab25f4fca0316459e1c7404c537a89702/c=0-0-1365-768/local/-/media/2018/06/11/USATODAY/usatsports/247WallSt.com-247WS-469696-arthur-christmas.jpg",
                "Arthur Christmas is a 2011 British-American 3D computer-animated Christmas comedy film, produced by Aardman Animations and Sony Pictures Animation as their first collaborative project. The film was released on 11 November 2011, in the UK, and on 23 November 2011, in the US.\n" +
                 "Directed by Sarah Smith and co-directed by Barry Cook,[4] it stars the voices of James McAvoy, Hugh Laurie, Bill Nighy, Jim Broadbent, Imelda Staunton, and Ashley Jensen."
            )
        )
        add(
            (Movie(
                "Frankenweenie",
                "Directed by: Tim Burton",
                "https://www.gannett-cdn.com/-mm-/1f8ac36e35875bcd7baab5ef2b330f818b7ad867/c=12-0-588-324/local/-/media/2018/05/14/USATODAY/usatsports/wp-USAT-allthemoms-front1-11182-frankenweenie.jpg",
                "Frankenweenie is a 2012 American 3D stop motion-animated supernatural horror comedy film directed by Tim Burton and produced by Walt Disney Pictures.[3] It is a remake of Burton's 1984 short film of the same name and is also both a parody of and homage to the 1931 film Frankenstein, based on Mary Shelley's book of the same name. The voice cast includes four actors who worked with Burton on previous films: Winona Ryder (Beetlejuice and Edward Scissorhands); Martin Short (Mars Attacks!); Catherine O'Hara (Beetlejuice and The Nightmare Before Christmas); and Martin Landau (Ed Wood and Sleepy Hollow), along with some new voice actors, such as Charlie Tahan and Atticus Shaffer."
            ))
        )
        add(
            Movie(
                "Winnie the Pooh",
                "Directed by: Stephen J. Anderson, Don Hall",
                "https://www.gannett-cdn.com/-mm-/b2b05a4ab25f4fca0316459e1c7404c537a89702/c=0-0-1365-768/local/-/media/2018/06/11/USATODAY/usatsports/winnie-the-pooh-2011.jpg",
                "Winnie-the-Pooh, also called Pooh Bear and Pooh, is a fictional anthropomorphic teddy bear created by English author A. A. Milne.\n" +
                "The first collection of stories about the character was the book Winnie-the-Pooh (1926), and this was followed by The House at Pooh Corner (1928). Milne also included a poem about the bear in the children's verse book When We Were Very Young (1924) and many more in Now We Are Six (1927). All four volumes were illustrated by E. H. Shepard."
            )
        )
    }
    val headerItem = HeaderItem(0, "Related Movies")

    return ListRow(headerItem, listRowAdapter)
}
  • قطعا این حجم دیتا وسط کد وحشتناکه، به زودی خیلی تمیزتر از Api میخونیم داده هارو 😊

7) نهایتا نمایش داده ها در تابع setMovieDetailAdapter

همینطور که می بینید، داخل این Fragment بر خلاف چیزی که در MainFragment دیدیم، من دو Presenter مختلف دارم، یکی از نوع ListRowPresenter و دیگری از نوع FullWidthDetailsOverviewRowPresenter. برای اینکه صفحه متوجه بشه که این داده ها قرار هست چطور کنار هم قرار بگیرند، از کلاسی به نام ClassPresenterSelector استفاده می کنیم که طبق تعریفش در داکیومنت گوگل:

یک Presenter مناسب با توجه به نوع item انتخاب می کند!

پس ما اینجا داده های بخش جزئیات فیلم و بعدش داده های بخش فیلم های مرتبط رو بهش میدیم، و اون تصمیم میگیره که با توجه به این داده کدام پرزنتر رو باید استفاده کنه:

private fun setMovieDetailAdapter() {
    val classPresenterSelector = ClassPresenterSelector().apply {
        addClassPresenter(DetailsOverviewRow::class.java, detailFullOverviewPresenter)
        addClassPresenter(ListRow::class.java, ListRowPresenter())
    }

    val detailPageAdapter = ArrayObjectAdapter(classPresenterSelector).apply {
        add(detailOverviewRow)
        add(generateRelatedMoviesRow())
    }

    setAdapter(detailPageAdapter)
}

همینطور که می بینید در هنگام تعریف این کلاس، گفتم که یا داده از از نوع DetailOverviewRow میگیره، که باید برای این بخش از FullWidthDetailsOverviewRowPresenter که بالاتر تعریف شده استفاده کنه، یا داده ای از جنس ListRow می گیره که در این صورت از کلاس ListRowPresenter پیشفرض خود LeanBack باید استفاده کنه. در ادامه این مقادیر رو بهش دادم و در نهایت آداپتر MovieDetailsFragment رو مقدار دهی کردم.

و حالاا اجرا (با اسکرول صفحه) :

روش پیاده سازی دوم: با استفاده از DetailsOverviewRowPresenter:
کافیه فقط در کد قبل، FullWidthDetailsOverviewRowPresenter رو با DetailsOverviewRowPresenter در MovieDetailsFragment جایگزین کنین! و تمام!

طبق قول کدها قرار هست داخل گیت قرار بگیرند. پس من داخل ریپوزیتوری گیتهابی که به این پروژه تخصیص دادم، روی برنچ iodroid_tv/add_background_manager کد این بخش از آموزش رو قرار دادم. (طبیعتا کد نهایی هم روی برنج master موجود هست)
برای دسترسی به این شاخه از پروژه گیتهاب کلیک کنید 🙂

یکی از قابلیت های جذابی که به اندروید تی وی اضافه شده (در قالب leanBack) کلاس BackgroundManager هست.

به کمک این کلاس شما قادر هستید با انتخاب هر یک از آیتم های لیست، عکس بک گراند صفحه رو عوض کنید.

البته شاید خیلی ساده بنظر برسه این موضوع پیاده سازیش (داخل اپلیکیشن اندروید موبایل) ولی به کمک این کلاس Android Tv این کار به افکتی که برای خود این دیوایس طراحی شده انجام میشه، و فضا رو تلطیف میکنه  😊

وقتی عکس رو بخواید از روی یک لینک وب بگیرید، این تغییر می تونه به کمک Glide یا Picasso  یا هر روش دیگه ای صورت بگیره، برای اینکه احتمال زیاد عکس های یک اپلیکیشن اندروید تی وی، از وب خوانده میشوند، دقیقا مثل عکس های لیست ما که یک سری عکس از فیلم هایی بودند که لینکش رو لود میکردیم، پس من ترجیح میدم همینجا داخل آموزش یک راست از Glide استفاده کنم.

کلاس زیر رو می سازیم:

class TvBackgroundManager(private val activity: Activity){
    var backgroundManager: BackgroundManager = BackgroundManager.getInstance(activity).apply {
        attach(activity.window)
    }
    var defaultDrawable: Drawable = ContextCompat.getDrawable(activity, R.drawable.iodroid_main_bg)!!

    fun updateBackground(drawable: Drawable){
         }

    fun updateBackground(imageUrl: String){
    }

    fun clearBackground(){
       }
}

من یک عکس پیشفرش گذاشتم به اسم defaultDrawable، که وقتی آیتمی از لیست لود نشده قرار هست این عکس نمایش داده بشه.

همینطور یک نمونه از BackgrounManager که داخل پکیج leanBack هست رو هم قرار دادم. حالا هرکدوم از متدها رو کامل میکنیم:


fun updateBackground(drawable: Drawable){
    backgroundManager.drawable = drawable
}

نوع عکسی که backgroundManager قبول میکنه، از جنس drawable یا bitmap هست. پس خیلی راحت داخل این متد ورودی رو به backgroundManager میدم تا نمایشش بده. این متد فعلا داخل کد اپلیکیشن استفاده نخواهد شد.

fun updateBackground(imageUrl: String){
     Glide.with(activity)
         .asBitmap()
         .load(imageUrl)
         .into(object : CustomTarget<Bitmap>(){
             override fun onLoadCleared(placeholder: Drawable?) {            }
 
             override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
                 backgroundManager.setBitmap(resource)
             }
         })
 }

همونطور که در بالا گفتم، backgroundManager قرار هست فقط drawable یا bitmap بگیره. داخل این متد ما لینک عکس رو داریم که با Glide قرار هست لود بشه. به کمک فانکشن .asBitmap() کتابخانه Glide این امکان رو میده که عکس رو بعنوان یک bitmap دریافت کنیم. از اونجایی که خروجی Glide رو لازم داریم تا به backgroundManager بدیم (و قرار نیست اون رو بعنوان عکس به یک imageView ست کنیم) پس داخل تابع into به جای دادن id یک imageView، با پیاده سازی abstract class CustomTarget  که داخل پکیج Glide هست، و داخل تابع onResourceReady خروجی کار Glide رو دریافت میکنم. در مرحله آخر اون رو به backgroundManager میدم:

backgroundManager.setBitmap(resource)

آخرین تابعی که پیاده میکنم، برای ست کردن عکس دیفالت به backgroundManager هست:

fun clearBackground(){
    backgroundManager.drawable = defaultDrawable
}

استفاده از این کلاس خیلی راحت تر از چیزی که فکر کنید هست. کافیه روی یک listener که متوجه بشه آیتمی از لیست رو انتخاب کردم، این متد ها رو صدا بزنم تا عکس اون آیتم رو روی بک گراند صفحه ست کنم.

داخل MainFragment با استفاده از setOnItemViewSelectedListener (که داخل کاتلین می توان آن را onItemViewSelectedListener صدا زد) من با پیاده سازی interface OnItemViewSelectedListener، نوع آیتم لیست رو گرفته و عکسش رو به backgroundManager میدهم:

onItemViewSelectedListener =
    OnItemViewSelectedListener { viewHolder, item, rowViewHolder, row ->
        if (item is Movie){
            tvBackgroundManager.updateBackground(item.coverUrl)
        }else{
            tvBackgroundManager.clearBackground()
        }
    }

پس اگر آیتم انتخاب شده از جنس کلاس Movie بود، عکس کاور فیلم بعنوان بک گراند صفحه قرار داده میشه، و اگر آیتم انتخاب شده از این جنس نبود(یا آیتمی انتخاب نشده بود) عکس دیفالت نمایش داده خواهد شد.

که البته tvBackgroundManager باید مقدار دهی بشه، پس بالای کلاس MainFragment این مقدار رو تعریف میکنم:

private val tvBackgroundManager: TvBackgroundManager by lazy {
    TvBackgroundManager(activity as MainActivity).apply {
        clearBackground()
    }
}

(احتمالا می دانید که در کاتلین، by lazy کمک می کند که متغیری را تعریف کنیم و هر هنگام که صدا زده شد آن را مقدار دهی کنیم)

و خروجی:

استفاده از BackgroundManager برای لیست در AndroidTv

طبق قول کدها قرار هست داخل گیت قرار بگیرند. پس من داخل ریپوزیتوری گیتهابی که به این پروژه تخصیص دادم، روی برنچ iodroid_tv/add_background_manager کد این بخش از آموزش رو قرار دادم. (طبیعتا کد نهایی هم روی برنج master موجود هست)
برای دسترسی به این شاخه از پروژه گیتهاب کلیک کنید 🙂

یکی از View هایی که در کتابخانه leanback بصورت پیشفرض موجود است، BaseCardView هست. یک layout شبیه CardView که از سه جزء اصلی Main, Info و Extra تشکیل شده. بخش Main همیشه نمایش داده می¬شود در حالی که بخش های info و extra بر اساس وضعیت(State) فعلی ممکن است نمایش داده نشوند. این وضعیت بر اساس دو فانکشن setSelected و setActivated قایب تغییر است. البته برای استفاده از این کلاس از آنجایی که DesignLayout تعریف شده ای ندارد، از ImageCardView که مستقیما از BaseCardView ارث می¬¬برد استفاده می¬کنیم. این کلاس همانند کلاس پدرش هست که در بخش Main(قسمتی که همیشه نمایش داده میشود) یک ImageView دارد.
بیاید با مثال و عکس بهتر متوجه این توضیحات شویم. همینطور که گفتم ImageCardView از سه جزء اصلی زیر تشکیل شده:

ساختار ImageCardView

در بخش اصلی (main)، یک عکس قرار میگیره، در بخش info یک عنوان و یک content محتوایی داریم و در بخش badge یک آیکون قابل نمایش هست، که دو بخش آخر ممکن هست وجود داشته باشند یا اصلا استفاده نشوند، و یا مقدار داشته باشند اما با توجه به وضعیت مقدار داده شده به imageCardView، hide باشند.

برای اینکه بجای ItemsPresenter (که در آموزش قبل از آن استفاده کردیم ) در RowsAdapter، از MovieViewPresenter استفاده کنم که قرار هست داخلش ImageCardView ها نمایش داده شوند.
به پروژه ای که ساختیم اول یک کلاس به نام Movie ایجاد می¬کنم که قرار هست نوع داده ای باشد که در سطرهایمان میخواهیم نمایش دهیم. پس کلاس زیر رو ایجاد میکنم:
data class Movie(val description: String, val title: String, val coverUrl: String)

هر فیلم (فعلا) شامل عنوان، توضیحات و لینک عکس کاور هست. در بخش بعدی Presenter ای برای نمایش ImageCardView ها به دو روش ایجاد می¬کنم:


1- پیاده سازی بدون استفاده از ViewHolder:
class MovieViewPresenter(val context: Context): Presenter() {_}

که context هم از بیرون به این کلاس بعنوان ورودی می¬دهیم (و در جلوتر استفاده خواهد شد)
در داخل این کلاس متدهای زیر باید Override شوند:

 override fun onCreateViewHolder(parent: ViewGroup?): ViewHolder {
     val view = ImageCardView(context).apply {
         isFocusable = true
         setMainImageDimensions(300, 150)
     }
     return ViewHolder(view)
 }

در اینجا یک نمونه از کلاس ImageCardView ساختم و با متد setMainImageDimentsions برای این بخش main که شامل یک ImageView هست طول و عرض تعیین می¬کنم.
در نهایت نمونه ای از ViewHolder خود leanBack رو بر¬میگردونم و این view ک ساختم رو به constructor این کلاس پاس می¬دهم.
برای مقدار دهی این CardView اول کتابخانه Glide رو برای لود کردن عکس ها در ImageView به پروژه اضافه میکنم. و البته دسترسی INTERNET برای خوندن url ها نیز در فایل manifest باید اضافه بشه:

<uses-permission android:name="android.permission.INTERNET"/>

در نهایت داخل متد onBindViewHolder به ImageCardView مقدار می¬دهم.

override fun onBindViewHolder(viewHolder: ViewHolder?, item: Any?) {
     (item as? Movie)?.let {movie->
         (viewHolder?.view as? ImageCardView)?.apply {
             titleText = movie.title
             contentText = movie.description
             Glide.with(context).load(movie.coverUrl).apply(RequestOptions().centerCrop()).into(this.mainImageView)
         }
     }
}

دو ورودی viewHolder و item برای ما از جنس ImageCardView و Movie هستند و با علم به این قضیه با خیال راحت cast می¬کنیم تا راحت تر مقادیر رو بخونیم. مقادیر زیر در ImageCardView مشخص هستند:
titleText: عنوان در بخش info
contentText: محتوا در بخش info
badgeImage: یک آیکون در زیر بخش info (که در اینجا یک عکس را که به drawable اضافه کردم به آن دادم)
mainImageView: عکس در بخش main که توسط Glide مقدار آن را از coverUrl فیلم می¬گیرم
و مقداردهی😊
من یک سری فیلم با کاور به آداپترم اضافه می¬کنم و برای این کار باید تغییرات زیر را در MainFragment اعمال کنیم:

 private fun makeListItems() {
val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())

val headerItem = HeaderItem(0, "FirstRow")
val headerItem1 = HeaderItem(1, "SecondRow")
val headerItem2 = HeaderItem(2, "ThirdRow")
val movieViewPresenter = MovieViewPresenter(requireContext())
val itemsPresenter = ItemsPresenter()

val itemRowAdapter = ArrayObjectAdapter(itemsPresenter).apply {
    add("ITEM 1")
    add("ITEM 2")
    add("ITEM 3")
}


val movieRowAdapter = ArrayObjectAdapter(movieViewPresenter).apply {
    add(Movie("Sleeping Beauty", "Directed by: Clyde Geronimi", "https://www.filimo.com/public/public/user_data/video_thumb_star/14/27509_27509-m.jpg"))
    add(Movie("Arthur Christmas", "Directed by: Sarah Smith, Barry Cook", "https://www.gannett-cdn.com/-mm-/b2b05a4ab25f4fca0316459e1c7404c537a89702/c=0-0-1365-768/local/-/media/2018/06/11/USATODAY/usatsports/247WallSt.com-247WS-469696-arthur-christmas.jpg"))
    add((Movie("Frankenweenie", "Directed by: Tim Burton", "https://www.gannett-cdn.com/-mm-/1f8ac36e35875bcd7baab5ef2b330f818b7ad867/c=12-0-588-324/local/-/media/2018/05/14/USATODAY/usatsports/wp-USAT-allthemoms-front1-11182-frankenweenie.jpg")))
    add(Movie("Winnie the Pooh", "Directed by: Stephen J. Anderson, Don Hall", "https://www.gannett-cdn.com/-mm-/b2b05a4ab25f4fca0316459e1c7404c537a89702/c=0-0-1365-768/local/-/media/2018/06/11/USATODAY/usatsports/winnie-the-pooh-2011.jpg"))
}
rowsAdapter.add(ListRow(headerItem, itemRowAdapter))
rowsAdapter.add(ListRow(headerItem1, movieRowAdapter))
rowsAdapter.add(ListRow(headerItem2, movieRowAdapter))

adapter = rowsAdapter
}

و اجرا:

اجرای BrowsFragment با استفاده از ImageCardView

همینطور که میبینید آیتم ها به سطرهای من اضافه شده، بصورت پیشفرض در هر ImageCardView فقط بخش عکس نمایش داده می¬شود، و با انتخاب کردن هرکدام از سطرها و فعال شدن ImageCardView، داده های بیشتر (شامل بخش info و badge) نمایش داده می¬شوند.

دو مقداری که با تغییر دادن آن¬ها می¬توانیم رفتار متفاوتی از Card ها ببینیم عبارت هستند از: cardType و infoVisibility داخل Presenter در دسترس هستند:

override fun onCreateViewHolder(parent: ViewGroup?): ViewHolder {
     val view = ImageCardView(context).apply {
         isFocusable = true
         setMainImageDimensions(300, 150)
         badgeImage = ContextCompat.getDrawable(context, R.drawable.ic_play_circle_outline_black_24dp)
         cardType = BaseCardView.CARD_TYPE_INFO_UNDER_WITH_EXTRA
         infoVisibility = BaseCardView.CARD_REGION_VISIBLE_ACTIVATED
     }
     return ViewHolder(view)
 }

در زیر حالت¬های متفاوتی که این مقادیر ایجاد می¬کنند رو توضیح دادم

cardType:
BaseCardView.CARD_TYPE_INFO_OVER: بخش info روی قسمت main قرار می¬گیره

BaseCardView.CARD_TYPE_INFO_OVER

BaseCardView.CARD_TYPE_MAIN_ONLY: فقط بخش عکس نمایش داده می¬شود

BaseCardView.CARD_TYPE_MAIN_ONLY

BaseCardView.CARD_TYPE_INFO_UNDER: بخش info زیر قسمت main قرار میگیره

infoVisibility:
BaseCardView.CARD_REGION_VISIBLE_ALWAYS: بخش info همیشه نمایش داده می¬شود:

BaseCardView.CARD_REGION_VISIBLE_ACTIVATED: زمانی که سطرها فعال باشند، برای تمامی کارت ها بخش info نمایش داده می¬شود.

BaseCardView.CARD_REGION_VISIBLE_SELECTED: هر کارت زمانی که انتخاب شود، بخش info برای فقط آن کارت نمایش داده می¬¬شود:

2- پیاده سازی با استفاده از ViewHolder:
برای اینکه ViewHolder سفارشی داخل presenter ایجاد کنیم، کافی هست کلاس زیر رو داخل Presenter اضافه کنیم و تنظیمات دیفالت imageCardView رو به اینجا منتقل کنیم:

class CardViewHolder(itemView: View): ViewHolder(itemView){
     var imageCardView: ImageCardView = itemView as ImageCardView
init {
    imageCardView.apply {
        isFocusable = true
        cardType = BaseCardView.CARD_TYPE_MAIN_ONLY
        infoVisibility = BaseCardView.CARD_REGION_VISIBLE_ALWAYS
        setMainImageDimensions(300, 150)
        badgeImage = ContextCompat.getDrawable(context, R.drawable.ic_play_circle_outline_black_24dp)
    }
}
}

پس کد نهایی کلاس Presenter به شکل زیر هست:

ass MovieViewPresenter(private val context: Context): Presenter() {
    override fun onCreateViewHolder(parent: ViewGroup?): ViewHolder {
        return CardViewHolder(ImageCardView(context))
    }

    override fun onBindViewHolder(viewHolder: ViewHolder?, item: Any?) {
        (item as? Movie)?.let {movie->
            (viewHolder as? CardViewHolder)?.imageCardView?.apply {
                titleText = movie.title
                contentText = movie.description
                Glide.with(context).load(movie.coverUrl).apply(RequestOptions().centerCrop()).into(this.mainImageView)
            }
        }
    }


    class CardViewHolder(itemView: View): ViewHolder(itemView){
        var imageCardView: ImageCardView = itemView as ImageCardView

        init {
            imageCardView.apply {
                isFocusable = true
                cardType = BaseCardView.CARD_TYPE_MAIN_ONLY
                infoVisibility = BaseCardView.CARD_REGION_VISIBLE_ALWAYS
                setMainImageDimensions(300, 150)
                badgeImage = ContextCompat.getDrawable(context, R.drawable.ic_play_circle_outline_black_24dp)
            }
        }
    }


    override fun onUnbindViewHolder(viewHolder: ViewHolder?) {

    }
}

طبق قول کدها قرار هست داخل گیت قرار بگیرند. پس من داخل ریپوزیتوری گیتهابی که به این پروژه تخصیص دادم، روی برنچ iodroid_a2/add_image_cardview کد این بخش از آموزش رو قرار دادم. (طبیعتا کد نهایی هم روی برنج master موجود هست)
برای دسترسی به این شاخه از پروژه گیتهاب کلیک کنید 🙂