JetPack知识点实战系列七:vlayout嵌套横向RecyclerView和Banner 实现主页的展示,自定义Moshi的JsonAdapter

本节教程我们来实现云音乐的主页展示,实现的效果如下图所示:

效果图

本节内容您将学习到如下内容:

  1. vlayout的介绍
  2. vlayout嵌套横向RecyclerView的使用
  3. Banner三方库的简单使用
  4. 自定义Moshi的JsonAdapter来解析同一个字段不同的数据类型的数据

vlayout架构分析

vlayoutRecyclerViewLayoutManager扩展库,VirtualLayoutManager这个类负责RecyclerView的UI布局。继承于RecyclerView.AdapterVirtualLayoutAdapter则是配合VirtualLayoutManager的对应的适配类。

大概的架构图如下所示:

vlayout架构图

  • VirtualLayoutManager负责整个RecyclerView界面布局排版绘制等工作
  • VirtualLayoutAdapter包含一系列的子Adapter,每个子Adapter负责RecyclerView某一部分的UI和数据的绑定工作。
  • 每个子Adapter包含一个下节会介绍的LayoutHelperLayoutHelper负责对应界面部分的UI排版布局绘制等工作

说明:这个架构中,大部分的内容vlayout以为为我们实现完成了,我们只需要在子Adapter中实现LayoutData的绑定工作就可以了。

vlayout布局介绍

vlayout主要提供了以下一系列的布局:

  • LinearLayoutHelper

LinearLayoutHelper

LinearLayoutHelper和系统提供的线性布局类似,能设置bgColor—背景颜色,bgImg—背景图片,diverHeight—分隔线高度等

  • GridLayoutHelper

2.png

GridLayoutHelper 和系统提供的网格布局类似,能设置spanCount—一行有几列,itemCount—总共多少个Item,vGap— item间的垂直间距,hGap— item间的水平间距,AutoExpand—最后一行如果没有足够的列数,是否充满整行

  • FixLayoutHelper

FixLayoutHelper

FixLayoutHelper的位置是固定的,不会随着RecyclerView滚动而滚动,位置可以根据alignType (TOP_LEFTTOP_RIGHTBOTTOM_LEFTBOTTOM_RIGHT)和XY值来确定。

  • ScrollFixLayoutHelper

ScrollFixLayoutHelper

ScrollFixLayoutHelperFixLayoutHelper类似也是固定位置显示的,但是可以当滚动到一定的位置时候才显示,如果showType设置为SHOW_ALWAYS,那两者就没有区别了

  • FloatLayoutHelper

FloatLayoutHelper

FloatLayoutHelper 可以设置setDragEnabletrue来实现可以拖动的效果。

  • StickyLayoutHelper

StickyLayoutHelper

StickyLayoutHelper可以设置StickyStart来控制吸附在顶部或者底部,这个用来设置不同Section的Header挺方便

  • ColumnLayoutHelper

ColumnLayoutHelper

ColumnLayoutHelper是设置几个Item占据一整行,通过设置setWeights让每个Item占据相应的比例宽度。

  • StaggeredGridLayoutHelper

StaggeredGridLayoutHelper

瀑布流布局,可以设置Item间的横向hGap和纵向vGap间距

  • OnePlusNLayoutHelper

OnePlusNLayoutHelper

1拖N的布局中每个Item占剩余空间的一半。可以设置itemCount来控制显示几个Item

基础工作准备

  • 修改首页布局文件内容
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/frameLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Fragment.DiscoveryMainFragment">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/main_recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:paddingBottom="20dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
  • 设置LayoutManagerAdapterRecycledViewPool
// 1
RecyclerView.RecycledViewPool().also {
    it.setMaxRecycledViews(1, 6)
    it.setMaxRecycledViews(2, 6)
    it.setMaxRecycledViews(3, 6)
    it.setMaxRecycledViews(4, 6)
    it.setMaxRecycledViews(5, 6)
    it.setMaxRecycledViews(6, 6)
    it.setMaxRecycledViews(7, 6)
    it.setMaxRecycledViews(8, 6)
    it.setMaxRecycledViews(9, 6)
    main_recyclerview.setRecycledViewPool(it)
    }

