Android技术分享| 自定义ViewGroup实现直播间大小屏无缝切换

Posted anyRTC

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android技术分享| 自定义ViewGroup实现直播间大小屏无缝切换相关的知识,希望对你有一定的参考价值。

源代码地址:请点击这里

需求

两种显示方式:

  1. 主播全屏,其他游客悬浮在右侧。下面简称大小屏模式。

  1. 所有人等分屏幕。下面简称等分模式。

分析

  • 最多4人连麦,明确这点方便定制坐标算法。
  • 自定义的 ViewGroup 最好分别提供等分模式和大小屏模式的边距设置接口,便于修改。
  • SDK 自己管理了 TextureView 的绘制和测量,所以 ViewGroup 需要复写 onMeasure 方法以通知 TextureView 测量和绘制。
  • 一个计算 0.0f ~ 1.0f 逐渐减速的函数,给动画过程做支撑。
  • 一个记录坐标的数据模型。和一个根据现有 Child View 的数量计算两种布局模式下,每个 View 摆放位置的函数。

实现

1.定义坐标数据模型

private data class ViewLayoutInfo(
    var originalLeft: Int = 0,// original开头的为动画开始前的起始值
    var originalTop: Int = 0,
    var originalRight: Int = 0,
    var originalBottom: Int = 0,
    var left: Float = 0.0f,// 无前缀的为动画过程中的临时值
    var top: Float = 0.0f,
    var right: Float = 0.0f,
    var bottom: Float = 0.0f,
    var toLeft: Int = 0,// to开头的为动画目标值
    var toTop: Int = 0,
    var toRight: Int = 0,
    var toBottom: Int = 0,
    var progress: Float = 0.0f,// 进度 0.0f ~ 1.0f,用于控制 Alpha 动画
    var isAlpha: Boolean = false,// 透明动画,新添加的执行此动画
    var isConverted: Boolean = false,// 控制 progress 反转的标记
    var waitingDestroy: Boolean = false,// 结束后销毁 View 的标记
    var pos: Int = 0// 记录自己索引,以便销毁
) {
    init {
        left = originalLeft.toFloat()
        top = originalTop.toFloat()
        right = originalRight.toFloat()
        bottom = originalBottom.toFloat()
    }
}

以上,记录了执行动画和销毁View所需的数据。(于源码中第352行)

2.计算不同展示模式下View坐标的函数

if (layoutTopicMode) {
    var index = 0
    for (i in 1 until childCount) if (i != position) (getChildAt(i).tag as ViewLayoutInfo).run {
        toLeft = measuredWidth - maxWidgetPadding - smallViewWidth
        toTop = defMultipleVideosTopPadding + index * smallViewHeight + index * maxWidgetPadding
        toRight = measuredWidth - maxWidgetPadding
        toBottom = toTop + smallViewHeight
        index++
    }
} else {
    var posOffset = 0
    var pos = 0
    if (childCount == 4) {
        posOffset = 2
        pos++

        (getChildAt(0).tag as ViewLayoutInfo).run {
            toLeft = measuredWidth.shr(1) - multiViewWidth.shr(1)
            toTop = defMultipleVideosTopPadding
            toRight = measuredWidth.shr(1) + multiViewWidth.shr(1)
            toBottom = defMultipleVideosTopPadding + multiViewHeight
        }
    }

    for (i in pos until childCount) if (i != position) {
        val topFloor = posOffset / 2
        val leftFloor = posOffset % 2
        (getChildAt(i).tag as ViewLayoutInfo).run {
            toLeft = leftFloor * measuredWidth.shr(1) + leftFloor * multipleWidgetPadding
            toTop = topFloor * multiViewHeight + topFloor * multipleWidgetPadding + defMultipleVideosTopPadding
            toRight = toLeft + multiViewWidth
            toBottom = toTop + multiViewHeight
        }
        posOffset++
    }
}

post(AnimThread(
    (0 until childCount).map { getChildAt(it).tag as ViewLayoutInfo }.toTypedArray()
))

