هنگامی که اینترنت قطع باشه و بخواهیم به کاربر خطایی نمایش بدیم، یا صفحه ای که قرار هست باز بشه به هر دلیلی با مشکلی مواجه بشه، در حالت عادی در اپلیکیشن موبایل اندروید یک صفحه یا فرگمنت نمایش خطا ایجاد می کنیم و این صفحه در مواقع ضروری باز میشه. جالب اینجاست که 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 موجود هست)
برای دسترسی به این شاخه از پروژه گیتهاب کلیک کنید 🙂

در این بخش با ساختار BrowsSupportFragment آشنا خواهیم شد و یاد خواهیم گرفت چطور لیستی از item ها در صفحه اپلیکیشن خود نشان دهیم. پس ابتدا باید با ساختار BrowsSupportFragment آشنا بشیم
پیش نیاز: AndroidTv – از ساخت تا اولین اجرا

شکل بالا نمایی از چیدمان یک فرگمنت هست که از BrowsSupportFragment ارث برده. با توجه به شماره ها میخوایم ببینیم چه آِیتم¬هایی در اختیار داریم در این فرگمنت و چه کارهایی میتونیم انجام بدیم داخلش.
1- همانطور که گفتیم، فرگمنت ما از BrowsSupportFragment ارث برده.
تعریفی که داخل سایت توسعه دهندگان اندروید از این کلاس گذاشته شده :
A fragment for creating Leanback browse screens. It is composed of a RowsSupportFragment and a HeadersSupportFragment.
در مجموع کار این فرگمنت این هست که مثل تصویر، مجموعه ای از سطرها(Row) رو داخل خودش نشون بده. هر سطر یک عنوان و تعدادی آیتم داره و احتمالا قرار هست داخل اپلیکیشن ما چنین شمایی رو برای ما ایجاد کنه:

2- RowsFragment: داخل BrowsSupportFragment قرار داره و عملا جایی هست که سطر آیتم¬ها داخلش قرار میگیره.
3- HeadersFragment: : داخل BrowsSupportFragment قرار داره و عنوان سطرهای محتوای فرگمنت در اینجا قرار می گیرند.
برای تغییر رنگ بک گراند این صفحه از متد setBrandColor داخل فرگمنت استفاده می¬کنیم.
4- برای این فرگمنت می توانیم عنوان یا عکس لوگو تعریف کنیم که یکی از دو متد برای اینکار استفاده می شود:
setBadgeDrawable
setTitle

5- HeaderItem: نوع داده ای هست که داخل HeadersFragment بعنوان عنوان سطرها نشان میدهیم
6- Object: آیتم هایی که در سطرها نشان می¬دهیم که می¬توانند هر نوع View ای باشند اما LeanBack کلاس¬های View استاندارد خودش را هم دارد.
7- سطرها که همانطور که گفتم شامل یک سری Object هستند، برای مقداردهی به سطرها باید از Adapter استفاده کنند که parent این آداپتر باید کلاسی به نام Presenter باشد. (مثل RecyclerView.Adapter در برنامه نویسی اندروید اپلیکیشن عادی اما به Position رفرنسی نداره). در نهایت برای اینکه این آداپتر را ست کنیم به RowsFragment باید از ArrayObjectAdapter استفاده کنیم تا از Presenter یک آداپتر ساخته و بتواند View های سطر را توسط آن بسازد. من نام نهاییش رو RowAdapter می گذارم که راحت تر باهم درموردش صحبت کنیم 😊
RowAdapter = ArrayObjectAdapter(ObjectsPresenter)
8- ListRow که مجموعه HeaderItem و RowAdapter هست برای اینکه یک سطر کامل از فرگمنت رو پوشش بده
ListRow = HeaderItem + RowAdapter

9- کل صفحه شامل یک آداپتر اصلی هست که مجموعه سطرها (ListRowها) ما رو در بر میگیره. خود LeanBack برای چیدن این سطرها Presenter خاص خودش رو داره به اسم ListRowPresenter. من اسم این آداپتر رو که توسط ListRowPresenter ساخته میشه RowsAdapter میگذارم:
RowsAdapter = ArrayObjectAdapter(ListRowPresenter())
کل ساختار BrowsSupportFragment که معمولا مهمترین صفحه اپلیکیشن تلویزیون ما رو ایجاد می کنه همین بود. و اگر با ساختارش آشنا بشید عملا مهمترین قسمت در توسعه Android Tv Application ها رو یاد گرفتید.