// 2
val layoutManager = VirtualLayoutManager(requireActivity()).also {
    main_recyclerview.layoutManager = it
}

// 3
main_recyclerview.adapter = DelegateAdapter(layoutManager, false)
  1. 首先设置回收池,需要针对不同的视图类型ViewType进行设置,需要根据不同的数据进行合理的设置,我们这里对每个都设为6
  2. 设置VirtualLayoutManager对象为RecyclerViewLayoutManager
  3. 设置DelegateAdapter对象为RecyclerViewAdapterDelegateAdaptervlayout提供的VirtualLayoutAdapter子类,可以直接使用

这里比较简单不做过多介绍。

  • 对子Adapter - DelegateAdapter.Adapter 进行抽象

由于子Adapter在项目中会非常的多,所以可以把一些公共的功能抽提出来,进行代码复用

open class BaseDelegateAdapter(protected val context: Context, private val layoutHelper: LayoutHelper, private val layoutId: Int, private val count: Int, protected val mViewType: Int) : DelegateAdapter.Adapter<BaseViewHolder>() {

    /* 创建ViewHolder */
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
        val v = LayoutInflater.from(parent.context).inflate(layoutId, parent, false)
        return BaseViewHolder(v)
    }

    /* 绑定ViewHolder */
    override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
    }

    /* 多少个Item */
    override fun getItemCount(): Int {
        return count
    }

    /* LayoutHelper */
    override fun onCreateLayoutHelper(): LayoutHelper {
        return layoutHelper
    }
}

BaseDelegateAdapter被抽提出来成为所有子Adapter的父类。构造函数中的contextlayoutHelper好理解,layoutId是对应界面的布局文件ID,count对应的是layoutHelper显示几个Item,mViewType标记视图类型,供RecyclerView进行View的复用。

上面的文件中还有一个BaseViewHolder类,它是RecyclerView.ViewHolder的子类,抽取了一些方法供子类复用。可以参考 BaseRecyclerViewAdapterHelper

vlayout实现轮播图

我们接下来实现轮播图的功能,效果如下:

轮播图效果

  • Banner的使用
  1. 引入库
// banner
implementation 'com.youth.banner:banner:2.1.0'
  1. Banner的使用
<com.youth.banner.Banner
        android:id="@+id/main_banner"
        android:layout_width="match_parent"
        android:layout_height="166dp"
        app:banner_auto_loop="true"
        app:banner_indicator_gravity="center"
        app:banner_indicator_marginBottom="21dp"
        app:banner_indicator_normal_color="#80FFFFFF"
        app:banner_indicator_normal_width="7dp"
        app:banner_indicator_selected_color="@color/colorAccent"
        app:banner_indicator_selected_width="7dp"
        app:banner_indicator_space="5dp"
        app:banner_infinite_loop="true" />

banner_auto_loop - 自动开始滚动;

banner_indicator_gravity - 指示器的位置;

banner_indicator_marginBottom - 指示器底部间距;

banner_indicator_normal_color - 指示器的颜色;

banner_indicator_selected_color - 指示器选中后的颜色;

banner_indicator_space - 指示器之间的间距;

banner_infinite_loop - 循环滚动;

  1. Banner添加Adapter

如果每个Item只是显示一张图片,可以不用自定义AdapterBanner库有提供一些默认的Adapter

我们每个Item显示一个图片,右下角还有个文本标签,我们自定义BannerImageTitleAdapter,代码如下:

class BannerImageTitleAdapter(data: List<HomeBanner>) : BannerAdapter<HomeBanner, BaseViewHolder>(data) {

    override fun onCreateHolder(parent: ViewGroup?, viewType: Int): BaseViewHolder {
        // viewHolder创建
        val view = LayoutInflater.from(parent!!.context).inflate(R.layout.layout_item_home_banner, parent, false)
        view.clipViewCornerByDp(6.0F)
        return BaseViewHolder(view)
    }