Demo源码中的add、remove、toggle方法重复代码过多,未来得及优化。这里只附上 addVideoView 中的计算部分(于源代码中第141行),只需稍微修改即可适用add、remove和toggle。(也可参考 CDNLiveVM 中的 calcPosition 方法,为经过优化的版本)layoutTopicMode = true 时,为大小屏模式。

由于是定制算法,只能适用这一种布局,故不写注释。只需明确一点,此方法最终目的是为了计算出每个View当前应该出现的位置,保存到上面定义的数据模型中并开启动画(最后一行 post AnimThread 为开启动画的代码,我这里是通过 post 一个线程来更新每一帧)。

可根据不同的需求写不同的实现,最终符合定义的数据模型即可。

3.逐渐减速的算法,使动画效果看起来更自然。

private inner class AnimThread(
    private val viewInfoList: Array<ViewLayoutInfo>,
    private var duration: Float = 180.0f,
    private var processing: Float = 0.0f
) : Runnable {
    private val waitingTime = 9L

    override fun run() {
        var progress = processing / duration
        if (progress > 1.0f) {
            progress = 1.0f
        }

        for (viewInfo in viewInfoList) {
            if (viewInfo.isAlpha) {
                viewInfo.progress = progress
            } else viewInfo.run {
                val diffLeft = (toLeft - originalLeft) * progress
                val diffTop = (toTop - originalTop) * progress
                val diffRight = (toRight - originalRight) * progress
                val diffBottom = (toBottom - originalBottom) * progress

                left = originalLeft + diffLeft
                top = originalTop + diffTop
                right = originalRight + diffRight
                bottom = originalBottom + diffBottom
            }
        }
        requestLayout()

        if (progress < 1.0f) {
            if (progress > 0.8f) {
                var offset = ((progress - 0.7f) / 0.25f)
                if (offset > 1.0f)
                    offset = 1.0f
                processing += waitingTime - waitingTime * progress * 0.95f * offset
            } else {
                processing += waitingTime
            }
            postDelayed(this@AnimThread, waitingTime)
        } else {
            for (viewInfo in viewInfoList) {
                if (viewInfo.waitingDestroy) {
                    removeViewAt(viewInfo.pos)
                } else viewInfo.run {
                    processing = 0.0f
                    duration = 0.0f
                    originalLeft = left.toInt()
                    originalTop = top.toInt()
                    originalRight = right.toInt()
                    originalBottom = bottom.toInt()
                    isAlpha = false
                    isConverted = false
                }
            }
            animRunning = false
            processing = duration
            if (!taskLink.isEmpty()) {
                invokeLinkedTask()// 此方法执行正在等待中的任务,从源码中能看到,remove、add等函数需要依次执行,前一个动画未执行完毕就进行下一个动画可能会导致不可预知的错误。
            }
        }
    }
}

上述代码除了提供减速算法,还一并更新了对应View数据模型的中间值,也就是模型定义种的 left, top, right, bottom 。

通过减速算法提供的进度值,乘以目标坐标与起始坐标的间距,得出中间值。

逐渐减速的算法关键代码为:

if (progress > 0.8f) {
    var offset = ((progress - 0.7f) / 0.25f)
    if (offset > 1.0f)
        offset = 1.0f
    processing += waitingTime - waitingTime * progress * 0.95f * offset
} else {
    processing += waitingTime
}

这个算法实现的有缺陷,因为它直接修改了进度时间,大概率会导致执行完毕的时间与设置的预期时间(如设置200ms执行完毕,实际可能超过200ms)不符。文末我会提供一个优化的减速算法。

变量 waitingTime 表示等待多久执行下一帧动画。用每秒1000ms计算即可,如果目标为60刷新率的动画,设置为1000 / 60 = 16.66667即可(近似值)。