در ادامه سعی می کنیم خیلی سریع لیست این صفحه رو پیاده کنیم.

1) ساختن ItemsPresenter:
همانطور که بالاتر اشاره کردم، این کلاس قرار هست که آیتم های RowsFragment رو بسازه و باید از کلاس Presenter از کتابخانه LeanBack ارث بری کنه. پس کلاس زیر رو ایجاد می کنم:

class ItemsPresenter : Presenter() {
     override fun onCreateViewHolder(parent: ViewGroup?): ViewHolder {
         val view = TextView(parent?.context).apply {
             layoutParams = ViewGroup.LayoutParams(300, 100)
             //make the item focusable while using TV's remote control
             isFocusable = true
             setBackgroundColor(ContextCompat.getColor(parent?.context!!, R.color.primary))
             setTextColor(Color.WHITE)
             gravity = Gravity.CENTER
         }    
return ViewHolder(view)
}

override fun onBindViewHolder(viewHolder: ViewHolder?, item: Any?) {
    (viewHolder?.view as TextView).text = item as String
}

override fun onUnbindViewHolder(p0: ViewHolder?) {}
}

در قدم اول من میخوام لیست ها فقط یک TextView نمایش بدهند. از ViewHolder خود اندروید استفاده می¬کنم (یک کلاس سفارشی فعلا براش ایجاد نکردم). تنها کاری که باید انجام بدیم موقع ایجاد TextView این هست که isFocusable رو برایش فعال کنیم تا موقع استفاده از کنترل تلوزیون کاربر بتونه روی این TextView ها حرکت کنه.
در نهایت از آنجایی که می¬دونم نوع داده که قرار هست به Presenter ارسال کنم String هست، پس داخل متد onBindViewHolder مقدار item رو به String تبدیل کرده و داخل TextView نمایش می¬دهم.

2) داخل MainFragment ابتدا تنظیمات اولیه¬ی فرگمنت رو انجام می¬دهم و بعد آیتم¬های لیست رو ایجاد می¬کنم. پس اول داخل onActivityCreated مقادیر زیر رو ایجاد می¬کنم:

override fun onActivityCreated(savedInstanceState: Bundle?) {
     super.onActivityCreated(savedInstanceState)
headersState = HEADERS_ENABLED
showTitle(true)
badgeDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.banner)
brandColor = ContextCompat.getColor(requireContext(), R.color.headers_background)

makeListItems()
}

مقدار headerState بیانگر این هست که HeaderFragment نمایش داده بشود یا نه، پس در صورت لزوم خیلی راحت می¬تونیم این فرگمنت رو غیرفعال کنیم.
3) داخل متد makeListItem که خودم ایجادش کردم به ترتیب زیر پیش میرویم:

  • آداپتری برای مجموعه سطرها ایجاد می¬کنم:
    val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
  • عنوان سطرها رو با استفاده از کلاس HeaderItem ایجاد می¬کنم که دو ورودی میگیرد، id سطر و متن نمایشی:
    val headerItem = HeaderItem(0, "FirstRow")
    val headerItem1 = HeaderItem(1, "SecondRow")
    val headerItem2 = HeaderItem(2, "ThirdRow")
  • آداپتری که قرار هست یک سطر را ایجاد کند (با کمک ItemsPresenter که قبل تر ایجادش کردیم) رو مقدار دهی می¬کنم(RowAdapter):
    val rowAdapter = ArrayObjectAdapter(itemsPresenter).apply {
    add("ITEM 1")
    add("ITEM 2")
    add("ITEM 3")
    }

از آنجایی که می¬خواهم هر سه سطر من دقیقا همین مقادیر را نشان بدهند برای هر سه از همین rowAdapter استفاده خواهم کرد.

  • برای هر سطر یک ListRow ایجاد می¬کنم و آن را به RowsAdapter اضافه می¬کنم:
    rowsAdapter.add(ListRow(headerItem, rowAdapter))
    rowsAdapter.add(ListRow(headerItem1, rowAdapter))
    rowsAdapter.add(ListRow(headerItem2, rowAdapter))
  • در نهایت rowsAdapter رو به adapter پیش فرض BrowsSupportFragment (فرگمنت فعلی)، assign می¬کنم:
    adapter = rowsAdapter