    override fun onBindView(holder: BaseViewHolder?, data: HomeBanner?, position: Int, size: Int) {

        val imageView = holder?.getView<ImageView>(R.id.banner_iv)
        val textView = holder?.getView<TextView>(R.id.bannber_title)

        // 设置图片
        imageView?.let {
            data?.let { bannerData ->
                Glide.with(holder!!.itemView)
                    .load(bannerData.pic)
                    .into(it)
            }
        }

        // 设置文本
        textView?.let {
            data?.let { bannerData ->
                it.text = bannerData.typeTitle
            }
        }

        // 设置背景颜色
        val shapeDrawable = holder?.getView<TextView>(R.id.bannber_title)?.background as? GradientDrawable
        shapeDrawable?.let {
            data?.let { bannerData ->
                it.setColor(Color.parseColor(bannerData.titleColor))
                holder.getView<TextView>(R.id.bannber_title).background = it
            }
        }
    }

}

BannerImageTitleAdapter中的这些方法是不是都很熟悉。没错Banner基于RecyclerView,所以BannerAdapterRecyclerView.Adapter的子类,将HomeBannerlayout_item_home_banner绑定在一起,然后将这些信息提供给Banner

data class HomeBanner(
    val pic: String,
    val typeTitle: String,
    val titleColor: String,
    val targetType: Long
)
<!-- layout_item_home_banner.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/linearLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginStart="16dp"
    android:layout_marginTop="16dp"
    android:layout_marginEnd="16dp"
    android:layout_marginBottom="16dp">

    <ImageView
        android:id="@+id/banner_iv"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scaleType="centerCrop"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@tools:sample/avatars" />

    <TextView
        android:id="@+id/bannber_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/left_top_corner_5_shape"
        android:paddingStart="5dp"
        android:paddingTop="5dp"
        android:paddingEnd="5dp"
        android:paddingBottom="5dp"
        android:text="TextView"
        android:textColor="#FFFFFF"
        android:textSize="11sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. 实现包含BannerHomeBannerAdapter

我们用HomeBannerAdapter来负责轮播图那部分的展示。
layoutHelper我们可以使用LinearLayoutHelperlayoutId使用的布局文件如下,count为1,viewType可以定义为1.

<!-- vlayout_banner.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">

    <com.youth.banner.Banner
        android:id="@+id/main_banner"
        android:layout_width="match_parent"
        android:layout_height="166dp"
        app:banner_auto_loop="true"
        app:banner_indicator_gravity="center"
        app:banner_indicator_marginBottom="21dp"
        app:banner_indicator_normal_color="#80FFFFFF"
        app:banner_indicator_normal_width="7dp"
        app:banner_indicator_selected_color="@color/colorAccent"
        app:banner_indicator_selected_width="7dp"
        app:banner_indicator_space="5dp"
        app:banner_infinite_loop="true" />

</LinearLayout>

HomeBannerAdapter文件中代码如下:

class HomeBannerAdapter(
    context: Context,
    layoutHelper: LayoutHelper,
    layoutId: Int,
    count: Int,
    mViewType: Int,
    private val bannerList: List<HomeBanner>,
    private val lifecycleOwner: LifecycleOwner
) : BaseDelegateAdapter(context, layoutHelper, layoutId, count, mViewType) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
        val holder = super.onCreateViewHolder(parent, viewType)
        holder.getView<Banner<HomeBanner, BannerImageTitleAdapter>>(R.id.main_banner).apply {
            // 
            adapter = BannerImageTitleAdapter(bannerList)
            addBannerLifecycleObserver(lifecycleOwner)
            indicator = CircleIndicator(context)
        }
        return holder
    }

}

此外,HomeBannerAdapter还多了两个构造参数,bannerListBanner的数据数组,lifecycleOwnerBanner在适当的时候取消加载图片和滚动的生命周期观察者。

  1. HomeBannerAdapter做为子Adapter添加到DelegateAdapter
// 添加HomeBannerAdapter
val bannerAdapter = HomeBannerAdapter(requireActivity(), LinearLayoutHelper(), R.layout.vlayout_banner, 1, ViewType.HOME_VIEW_TYPE_BANNER, data, this)
adapters.add(bannerAdapter)

至此,轮播图功能完成了。代码有点多且零散,还是来一张图片来总结下吧。

Banner

vlayout嵌套横向滑动RecyclerView

我们先来看下下面横向滑动的需求:

横向滑动

