玩转ViewPager2以及在自如中的应用

6,686 阅读8分钟

一、简介

姓名:ViewPager2

生日:2019年11月20日

地址:androidx.viewpager2.widget

背景:

在2019年的11月20号,Google对外公开了一件大事!让很多Android开发者激动万分~ 那就是公布了大家期待已久的 ViewPager2 正式版。他属于 androidx 组件包中的一员,ViewPager2 可以看做是 ViewPager的升级版,所以很多场景是可以按照ViewPager的使用方式来用的。ViewPager2的进阶处理是内部采用了RecyclerView实现,解决了很多在使用ViewPager过程中的问题同时还增加了一些自身的特性~ 接下来就让我们一起来认识一下他吧~

二、基础

1.差异对比

谷歌在源码中有这样介绍ViewPager2

ViewPager2 replaces ViewPager, addressing most of its predecessor’s pain-points, including right-to-left layout support, vertical orientation, modifiable Fragment collections, etc.

用如下表格清晰的展示对比:

ViewPagerViewPager2
PagerAdapterRecyclerView.Adapter
FragmentStatePagerAdapterFragmentStateAdapter
addPageChangeListenerregisterOnPageChangeCallback
不支持支持RTL
不支持支持垂直滑动
不支持支持停止用户操作

个人感觉最爽的改动点在于内部的RecyclerView实现以及支持垂直滚动

2.基本使用

以ViewPager2来实现ViewPager最基础的功能【图片banner】为例:

前提是需要保证项目代码支持androidx的呦~参考官网

2.1 添加依赖(需要单独添加依赖~),

dependencies {
    implementation "androidx.viewpager2:viewpager2:version"
}

2.3 布局文件使用

在xml中使用,没有什么特殊的点,类似常见控件使用方式即可。设置宽高、ID、位置等等条件

    <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/view_pager2"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintDimensionRatio="2:3"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

2.4 设置Adapter

因为内部RecycleRiew实现的,所以ViewPager2的Adapter就需要设置为RecyclerView的Adapter

class VpAdapter : RecyclerView.Adapter<VpAdapter.VpViewHolder>() {

    // adapter的数据源
    private var data: MutableList<HouseItem> = mutableListOf()

    fun setData(list: MutableList<HouseItem>) {
        data.clear()
        data.addAll(list)
        notifyDataSetChanged()
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VpViewHolder {
        val view =
            LayoutInflater.from(parent.context).inflate(R.layout.item_rv2_basic_view, parent, false)
        return VpViewHolder(view)
    }

    override fun onBindViewHolder(holder: VpViewHolder, position: Int) {
        holder.render(data[position])
    }

    override fun getItemCount() = data.size

    class VpViewHolder(_itemView: View) : RecyclerView.ViewHolder(_itemView) {
        private var pvImage: SimpleDraweeView = itemView.findViewById(R.id.pv_image)
        private var tvTitle: TextView = itemView.findViewById(R.id.tv_title)

        fun render(bean: HouseItem) {
            tvTitle.text = bean.houseTypeName
            pvImage.setImageURI(bean.houseTypePic)
        }
    }
}

布局文件也很简单,单纯的放置了一个SimpleDraweeView+一个TextView用来展示一张图片+一个标题; 可以看到,这跟我们平常使用RecyclerView时写的Adapter没有任何区别。所以在使用的时候是不会有任何阻碍的~

(SimpleDraweeView的使用在此不赘述,感兴趣的小伙伴可参考官网学习SimpleDraweeView)

2.5 配置属性

类似于RecyclerView~ 初始化ViewPager2、初始化Adapter、设置数据源、给ViewPager2设置Adapter,就能实现类似ViewPager的Banner效果了~

        adapter = VpAdapter()
        adapter.setData(list)
        viewPager2.adapter = adapter

效果如图: Video

前边讲述区别的时候,有提到过ViewPager2默认支持垂直滑动,那么我要实现垂直滚动的Banner,该怎么处理呢?

其实很简单,只需要设置ViewPager2的滚动方向属性就行了~