کد نهایی کلاس MainFragment:

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

    headersState = HEADERS_ENABLED
    showTitle(true)
    badgeDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.banner)
    brandColor = ContextCompat.getColor(requireContext(), R.color.headers_background)

    makeListItems()
}

private fun makeListItems() {

    val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())

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

    val rowAdapter = ArrayObjectAdapter(itemsPresenter).apply {
        add("ITEM 1")
        add("ITEM 2")
        add("ITEM 3")
    }
    rowsAdapter.add(ListRow(headerItem, rowAdapter))
    rowsAdapter.add(ListRow(headerItem1, rowAdapter))
    rowsAdapter.add(ListRow(headerItem2, rowAdapter))

    adapter = rowsAdapter
}
}

و حالا اجرا کنید و لذت ببرید 😊

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

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

اگر میخواهید از سایت خود توسعه دهنگان اندروید برای پیدا کردن دید بهتر نسبت به Android Tv استفاده کنین، مقاله مرتبط رو در این سایت بهتون پیشنهاد میکنم.
همینطور برای آشنایی بیشتر با Design guidline تلوزیون اندروید، سایت withgoogle.com توضیحات خوبی قرار داده.

برای شروع باید اول با کتابخانه LeanBack آشنا بشید که شامل کلاس هایی برای توسعه واسط کاربری مناسب تلوزیون هست که در این آموزش هم از این کتابخانه استفاده خواهیم کرد.
1) ایجاد پروژه
اولین قدم مانند شروع هر پروژه اندروید دیگه، ایجاد یک New project در اندروید استادیو هست. فقط این بار به جای Phone and Tablet گزینه TV رو انتخاب کنید. از بین دو گزینه برای ایجاد اکتیویتی، Add No Activity را انتخاب کنید، برای اینکه گزینه ی Android Tv Activity، فایل های زیادی رو به پروژه اضافه خواهد کرد که در حین اینکه مرجع خوبی هست، یادگیری رو پیچیده می کنه.

پروژه اضافه خواهد کرد که در حین اینکه مرجع خوبی هست، یادگیری رو پیچیده می کنه.

2) ایجاد اولین صفحه
بعد از Build شدن پروژه، روی پکیج راست کلیک کنید و یک Blank Activity به اسم MainActivity ایجاد کنید.

در بخش بعدی باید برای این اکتیویتی یک فرگمنت ایجاد کنیم. پس دوباره با راست کلیک روی پکیج، یک Blank Fragment ایجاد می کنم با عنوان MainFragment :

و البته تیک گزینه های مربوط به ایجاد پیشفرض های فرگمنت رو هم بردارید چون نیازی به فایل ها و متدهای دیفالت ایجاد شده توسط اندروید استادیو نداریم.
و اولین جایی که با کتابخانه leanBack مواجه می شویم، همینجاست! که بجای فرگمنت اندروید، parent را به BrowseSupportFragment از کتابخانه لین بک تغییر می دهیم:

در نهایت تکه کد زیر را به activity_main.xml (لیوت ایجاد شده برای MainActivity) اضافه کنید:

<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/main_fragment_container"
    android:name="ir.iodroid.androidtvsample.MainFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

3) تغییر تم اپلیکیشن:
تم اکتیویتی ایجاد شده را در manifest به android:theme=”@style/Theme.Leanback” تغییر بدهید. و البته intent-filter های مربوط به لانچر کردن اکتیویتی هم فراموش نکنید!

<application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:supportsRtl="true"
    android:theme="@style/Theme.Leanback">
    <activity android:name=".MainActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN"/>

            <category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
        </intent-filter>
    </activity>
</application>

در ادامه هم از آنجایی که این تم action bar ندارد، جهت جلوگیری از خطای زیر تم اکتیویتی را به تغییر دهید.

java.lang.RuntimeException: Unable to start activity ComponentInfo{...} :
java.lang.IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this
activity.
(خطای احتمالی بدون تغییر تم)