通过Banner的练习,我们可以联想到可以用一个横向滑动的RecyclerView搭配LinearLayoutHelper实现。我们开始吧。

  • 定义横向RecyclerView布局
<!-- vlayout_recyclerview.xml -->
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/frame"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview_hor"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:overScrollMode="never"
        android:scrollbars="none"
        app:fastScrollEnabled="false" />
</FrameLayout>
  • 定义Item 布局
<!-- layout_item_home_playlist -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/constraint"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/live_iv"
        android:layout_width="105dp"
        android:layout_height="105dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@mipmap/default_pic" />

    <LinearLayout
        android:id="@+id/live_tip_ll"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/live_tip_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="0"
            android:background="@drawable/right_bottom_corner_5_90percent_shape"
            android:ellipsize="end"
            android:lines="1"
            android:paddingStart="6dp"
            android:paddingTop="3dp"
            android:paddingEnd="6dp"
            android:paddingBottom="3dp"
            android:text="TextView"
            android:textColor="@color/colorPrimary"
            android:textSize="10sp" />
    </LinearLayout>

    <TextView
        android:id="@+id/live_tv"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:ellipsize="end"
        android:lines="2"
        android:text="TextView"
        android:textColor="@color/black_21_color"
        android:textSize="14sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/live_iv" />

</androidx.constraintlayout.widget.ConstraintLayout>

8.png

  • 定义Adapter - HomePlayListAdapter

Adapter的代码如下:

class HomePlayListAdapter(
    context: Context,
    layoutHelper: LayoutHelper,
    layoutId: Int,
    count: Int,
    mViewType: Int,
    private val creatives: List<Creatives>
): BaseDelegateAdapter(context, layoutHelper, layoutId, count, mViewType) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
        val viewHolder = super.onCreateViewHolder(parent, viewType)
        // 1.
        viewHolder.getView<RecyclerView>(R.id.recyclerview_hor).apply {
            // 2
            layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
            // 3
            addItemDecoration(object : RecyclerView.ItemDecoration() {
                override fun getItemOffsets(
                    outRect: Rect,
                    view: View,
                    parent: RecyclerView,
                    state: RecyclerView.State
                ) {
                    val position = parent.getChildAdapterPosition(view)
                    if (position == RecyclerView.NO_POSITION) return
                    when (position) {
                        0 -> outRect.set(context.dp2px(16.0F), 0, context.dp2px(10.0F), 0)
                        creatives.size - 1 -> outRect.set(0, 0, context.dp2px(16.0F), 0)
                        else -> outRect.set(0, 0, context.dp2px(10.0F), 0)
                    }
                }
            })
            // 4
            adapter = object : RecyclerView.Adapter<BaseViewHolder>() {

                override fun onCreateViewHolder(
                    parent: ViewGroup,
                    viewType: Int
                ): BaseViewHolder {
                    return BaseViewHolder(
                        LayoutInflater.from(parent.context)
                            .inflate(R.layout.layout_item_home_playlist, parent, false)
                    )
                }

                override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
                    holder.getView<ImageView>(R.id.vlog_iv).apply {
                        loadRoundCornerImage(
                            context,
                            EmptyEx.checkStringNull(creatives[position]?.uiElement?.image?.imageUrl)
                        )
                    }
                    holder.getView<TextView>(R.id.vlog_title_tv).text =
                        EmptyEx.checkStringNull(creatives[position]?.uiElement?.mainTitle?.title)
                    holder.getView<TextView>(R.id.vlog_zan_tv).text =
                        EmptyEx.checkLongNull(creatives[position]?.resources?.get(0)?.resourceExtInfo?.playCount)
                            .playCountString(context)
                }

                override fun getItemCount(): Int {
                    return creatives.size
                }
            }
        }
        return viewHolder
    }

}

代码解释如下:

  1. onCreateViewHolder创建ViewHolder的时候找到RecyclerView.
  2. RecyclerViewLayoutManager设置为横向RecyclerView.HORIZONTAL
  3. 添加ItemDecoration,让Item间有适当的间隔。
  4. 添加Adapter,布局文件为上面的添加的布局
  • HomePlayListAdapter做为子Adapter添加到DelegateAdapter
