AndroidAndroid-ui效果Android UI系列

视频播放列表初步 —— 简单实现今日头条与微淘视频列表效果

2018-03-02  本文已影响565人  皮球二二

最近开始流行列表播放视频效果,比如今日头条、FaceBook、淘宝等,那我们就来看看怎样在列表中播放视频吧
本文涉及到的代码已经上传到github

来看看今日头条与微淘的效果


今日头条 微淘界面

为什么要把这两个都贴出来?因为它们的体验效果并不同。今日头条是点击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)
        }
    }
}

这时候logcat显示的rect的left top right bottom

Rect(0, 0 - 263, 263)

这时候我们给view_visibility向下移动100dp,但是控件仍然完全显示

android:layout_marginTop="100dip"
android:layout_marginTop="100dip"

来看看这会会打印什么结果出来

Rect(0, 0 - 263, 263)

没有发生变化
好,我们将其向上移动-50dp,使其垂直方向视图的一半不在屏幕中

android:layout_marginTop="-50dip"
android:layout_marginTop="-50dip"

来看看这会会打印什么结果出来

Rect(0, 131 - 263, 263)

top的值发生了变化。
我们记住这一变化,继续看下面一个情况。我们将其向下移动400dp,使其垂直方向视图只留在屏幕一小部分

android:layout_marginTop="550dip"
android:layout_marginTop="550dip"

来看看这会会打印什么结果出来

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)
    }
}

来看看效果


进度更新效果

微淘效果实现

微淘里有一个投机取巧的地方不知道你有没有注意到,它里面100%完全可见的Item始终只有一个,这样就将开发的难度大大降低了。我们只需要理清楚如下问题即可:

  1. 找到100%可见的视图集合。
  2. 找到之后,循环执行激活动作,同时取消之前的激活动作

理清楚这两个问题之后我们开始代码的编写

首先要将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()方法之后即可

来看看效果

微淘效果

今日头条效果实现

今日头条效果做起来也很简单,我们把之前的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添加之前的监听事件即可

来看看效果


今日头条效果
上一篇 下一篇

猜你喜欢

热点阅读