视频播放列表初步 —— 简单实现今日头条与微淘视频列表效果
最近开始流行列表播放视频效果,比如今日头条、FaceBook、淘宝等,那我们就来看看怎样在列表中播放视频吧
本文涉及到的代码已经上传到github
来看看今日头条与微淘的效果
![](https://img.haomeiwen.com/i1018039/dc61c8c1c5739191.png)
![](http://upload-images.jianshu.io/upload_images/1018039-04123bb7e6e7029c.png)
为什么要把这两个都贴出来?因为它们的体验效果并不同。今日头条是点击Item后视频进行播放,一旦播放的Item被移动到可见范围之外,播放则停止进而复原;而微淘则是在滚动列表的过程中,一旦Item处于可见范围并且完全显示,则进行视频播放,被移动到可见范围之外,播放则停止进而复原
预备知识
想要实现整体功能,我们必须先掌握关于RecyclerView的几个小概念
getChildAt:可以通过index来获取当前列表中可见Item的视图。注意传入的入参并不是这个Item的position,而是按照可见顺序从0开始依次加1的索引。获取不在可见范围内的视图那么该方法将返回null
findViewByPosition:用来对比getChildAt,它的功能与其相同,不同的地方是它传入的入参是这个Item的position
findViewHolderForAdapterPosition:如果你不需要返回itemView而是直接返回该Item对应的ViewHolder,那么你可以使用这个方法。同样传入的入参是这个Item的position
findFirstVisibleItemPositions:通过LinearLayoutManager去调用,获取当前可见部分中第一个View的position。这个position是这个Item的position
findLastVisibleItemPosition:大体概念同findFirstVisibleItemPositions,获取当前可见部分中最后一个View的position。这个position也是这个Item的position
getChildCount:获取当前可见Item的总数,也就是说你能看到3个Item的话这个值就是3
indexOfChild:通过相应Item的视图去获取该Item在可见视图中的的索引。如果你能看到3个Item的话,把其中第0个Item的视图放进去,那么它的返回值索引就是0
addOnChildAttachStateChangeListener:添加Item视图被添加或移除屏幕关联的监听事件,其中onChildViewDetachedFromWindow方法代表被移除,onChildViewAttachedToWindow代表被添加
完成以上预备知识,我们就开始真正的代码编写工作吧
搭建RecyclerView
为了更方便的理解后续代码,我还是把所有基础代码都添加完全
先看看adapter的布局,tv_main用来显示position的序号,tv_percent用来显示视图可见的比例
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="300dip">
<RelativeLayout
android:id="@+id/layout_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<TextView
android:id="@+id/tv_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"/>
<TextView
android:id="@+id/tv_percent"
android:gravity="right|center_vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</RelativeLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dip"
android:background="@color/colorAccent"></View>
</LinearLayout>
activity的代码
class MainActivity2 : AppCompatActivity() {
var adapter: MainAdapter2? = null
private val beans: ArrayList<String> by lazy {
ArrayList<String>()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
for (i in 0..19) {
beans.add(""+i)
}
adapter = MainAdapter2(beans, this, rv_main)
rv_main.layoutManager = LinearLayoutManager(this)
rv_main.setHasFixedSize(true)
rv_main.adapter = adapter
}
}
adapter的代码
class MainAdapter2(private val beans: ArrayList<String>, private val context: Context, private val recyclerView: RecyclerView) : RecyclerView.Adapter<MainAdapter2.MainHolder2>() {
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): MainHolder2 {
val view = LayoutInflater.from(context).inflate(R.layout.adapter_main, parent, false)
return MainHolder2(view)
}
override fun getItemCount() = beans.size
override fun onBindViewHolder(holder: MainHolder2?, position: Int) {
holder?.setText(beans[position])
}
class MainHolder2(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun setText(text: String) {
itemView.tv_main.text = text
}
}
}
好了,基础的代码部分我们已经完成了,现在开始一步步开始玩耍了
视图可见部分显示比例
不知道大家是否记得有这么一个方法getLocalVisibleRect(),它的作用是获取视图本身可见的坐标区域,并且坐标以自己的左上角为原点。什么意思呢?来看个例子吧
同样一个最简单的布局,把一个宽高100dp的view放在布局的左上角
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:id="@+id/view_visibility"
android:layout_width="100dip"
android:layout_height="100dip"
android:background="@android:color/holo_blue_dark"></View>
</RelativeLayout>
在布局加载完成之后显示视图的位置
class VisibilityActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_visibility)
view_visibility.post {
val rect = Rect()
view_visibility.getLocalVisibleRect(rect)
println(rect)
}
}
}
![](http://upload-images.jianshu.io/upload_images/1018039-75b6f236a487d6d4.png)
这时候logcat显示的rect的left top right bottom
Rect(0, 0 - 263, 263)
这时候我们给view_visibility向下移动100dp,但是控件仍然完全显示
android:layout_marginTop="100dip"
![](http://upload-images.jianshu.io/upload_images/1018039-024b2af88cad7056.png)
来看看这会会打印什么结果出来
Rect(0, 0 - 263, 263)
没有发生变化
好,我们将其向上移动-50dp,使其垂直方向视图的一半不在屏幕中
android:layout_marginTop="-50dip"
![](http://upload-images.jianshu.io/upload_images/1018039-26e2877317fa236d.png)
来看看这会会打印什么结果出来
Rect(0, 131 - 263, 263)
top的值发生了变化。
我们记住这一变化,继续看下面一个情况。我们将其向下移动400dp,使其垂直方向视图只留在屏幕一小部分
android:layout_marginTop="550dip"
![](http://upload-images.jianshu.io/upload_images/1018039-46dbc1d9a8bb78ab.png)
来看看这会会打印什么结果出来
Rect(0, 0 - 263, 140)
现在轮到bottom的值发生了变化
来分析一下其中的奥秘:我们在完全可见的情况下,那肯定就是(0, 0 - 263, 263),因为这个坐标是以自身布局左上角为起始点,所以只要你完全可见,无论你上下左右如何移动,rect的值是不会变的。如果通过移动而导致上方部分不可见,那么相对于视图自身左上角,top值肯定就大于0了;同理下方部分不可见,bottom值也会比总高度要小
这样我们就可以得到这样一个工具类
class Utils {
companion object {
/**
* 返回显示的比例
*/
fun getVisibilityPercents(view: View) : Int {
val rect = Rect()
val height = if (view == null || view.visibility != View.VISIBLE) 0 else view.height
if (height == 0) {
return 0
}
if (view.getLocalVisibleRect(rect)) {
return when {
viewIsPartiallyHiddenTop(rect) -> {
(height-rect.top)*100/height
}
viewIsPartiallyHiddenBottom(rect, height) -> {
rect.bottom*100/height
}
else -> 100
}
}
return 0
}
/**
* 底部视图被遮挡
*/
private fun viewIsPartiallyHiddenBottom(currentViewRect: Rect, height: Int) = currentViewRect.bottom in 1..(height - 1)
/**
* 顶部视图被遮挡
*/
private fun viewIsPartiallyHiddenTop(currentViewRect: Rect) = currentViewRect.top>0
}
}
RecyclerView可见部分显示比例
基于以上的知识,我们开始完成RecyclerView可见比例计算,首先添加滚动事件监听addOnScrollListener
RecyclerView一共有三种滚动事件类型:
SCROLL_STATE_IDLE:滑动结束
SCROLL_STATE_SETTLING:惯性滑动中
SCROLL_STATE_DRAGGING:手指拖动中
正常的滚动事件流程应该是这样的
onScrollStateChanged:SCROLL_STATE_DRAGGING
onScrolled(省略多次调用)
onScrollStateChanged:SCROLL_STATE_SETTLING
onScrolled(省略多次调用)
onScrollStateChanged:SCROLL_STATE_IDLE
所以我们需要onScrolled以及onScrollStateChanged的SCROLL_STATE_IDLE时去触发RecyclerView可见部分显示比例的计算
rv_main.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
refreshPercent()
}
override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
scrollState = newState
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
refreshPercent()
}
}
})
private fun refreshPercent() {
val manager = rv_main.layoutManager as LinearLayoutManager
val first = manager.findFirstVisibleItemPosition()
for ( i in 0 until rv_main.childCount) {
val percent = Utils.getVisibilityPercents(rv_main.getChildAt(i))
println("" + (first+i) + " " + percent)
}
}
剩下就是将回调方法里面的数据刷新到UI上面了,当然需要接口来实现
interface ListItem {
// 设置进度
fun setPercent(percent: Int)
}
我们在Holder中实现这个接口
class MainHolder2(itemView: View) : RecyclerView.ViewHolder(itemView), ListItem {
override fun setPercent(percent: Int) {
itemView.tv_percent.text = ""+percent
}
fun setText(text: String) {
itemView.tv_main.text = text
}
}
然后我们要在adapter中提供方法,因为adapter对象在外面,可以被内部类使用。通过findViewHolderForAdapterPosition
方法就可以找到相应的viewHolder对象,然后进行percent更新
fun getItem(position: Int): ListItem? {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position)
if (viewHolder is MainHolder2) {
return viewHolder
}
return null
}
修改一下之前的refreshPercent方法
private fun refreshPercent() {
val manager = rv_main.layoutManager as LinearLayoutManager
val first = manager.findFirstVisibleItemPosition()
for ( i in 0 until rv_main.childCount) {
val percent = Utils.getVisibilityPercents(rv_main.getChildAt(i))
adapter?.getItem(first+i)?.setPercent(percent)
}
}
来看看效果
![](http://upload-images.jianshu.io/upload_images/1018039-df87a5b41e0e7604.gif)
微淘效果实现
微淘里有一个投机取巧的地方不知道你有没有注意到,它里面100%完全可见的Item始终只有一个,这样就将开发的难度大大降低了。我们只需要理清楚如下问题即可:
- 找到100%可见的视图集合。
- 找到之后,循环执行激活动作,同时取消之前的激活动作
理清楚这两个问题之后我们开始代码的编写
首先要将adapter中的itemView高度给调整成350dp,这样保证视图高度足够高。同时对之前的接口进行扩展,引入激活与反激活的功能
interface ListItem {
// 当前显示项中的某一项从不完全显示到完全显示时使用
fun setActive()
// 当前显示项中的某一项从完全显示到不完全显示时使用
fun deActive()
// 设置进度
fun setPercent(percent: Int)
}
然后就是播放视频了,我这里仅以变色作为示例,那种在滑动列表中的异步缓存和播放功能的视频组件我们以后再说
fun play() {
val manager = rv_main.layoutManager as LinearLayoutManager
val first = manager.findFirstVisibleItemPosition()
for (i in 0 until rv_main.childCount) {
val percent = Utils.getVisibilityPercents(rv_main.getChildAt(i))
if (percent == 100 && lastPosition != first+i) {
adapter?.getItem(lastPosition)?.deActive()
adapter?.getItem(first+i)?.setActive()
lastPosition = first+i
}
}
}
最后将play()加在之前refreshPercent()方法之后即可
来看看效果
![](http://upload-images.jianshu.io/upload_images/1018039-435684f942f18965.gif)
今日头条效果实现
今日头条效果做起来也很简单,我们把之前的play()方法注释掉。我们用之前说到的addOnChildAttachStateChangeListener
方法来实现这个效果。
首先要将adapter中的itemView高度给调整成150dp,反正不要太大就行了
视图出现的时候添加点击事件。在新视图激活的情况下,如果上一次激活的视图在可见范围内,则反激活
override fun onChildViewAttachedToWindow(view: View?) {
val manager = rv_main.layoutManager as LinearLayoutManager
val first = if (manager.findFirstVisibleItemPosition()<0) 0 else manager.findFirstVisibleItemPosition()
println("onChildViewAttachedToWindow:"+(first + rv_main.indexOfChild(view)))
// 视图出现的时候添加点击事件。在新视图激活的情况下,如果上一次激活的视图在可见范围内,则反激活
view?.setOnClickListener {
if (Utils.getVisibilityPercents(view!!) == 100) {
if (lastPosition != -1) {
adapter?.getItem(lastPosition)?.deActive()
}
lastPosition = manager.findFirstVisibleItemPosition() + rv_main.indexOfChild(view)
adapter?.getItem(lastPosition)?.setActive()
}
}
}
当前视图被移除时如果处于激活状态,则进行反激活。这里有一个地方要注意下,就是要区分滚动方向。如果从上往下拉的话,是顶部的视图被移除反激活;如果从下往上拉的话,则是底部的视图被移除反激活
override fun onChildViewDetachedFromWindow(view: View?) {
val manager = rv_main.layoutManager as LinearLayoutManager
val first = manager.findFirstVisibleItemPosition()
val last = manager.findLastVisibleItemPosition()
// 当前视图被移除时如果处于激活状态,则进行反激活
if (direction == ScrollDirection.Up) {
println("onChildViewDetachedFromWindow:"+(first))
if (first == lastPosition) {
adapter?.getItem(first)?.deActive()
lastPosition = -1
}
}
else if (direction == ScrollDirection.Down) {
println("onChildViewDetachedFromWindow:"+(last))
if (last == lastPosition) {
adapter?.getItem(last)?.deActive()
lastPosition = -1
}
}
// 视图被销毁的时候去除点击事件
view?.setOnClickListener {
}
}
那么这个direction怎么判断呢?不知道你之前有没有看过我简单实现StickHeader粘性列表特效这篇文章,其中有讲到Item切换时候其top值会发生变化,我们就可以通过这个top值以及firstVisibleItem值进行判断。在firstVisibleItem值不发生变化的时候,如果top值越来越小,说明滑动方向是从下到上;如果firstVisibleItem值越来越大,说明滑动方向是从下到上
fun detectedScrollDirection(recyclerView: RecyclerView, firstVisibleItem: Int) {
val firstVisibleView = recyclerView.layoutManager.getChildAt(0)
val top = firstVisibleView.top
if (firstVisibleItem == tempFirstVisibleItem) {
// 从上向下拉,RecyclerView渲染会调用onScrolled方法,因此这里可能存在不滑动的情况
if (top>tempFirstVisibleItemTop) {
direction = ScrollDirection.Down
}
else if (top<tempFirstVisibleItemTop) {
direction = ScrollDirection.Up
}
}
else {
// 从上向下拉
direction = if (firstVisibleItem < tempFirstVisibleItem) {
ScrollDirection.Down
} else {
ScrollDirection.Up
}
}
tempFirstVisibleItem = firstVisibleItem
tempFirstVisibleItemTop = top
}
我们将该判断放到onScrolled回调方法中
if (recyclerView != null) {
val firstVisibleItem = (recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
detectedScrollDirection(recyclerView, firstVisibleItem)
}
最后通过rv_main.addOnChildAttachStateChangeListener
添加之前的监听事件即可
来看看效果
![](http://upload-images.jianshu.io/upload_images/1018039-a74d1c959bd2d13a.gif)