val playAdapter = HomePlayListAdapter(requireActivity(), LinearLayoutHelper(), R.layout.vlayout_recyclerview, 1, ViewType.HOME_VIEW_TYPE_SLIDE_PLAYLIST, creative)
adapters.add(playAdapter)

至此vlayout嵌套横向RecyclerView的功能就完成了。

字段内容类型不一致

首页的接口有一个特殊的地方,ExtInfo在博客的列表中是Map,在直播的列表中是List。如果直接解析肯定是有问题,会直接崩溃。

冲突

解决方案是自定义Moshi的JsonAdapter。

  • 修改ExtInfo类,可以接收两种数据类型
data class ExtInfo constructor (
    val liveExt: List<LiveExt>?,
    val blogExt: BlogExt?
) {
    constructor(liveExt: List<LiveExt>) : this(liveExt, null)
    constructor(blogExt: BlogExt): this(null, blogExt)
}

如果表示直播就是给liveExt赋值,如果表示博客就是给blogExt赋值。

  • 添加ExtInfo类的JsonAdapter
class ExtInfoAdapter {
    // 1
    @FromJson fun fromJson(reader: JsonReader): ExtInfo {
        val jsonValue = reader.readJsonValue()
        return when (jsonValue) {
            is List<*> -> {
                var lists = mutableListOf<LiveExt>()
                jsonValue.forEach {
                    val map = it as? Map<String, Any>
                    map?.let { map ->
                        val popularity = (map["popularity"] as Double).toLong()
                        val verticalCover = map["verticalCover"] as String
                        val startStreamTagName = map["startStreamTagName"] as String
                        val title = map["title"] as String
                        val ext = LiveExt(
                            popularity,
                            verticalCover, startStreamTagName, title
                        )
                        lists.add(ext)
                    }
                }
                // 2
                ExtInfo(lists)
            }
            is Map<*, *> -> {
                var title: String? = jsonValue["moduleName"] as String?
                val squareFeedViewDTOList =
                    jsonValue["squareFeedViewDTOList"] as? List<Map<String, *>>
                var lists = mutableListOf<BlogDetail>()
                squareFeedViewDTOList?.let { feedList ->
                    for (map in feedList) {
                        val resources = map["resource"] as? Map<String, *>
                        val mlogBaseData = resources?.get("mlogBaseData") as? Map<String, *>
                        val coverUrl = mlogBaseData?.get("coverUrl") as? String
                        val id = mlogBaseData?.get("id") as? String

                        val talk = mlogBaseData?.get("talk") as? Map<String, *>
                        val talkDesc = talk?.get("talkDesc") as? String

                        val mlogExt = resources?.get("mlogExt") as? Map<String, *>
                        val likedCount = (mlogExt?.get("likedCount") as Double).toLong()

                        lists.add(BlogDetail(id, talkDesc, coverUrl, likedCount))
                    }
                }
                // 3
                ExtInfo(BlogExt(lists, title))
            }
            // 4
            else -> throw JsonDataException("Expected a field of type List or Map")
        }
    }

}
  1. @FromJson 表示从JSONExtInfo对象时候调用这个方法
  2. List<*>的时候解析数据,调用ExtInfo(lists)构造函数
  3. Map<*>的时候解析数据,调用ExtInfo(BlogExt(lists, title))构造函数
  4. 其他数据类型,报错
  • 修改RetrofitConverterFactory
// 1
val moshi = Moshi.Builder()
                .add(ExtInfoAdapter())
                .build()
                
val retrofit = Retrofit.Builder()
                .baseUrl(MusicApiConstant.BASE_URL)
                .client(okHttpClient)
                // 2
                .addConverterFactory(MoshiConverterFactory.create(moshi))
                .build()                
  1. moshi解析器添加ExtInfoAdapter
  2. MoshiConverterFactory构造的时候传入moshi

至此,Retrofit解析ExtInfo时就能自动解析不同的数据类型了。

总结

首页的其他内容也类似,只是layout不一样,然后定义相应类型的Adapter 然后加入到添加到DelegateAdapter中。这样其他的工作就交给vlayout去自动实现了。

猜你喜欢

转载自blog.csdn.net/lcl130/article/details/108773663