计算并存储每个 View 的中间值后,调用 requestLayout() 通知系统的 onMeasure 和 onLayout 方法,重新摆放 View 。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    if (childCount == 0)
        return

    for (i in 0 until childCount) {
        val child = getChildAt(i)
        val layoutInfo = child.tag as ViewLayoutInfo
        child.layout(
            layoutInfo.left.toInt(),
            layoutInfo.top.toInt(),
            layoutInfo.right.toInt(),
            layoutInfo.bottom.toInt()
        )
        if (layoutInfo.isAlpha) {
            val progress = if (layoutInfo.isConverted)
                1.0f - layoutInfo.progress
            else
                layoutInfo.progress

            child.alpha = progress
        }
    }
}

4.定义边距相关的变量,供简单的定制修改

/**
 * @param multipleWidgetPadding : 等分模式读取
 * @param maxWidgetPadding : 大小屏布局读取
 * @param defMultipleVideosTopPadding : 距离顶部变距
 */
private var multipleWidgetPadding = 0
private var maxWidgetPadding = 0
private var defMultipleVideosTopPadding = 0

init {
    viewTreeObserver.addOnGlobalLayoutListener(this)
    attrs?.let {
        val typedArray = resources.obtainAttributes(it, R.styleable.AnyVideoGroup)
        multipleWidgetPadding = typedArray.getDimensionPixelOffset(
            R.styleable.AnyVideoGroup_between23viewsPadding, 0
        )
        maxWidgetPadding = typedArray.getDimensionPixelOffset(
            R.styleable.AnyVideoGroup_at4smallViewsPadding, 0
        )
        defMultipleVideosTopPadding = typedArray.getDimensionPixelOffset(
            R.styleable.AnyVideoGroup_defMultipleVideosTopPadding, 0
        )
        layoutTopicMode = typedArray.getBoolean(
            R.styleable.AnyVideoGroup_initTopicMode, layoutTopicMode
        )
        typedArray.recycle()
    }
}

取名时对这三个变量的职责定义,与编写逻辑时的定义有出入,所以有点词不达意,需参考注释。

由于这只是定制化的变量,并不重要,可根据业务逻辑自行随意修改。

5.复写 onMeasure 方法,这里主要是通知 TextureView 更新大小。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)

    multiViewWidth = widthSize.shr(1)
    multiViewHeight = (multiViewWidth.toFloat() * 1.33334f).toInt()
    smallViewWidth = (widthSize * 0.3125f).toInt()
    smallViewHeight = (smallViewWidth.toFloat() * 1.33334f).toInt()

    for (i in 0 until childCount) {
        val child = getChildAt(i)
        val info = child.tag as ViewLayoutInfo
        child.measure(
            MeasureSpec.makeMeasureSpec((info.right - info.left).toInt(), MeasureSpec.EXACTLY),
            MeasureSpec.makeMeasureSpec((info.bottom - info.top).toInt(), MeasureSpec.EXACTLY)
        )
    }

    setMeasuredDimension(
        MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY),
        MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)
    )
}

总结

  1. 明确数据模型,一般情况下记录起始上下左右坐标、目标上下左右坐标、和进度百分比就足够了。
  2. 根据需求明确动画算法,这里补充一下优化的减速算法:
factor = 1.0
if (factor == 1.0)
    (1.0 - (1.0 - x) * (1.0 - x))
else
    (1.0 - pow((1.0 - x), 2 * factor))
// x = time.
  1. 根据算法计算出来的值更新 layout 布局即可。

此类 ViewGroup 实现简单方便,只涉及到几个基本系统API。如不想写 onMeasure 方法可继承 FrameLayout 等已写好 onMeasure 实现的 ViewGroup 。

以上是关于Android技术分享| 自定义ViewGroup实现直播间大小屏无缝切换的主要内容,如果未能解决你的问题,请参考以下文章

Android技术分享| 自定义ViewGroup实现直播间大小屏无缝切换

Android技术分享| 自定义LayoutManager

Android自定义ViewGroup——带悬停标题的ExpandableListView

Kotlin 写自定义 ViewGroup

android--------自定义控件 之 ViewGroup

Android自定义ViewGroup及自定义属性