        // 设置垂直滚动方向
        viewPager2.orientation = ViewPager2.ORIENTATION_VERTICAL 

至此,关于ViewPager2基本的使用都介绍完了~

三、进阶

1.页面切换监听

ViewPager2的页面切换监听,提供了 registerOnPageChangeCallback 的方法,传递一个 抽象对象 OnPageChangeCallback,好处是三个方法不需要一次性列出来了,用到哪个写哪个。

    public abstract static class OnPageChangeCallback {
       
        public void onPageScrolled(int position, float positionOffset,
                @Px int positionOffsetPixels) {
        }

        public void onPageSelected(int position) {
        }

        public void onPageScrollStateChanged(@ScrollState int state) {
        }
    }

通过以上源码解释,常用的应该就是 onPageSelected(int position)方法了,监听到滑动到对应索引值的页面。

        // 页面切换监听
        viewPager2.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {

            // 选中 回调,核心用于 处理选中索引值相关逻辑
            override fun onPageSelected(position: Int) {
                super.onPageSelected(position)
                Log.e(TAG, "onPageSelected: position = $position")
            }
        })

2.一屏多页

ViewPager2如果要实现一屏多页的效果,需要借助于 offscreenPageLimit 以及 setPadding

        viewPager2.apply {
            offscreenPageLimit = 1
            val recyclerView = this.getChildAt(0) as RecyclerView // 获取ViewPager2中的 RecyclerView

            recyclerView.apply {
                val padding = 40.dp
                setPadding(padding, 0, padding, 0)
                clipToPadding = false
            }
        }
val recyclerView = this.getChildAt(0) as RecyclerView

针对这个地方,参考源码254行,可以看到,ViewPager2内部是默认将 mRecyclerView添加到ViewGroup中的,索引值固定是0!

attachViewToParent(mRecyclerView, 0, mRecyclerView.getLayoutParams());

3.offscreenPageLimit 预加载

在ViewPager中,会默认加载前后各一个页面,所以处理懒加载就比较麻烦,那么在ViewPager2中,默认去掉了这限制,也就是默认只会加载一个页面, 先来看下 参数

    public void setOffscreenPageLimit(@OffscreenPageLimit int limit) {
        if (limit < 1 && limit != OFFSCREEN_PAGE_LIMIT_DEFAULT) {
            throw new IllegalArgumentException(
                    "Offscreen page limit must be OFFSCREEN_PAGE_LIMIT_DEFAULT or a number > 0");
        }
        mOffscreenPageLimit = limit;
        // Trigger layout so prefetch happens through getExtraLayoutSize()
        mRecyclerView.requestLayout();
    }

源码中的setOffscreenPageLimit设置需要处理一个参数,可以看到有个注解OffscreenPageLimit表明默认值是需要从1开始的。

接下来我用ViewPager2加载Fragment来证明,默认以Fragment的生命周期日志为参照

3.1 offscreenPageLimit 不设置

E/debug_VpFragment: onCreate:  mCurrentPosition = 0
E/debug_VpFragment: onPause:  onCreateView = 0
E/debug_VpFragment: onResume:  mCurrentPosition = 0

可以看到,默认对此方法不做处理的话,只对第一个Fragment进行了初始化的相关操作。

3.2 offscreenPageLimit = 1

E/debug_VpFragment: onCreate:  mCurrentPosition = 0
E/debug_VpFragment: onPause:  onCreateView = 0
E/debug_VpFragment: onResume:  mCurrentPosition = 0
E/debug_VpFragment: onCreate:  mCurrentPosition = 1
E/debug_VpFragment: onPause:  onCreateView = 1

设置offscreenPageLimit = 1的时候,对前两个Fragment进行了初始化相关操作。

3.3 offscreenPageLimit = 2