4) و اولین اجرا!
یک نفس عمیق بکشید که وقت اجرا گرفتن از اولین پروژه Android Tv شماست.
برای اجرا گرفتن چند راه دارید:
1) استفاده از تلوزیون اسمارت که سیستم عامل اندروید دارد
2) استفاده از باکس های Android Tv مثل شیائومی
3) استفاده از شبیه ساز
که من در این آموزش از شبیه ساز استفاده میکنم
برای ساخت شبیه ساز تلوزیون اندروید، مثل زمانی که شبیه ساز گوشی اندروید میسازید، وارد AVD Manager بشید و این بار گزینه Android Tv رو انتخاب کنید:

در بخش آخر زمان تخصیص Ram هم تا حد ممکن رم کمتر از 2گیگ برای این شبیه ساز در نظر نگیرید تا از کندی اون جلوگیری کنید.

و حالا وقت زدن دکمه Run هست:

تبریک! شما اولین پروژه خود تلوزیون اندرویدی خودتون رو اجرا گرفتین😊

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

صفحه اپلیکیشن های تلوزیون

آیکون استاندارد برای Tv به اسم bannerهست که باید بر اساس داکیومنت سایت توسعه دهندگان اندروید گوگل، سایزش 320×180 بوده و داخلش متن نام اپلیکیشن قرار گرفته باشد. و البته اگر اپلیکیشن شما از زبان های مختلفی پشتیبانی می کند باید به ازای هر زبان یک بنر جداگانه با متن مربط به آن زبان داشته باشید.
به همین منظور من بنری با این سایز ایجاد کردم و داخل manifest این بنر رو قرار دادم(بنر ها باید داخل پوشه xhdpi قرار گیرند، مانند سایر resource های تلوزیون اندروید که بعلت سایز دستگاه داخل این پوشه قرار خواهند گرفت)

      <application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:supportsRtl="true"
    android:banner="@drawable/banner"
    android:theme="@style/Theme.Leanback">
    <activity android:name=".MainActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN"/>

            <category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
        </intent-filter>
    </activity>
</application>

و برای مشاهده تغییر باید احتمالا یک دور اپلیکیشن را از روی دستگاه پاک کرده و بعد دوباره نصب کنید.
در نهایت دو تگ زیر رو به بالای تگ application اضافه می کنم. این دو برای این هست که اعلام کنیم چه دستگاهایی ساپورت می کنن اپلیکیشن مارو. و همینطور داخل گوگل پلی این اپلیکیشن به چه دستگاهایی باید نمایش داده بشه:

<uses-feature
    android:name="android.hardware.touchscreen"
    android:required="false"/>
<!--
 true:  your app runs on only TV
 false: your app runs on phone and TV
-->
<uses-feature
    android:name="android.software.leanback"
    android:required="true"/>

یکی از کتابخانه های خیلی کاربردی برای کار با فایل Json ، کتابخانه Gson هست که توسط خود گوگل منتشر شده.

نحوه کار این کتابخانه به این صورت هست که اول به ازای تمام آبجکت هایی که جیسون مورد نظر ما داره، یک کلاس جاوا تعریف می کنیم و داخلش به ازای تمامی فیلد های جیسون متناظر، یک فیلد جاوا می گذاریم. (مثلا String یا Int یا…)

در نهایت کتابخانه Gson با متدها fromJson و toJson با یک خط کد، جیسون مورد نظر رو به آبجکت جاوای ما تبدیل می کنه و یا برعکس، با مقداری دهی به آبجکت جاوا، از اون یک جیسون می سازه.

لینک این کتابخانه: Github Gson

لینک فایل کد آموزش:

در این آموزش بطور کامل با مفاهیم وب سرویس و API آشنا خواهید شد. ابتدا بررسی می کنیم API چی هست و چه فرقی با وب سرویس داره. بعد یه مروری می کنیم روی متدهای http و به اینجا می رسیم که rest webservice چی هست و چه قوانینی رو باید رعایت کنیم تا وب سرویس ما restfull باشه.

این آموزش پیش نیاز همه آموزش های مرتبط با خواندن داده از اینترنت هست و جزو دسته بندی آموزش هایی هست که هم برای اندروید و هم برای ios بدردتون میخوره.

امیدوارم از این آموزش استفاده کامل بکنید و خودتون رو برای آموزش های بعدی آماده کنید. 🙂