总体介绍
一、弹幕实现理论分析
1、弹幕View应该如何设计
我们的设想,是在任意界面,通知消息来了,我们弹幕就可以出现。
所以承载弹幕的View是不可能放在Activity里面的,不然退出APP就没有弹幕了。
需要再手机上全场景覆盖,那符合我们要求的就是悬浮窗。悬浮窗可以脱离app,在手机桌面和其他应用的最上层显示,这更加符合我们的要求。所以我们就选定,悬浮窗作为弹幕的载体。
View的位置想好后,接下来就是弹幕应该如何设计布局。
首先我们可以将悬浮窗View宽度设置为match_parent,也就是与屏幕等宽。高度部分,为了避免通知多的时候,把整个手机屏幕铺满了的弹幕。我就把高度控制屏幕上方部分吧。
这样就可以将弹幕的范围,控制在手机屏幕上半区。弹幕多的时候,也不会因满屏的弹幕,从而影响手机正常使用。
2、弹幕效果如何设计
我计划将悬浮窗View的布局大概规划成7行。类似于写信的横格纸,我只预留出7行位置供TextView从右往左滑。
弹幕本身就是一个TextView文字,我们需要做的就是,有新系统通知时,将通知正文,创建成一个TextView对象,然后通过addView,将其作为在悬浮窗View的子控件。
TextView创建好后,给它从0 - 6里分配一个随机数,然后把TextView放在随机数对应的行上。这样就能得到弹幕随机高度的效果。
最后将TextView放在悬浮窗View对应行的最右侧,再给TextView一个动画,从右往左滑动就行了。
大体思路就是这样。是不是看着比较麻烦?跟着一步一步走,不会太麻烦的。
开干吧!
二、 创建悬浮窗View
Android的悬浮窗,就还是使用WindowManager。通过WindowManager去绑定一个View。
所以我们需要自定义一个View。
我就还是先贴代码,代码具体逻辑解释,我就在注释里面体现了。
1、自定义悬浮窗窗口
class FloatWindows(context: Context): View(context) {
//View对象创建所需的上下文
private lateinit var mContext: Context
private lateinit var wm: WindowManager
//属性对象
private val wmParams = WindowManager.LayoutParams()
public lateinit var mContentView: View
public var isShowing = false //是否正在显示
init {
wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager //获取窗口管理器
mContext = context
}
//这里是将悬浮窗内部的布局,采用传入layout文件的形式。
public fun setLayout(layoutId: Int){
mContentView = LayoutInflater.from(mContext).inflate(layoutId, null) //获取自定义视图
}
//展示悬浮窗
@SuppressLint("WrongConstant")
public fun show(gravity: Int){
if (mContentView != null){
//设置悬浮窗类型,安卓8以下用TYPE_SYSTEM_ALERT,8及8以上用TYPE_APPLICATION_OVERLAY
wmParams.type = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_SYSTEM_ALERT else WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
wmParams.format = PixelFormat.RGBA_8888
//设置悬浮窗属性,这里大概意思是,不拦截点击事件。
//比如手机处于桌面的时候,我们悬浮窗布局是覆盖手机上半部分的
//这代表,手机由一部分APP图标是被悬浮窗覆盖住的
//如果不给悬浮窗添加这个属性,则用户点击APP图标的时候,实际上都是点在了悬浮窗上。
//点击事件被悬浮窗响应了,则桌面并没有收到点击APP的事件,那就出现用户点不了图标的情况
//所以我们要声明悬浮窗不对点击事件做响应,让事件直接透传到桌面图标上。
wmParams.flags = (WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
or WindowManager.LayoutParams.FIRST_SYSTEM_WINDOW)
wmParams.alpha = 0.8f //透明度
wmParams.gravity = gravity
//布局位置
wmParams.x = 0
wmParams.y = 0
wmParams.width = WindowManager.LayoutParams.MATCH_PARENT
wmParams.height = WindowManager.LayoutParams.WRAP_CONTENT
//给WindowManager添加悬浮窗布局,显示悬浮窗
wm.addView(mContentView, wmParams)
isShowing = true
}
//关闭悬浮窗
public fun close(){
if (mContentView != null){
wm.removeView(mContentView)
isShowing = false
}
}
}
2、自定义悬浮窗布局
我暂时没摸清楚,在WindowManager里,对compose的用法,所以悬浮窗这里还是用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"
android:orientation="vertical">
<!--这里自定义绘制了弹幕View -->
<com.liuzi.mynotice.view.BarrageView
android:layout_width="match_parent"
android:layout_height="400dp"
android:id="@+id/barrage"/>
</LinearLayout>
3、自定义弹幕View布局
class BarrageView(context: Context?, attrs: AttributeSet?) : LinearLayout(context, attrs) {
private lateinit var mContext: Context
//弹幕布局分为七行
private val mRowCount = 7
//TextView的字体大小
private val mTextSize = 15
//每一条弹幕的字数上限
private val mMaxLength = 22
//存储7行TextView的layout属性的list
private val mLayoutList = mutableListOf<RelativeLayout>()
//BarrageView的宽度
private var mWidth = 0
//记录上一条弹幕所处的行数
private var mLastPos1 = -1
//记录上上一条弹幕所处的行数
private var mLastPos2 = -1
init {
initView(context!!)
}
//初始化弹幕布局
private fun initView(context: Context) {
mContext = context
//整个弹幕View为竖向线性布局
orientation = LinearLayout.VERTICAL
//弹幕View内部,布局为竖向的7行RelativeLayout,也就是弹幕的7条“跑道”
//弹幕每条“跑道”,宽度都是MATCH_PARENT,高度写死为40dp
for (i in (0 until mRowCount)) {
val layout = RelativeLayout(mContext)
//layout.setPadding(0, 100, 0, 0)
val param =
RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp2Px(context, 40.toFloat()))
layout.layoutParams = param
mLayoutList.add(layout)
//将所有“跑道”添加入布局
addView(layout)
}
}
//给弹幕随机分配“跑道”
private fun getPos(): Int {
var pos = 0
do {
Random.nextInt(mRowCount).also { pos = it }
} while (pos == mLastPos1 || pos == mLastPos2) //避开上一条和上上一条的“跑道”,让弹幕分布均匀一些
mLastPos2 = mLastPos1 //更新记录的历史跑道位置
mLastPos1 = pos
return pos //返回分配的跑道编号
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
mWidth = measuredWidth //获取弹幕View的实际测量宽度值(MATCH_PARENT)
}
//给弹幕View添加弹幕
//入参:comment:系统通知正文,pkg:通知所属APP的包名
@RequiresApi(Build.VERSION_CODES.HONEYCOMB)
public fun addComment(comment: String, pkg: String) {
//获取新分配的跑道编号,通过编号,获取对应跑道的layout
val layout = mLayoutList.get(getPos())
//创建弹幕对象
val tv_comment = getCommentView(comment, pkg)
//弹幕未开始滑动前,先设置为不可见
tv_comment.visibility = INVISIBLE
//将弹幕对象塞入跑道
layout.addView(tv_comment)
tv_comment.post(Runnable {
// 获得弹幕的宽度,单位px
val textWidth: Int = tv_comment.width
tv_comment.visibility = VISIBLE
//设置估值器,通过估值器动态计算弹幕的属性,并实时调节,让弹幕跑起来
//入参为需要计算的启动数值和终点数值。
//此时mWidth代表手机屏幕宽度值,-textWidth代表负的弹幕宽度
val anim = ValueAnimator.ofInt(mWidth, -textWidth)
//估值器动画的刷新监听
anim.addUpdateListener { animation: ValueAnimator ->
//获取当前弹幕对象属性
val tv_params = tv_comment.layoutParams as RelativeLayout.LayoutParams
//根据计算值,修改弹幕的leftMargin
tv_params.leftMargin = animation.getAnimatedValue() as Int
//由于弹幕的leftMargin会被估值器实时修改
//假设屏幕宽度100,弹幕宽度5,则弹幕的左侧Margin,会从100变成负5
//这代表,弹幕会从屏幕最右侧出现,然后逐渐滑动到左边,直至划出屏幕外
tv_comment.setLayoutParams(tv_params)
}
//设置动画的播放目标
anim.setTarget(tv_comment)
//setDuration设置弹幕的播放时长,时间越长,弹幕跑的越慢
//这里的判断是对设备是否横屏做了判断
//横屏的设备,屏幕太宽,弹幕在相同的播放时长下,会跑得很快
//所以针对横屏,播放时长要长一点
if (DeviceStateUtils.isScreenOrienatation(mContext)) {
anim.setDuration(40000)
}else{
anim.setDuration(20000)
}
//设置估值器的动画属性为线性
anim.interpolator = LinearInterpolator()
//启动动画
anim.start()
//这里对估值器又做了一个监听,当动画结束的时候,就结束动作,清除弹幕对象
anim.addListener { animator ->
tv_comment.clearAnimation()
removeView(tv_comment) //移除弹幕
animation.cancel()
}
})
}
//我设计的弹幕布局,是一个app图标加一个通知正文
//此方法为创建弹幕对象
private fun getCommentView(content: String, pkg: String): LinearLayout {
//ImageView,APP图标
val icon = ImageView(mContext)
//TextView,通知正文
val tv = TextView(mContext)
//线性横向布局,图标在前,文字在后
val linearLayout = LinearLayout(mContext)
linearLayout.orientation = HORIZONTAL
linearLayout.gravity = Gravity.CENTER
//通过包名获取图片对象,设置进ImageView
icon.setImageDrawable(mContext.getPackageManager().getApplicationIcon(pkg))
icon.scaleType = ImageView.ScaleType.FIT_CENTER
icon.adjustViewBounds = true
icon.maxHeight = 100
icon.maxWidth = 100
tv.setPadding(30, 0, 0, 0)
//如果通知文字过长,则进行截取,以省略号拼接结尾
tv.text = if (content.length > mMaxLength) content.substring(0, mMaxLength)
.plus("...") else content
tv.setTextSize(mTextSize.toFloat())
tv.setTextColor(Color.WHITE)
//设置一点阴影,让弹幕在各种背景下都容易能看到
tv.setShadowLayer(0.3f, 2f, 2f, Color.BLACK)
tv.setSingleLine(true)
val params = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
//将设置好的ImageView和TextView,放入线性布局,返回线性布局View。高度和宽度都是WRAP_CONTENT
linearLayout.addView(icon)
linearLayout.addView(tv)
linearLayout.layoutParams = params
return linearLayout
}
companion object {
fun px2dp(context: Context, pxValue: Float): Int {
val scale = context.resources.displayMetrics.density
return (pxValue / scale + 0.5f).toInt()
}
fun dp2Px(context: Context, dp: Float): Int {
val scale = context.resources.displayMetrics.density
return Math.round(dp * scale);
}
}
}
4、判断设备横屏
class DeviceStateUtils {
// companion object内的声明方法,即代表静态方法
companion object{
fun isScreenOrienatation(mContext: Context): Boolean {
val mConfiguration = mContext.resources.configuration
return mConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE
}
}
}
5、弹幕启用
弹幕开启,咱们可以直接放在activity启动的时候。
步骤很简单,创建FloatWindow对象初始化,然后调用show方法就可以了。代码如下:
private var mFloatWindows: FloatWindows? = null
if (mFloatWindows == null) {
mFloatWindows = FloatWindows(mContext)
mFloatWindows?.setLayout(R.layout.float_window)
mBarrageView = mFloatWindows?.mContentView?.findViewById(R.id.barrage)
}
mFloatWindows?.let {
if (!it.isShowing) {
it.show(Gravity.LEFT or Gravity.TOP)
mFloatShow = true
mBarrageView?.addComment(
"通知弹幕功能开启成功,这是一条测试弹幕",
"com.liuzi.mynotice"
)
}
}
初始化完成好了,那后面怎么把通知消息和弹幕串起来呢?
那还不简单吗,我们回到MyNotificationService,把获取系统通知的时候,直接调用一下弹幕mBarrageView?.addComment就可以了!
//重写消息监听的方法
override fun onNotificationPosted(sbn: StatusBarNotification?) {
//super.onNotificationPosted(sbn)
try {
if (sbn!!.notification.tickerText != null) {
//本次新增逻辑
if (mFloatShow) {
mBarrageView?.addComment(
sbn.notification.extras.getString(
Notification.EXTRA_TEXT
).toString(),
sbn.packageName
)
}
val notice = toNotice(sbn)
GlobalScope.launch(Dispatchers.IO) {
saveNotice(notice)
}
}
} catch (e: Exception) {
Log.e(TAG, "保存失败:$e")
}
}
三、 别忘了悬浮窗所需要的权限
1、AndroidMenifest声明权限
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />
2、动态权限
悬浮窗需要系统授权,允许应用“显示在其他应用上层”。
这个权限无法直接弹窗让用户授权,只能让用户去系统设置里面给app打开。
这部分代码我就没写了,可以自行去系统设置里面为app授权。
四、总结
本次我们算是完成最核心的弹幕内容,相关逻辑说明,在注释里面都解释的差不多了。看看大家还有没有疑问。如果大家有什么疑问或建议的话,欢迎大家在评论区交流。
需要注意的一点是,无论是悬浮窗,还是自定义弹幕View,这部分我们都在和View打交道。所以Context对象十分重要,大家在调用弹幕的时候,一定要注意Context的传递和使用。既不要出现context空指针导致应用崩溃,也不要因为context引用问题造成内存泄漏。
对了,我们还遗漏了一个开启关闭弹幕的button没有实现,如果后面大家评论区疑问比较多的话,那我再续一篇,把大家疑问做一个探讨,顺便把button的对应功能的代码实现补上。