E/debug_VpFragment: onCreate:  mCurrentPosition = 0
E/debug_VpFragment: onPause:  onCreateView = 0
E/debug_VpFragment: onResume:  mCurrentPosition = 0
E/debug_VpFragment: onCreate:  mCurrentPosition = 1
E/debug_VpFragment: onPause:  onCreateView = 1
E/debug_VpFragment: onCreate:  mCurrentPosition = 2
E/debug_VpFragment: onPause:  onCreateView = 2

设置offscreenPageLimit = 2的时候,对前三个Fragment进行了初始化相关操作。

4.结合TabLayout

TabLayout结合ViewPager也是比较常用的,但是ViewPager2结合使用的时候,需要多一个TabLayoutMediator

TabLayoutMediator接受三个参数:

参数1:TabLayout

参数2:ViewPager2

参数3:TabConfigurationStrategy (interface)

重点是第三个参数,需要重写onConfigureTab(@NonNull TabLayout.Tab tab, int position),所以如果我们想要实现一些自定义的Tab样式,可以直接在此方法中处理,同时可以拿到索引值进行特殊处理。

        new TabLayoutMediator(mTabLayout, mViewPager2, new TabLayoutMediator.TabConfigurationStrategy() {
            @Override
            public void onConfigureTab(@NonNull TabLayout.Tab tab, int position) {
                TextView textView = new TextView(mFragmentActivity);
                textView.setText(mVpBeans.get(position).getTitle());
                tab.setCustomView(textView);
            }
        }).attach();

项目实战

自如寓,隶属于自如大家庭的重要一员,是一种更加年轻的、社交化的集中式公寓,具有多种社交活动等功能,所以自如寓的管家们会将日常组织的活动中的一些精彩视频、图片记录下来,并上传到“好玩”tab的数据里边,并以瀑布流的形式展示。 基于用户体验以及交互形式,我们考虑针对这些视频、图片做一种上下滑动播放视频、横向滑动展示图片的一种效果,考虑到了上述ViewPager2的特性,尝试使用实现

大致思路

页面容器中,采用ViewPager2来承载每一个切换的Page;Page以Fragment实现;Fragment中区分视频类型/图片类型,分别以视频播放器/图片banner来承载

页面结构

考虑到了ViewPager2的垂直滚动功能,毫不犹豫的进行尝试一下子;页面结构实现简单化,在对应的页面中,整体结构就是ViewPager2+TopView(一些自定义的样式展示,此处忽略),然后VpFragmeng作为每一个Page的承载,内含播放器控件或者轮播控件(展示横滑图片) 大致如图:

Activity中的内容比较简单,主要就是对ViewPager2、Fragment绑定的处理。

  private fun initView() {
        mAdapter = PageAdapter(this, mModelList)
        mAdapter?.setOnAdapterDataChangeListener(object : PageAdapter.OnAdapterDataChangeListener {
            override fun onPageChangeListener(selectedPosition: Int) {
                mTvCurrentPage?.text = String.format("%d", selectedPosition + 1)
            }

            override fun onSupportClick(bean: PlayEvaluationListModel.RowsBean?, position: Int, status: Boolean) {
            }

            override fun onImageClick() {
                finish()
            }
        })

        // 设置垂直滚动效果
        mViewPager2?.orientation = ViewPager2.ORIENTATION_VERTICAL
        mViewPager2?.adapter = mAdapter

        mViewPager2?.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
            override fun onPageSelected(position: Int) {
                super.onPageSelected(position)
                // 处理顶部的指示器数据
                //这只当前item的数据
                val model = mModelList[position]
              
                // 准备加载更多的数据
                if (position == mModelList.size - 2) { // -2 ,是为了在倒数第二个的时候就让他进行分页,提前一个加载
                    if (!mCanLoadMore) {
                        return
                    }
                    mPresenter?.fillData()
                }
            }
        })

        initTitlePosition()
    }

Adapter的代码就不贴了,跟demo中类似,绑定Fragment就行了~

PageFragment,作为每一个Page的承载,使用方式跟普通Fragment没什么本质区别;

常规数据初始化
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mFragmentActivity = activity
        if (arguments != null) {
            mRowsBean = arguments?.getSerializable("rowsBean") as PlayEvaluationListModel.RowsBean
            mCurrentPosition = arguments?.getInt("position") ?: 0
        }
    }
绑定View
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.item_play_evaluation_list, container, false)
        initView(view)
        return view
    }

// 这个是具体的展示逻辑,根据是视频类型还是图片类型,分别展示 视频播放器 或者 图片banner控件;

    private fun render() {
        val model = mRowsBean ?: return
        if (model.isVideo) {
            mZvvVideo?.visible = true
            mCpPicList?.visible = false
            val pic = if (null != model.picList && model.picList.size >= 1) model.picList[0].picUrl else ""
            mZvvVideo?.setPlaceImg(pic)
            mZvvVideo?.setVideoPath(model.videoUrl)

            mZvvVideo?.setOnClickListener { _: View? ->
                if (mZvvVideo?.videoView != null && mZvvVideo?.videoView?.isPlaying == true) {
                    mZvvVideo?.pause()
                } else {
                    mZvvVideo?.startVideo()
                }
            }
            var imgWidth = 720.0
            var imgHeight = 1280.0
            // 视频和图片的宽高
            if (model.picList != null && model.picList.isNotEmpty()) {
                val picListBean = model.picList[0]
                if (picListBean != null) {
                    imgWidth = picListBean.width
                    imgHeight = picListBean.height
                }
            }

            // 手动赋值宽高
            val lp: ViewGroup.LayoutParams = mZvvVideo?.layoutParams
                    ?: ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
            lp.width = mScreenWidth
            var newH = imgHeight

            // 竖屏视频
            if (imgWidth > 0) {
                newH = mScreenWidth * imgHeight / imgWidth
            }

            lp.height = newH.toInt()
            mZvvVideo?.videoView?.setOnVideoSizeChangedListener { _: Int, _: Int ->

                mZvvVideo?.videoView?.displayAspectRatio = PLVideoView.ASPECT_RATIO_FIT_PARENT
            }
        } else {
            mZvvVideo?.visible = false
            mCpPicList?.visible = true
            mCpPicList?.isCanLoop = false

            mCpPicList?.setPages({
                val detailPlayPicHolder = DetailPlayPicHolder()
                detailPlayPicHolder.setOnAdapterDataChangeListener(object : OnAdapterDataChangeListener {
                    override fun onPageChangeListener(selectedPosition: Int) {}
                    override fun onSupportClick(bean: PlayEvaluationListModel.RowsBean?, position: Int, status: Boolean) {}
                    override fun onImageClick() {
                        mOnAdapterDataChangeListener?.onImageClick()
                    }
                })
                detailPlayPicHolder
            }, model.picList)

            mCpPicList?.onPageChangeListener = object : ViewPager.OnPageChangeListener {
                override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
                override fun onPageSelected(page: Int) {
                    mOnAdapterDataChangeListener?.onPageChangeListener(page)
                }

                override fun onPageScrollStateChanged(state: Int) {}
            }
        }
        
        // ....省略....
    }

考虑到视频播放状态要与用户操作页面状态结合,所以需要在页面切换出去的时候,将对应的视频播放器进行暂停或者停止操作(具体采取何种操作视情况而定),在此处我们采用的是暂停方案,当用户手动切换回一个刚切换出去的Page,可以实现继续播放上次未播放完毕的视频。

    override fun onResume() {
        super.onResume()
        mZvvVideo?.start()
    }

    override fun onPause() {
        super.onPause()
        mZvvVideo?.pause()
    }

至此,实现了支持上下滑动切换视频/图片、左右切换图集的功能。 以上,仅为本人实战体验,如有更优姿势,欢迎不吝赐教~

本文作者:自如大前端研发中心-邱宴峰