使用compose 实现一个仿抖音直播间 效果,飘心动画,点赞,聊天,观众,还有退出后 悬浮窗直播
demo地址:github.com/PangHaHa121…
demo截图
下面是完整的compose代码
package com.example.test001
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.lerp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlin.math.pow
import kotlin.math.roundToInt
class TestLiveActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LiveRoomScreen()
}
}
override fun onBackPressed() {
super.onBackPressed()
startService(Intent(this, FloatingService::class.java))
}
}
// ================= 主体入口 =================
@Composable
fun LiveRoomScreen() {
val avatar =
"https://c-ssl.duitang.com/uploads/item/201703/09/20170309211351_3eKNs.jpeg"
val avatar2 =
"https://tse3-mm.cn.bing.net/th/id/OIP-C.eQPvuWmUtxZRLNrUGJK6wQHaHa?rs=1&pid=ImgDetMain"
val context = LocalContext.current
val rainbowColors = listOf(
Color(0xFFFF0000), // 红
Color(0xFFFF7F00), // 橙
Color(0xFFFFFF00), // 黄
Color(0xFF00FF00), // 绿
Color(0xFF00BFFF), // 青
Color(0xFF0000FF), // 蓝
Color(0xFF8B00FF) // 紫
)
val videoUrl0 = "http://220.161.87.62:8800/hls/0/index.m3u8"
val videoUrl1 = "http://devimages.apple.com/iphone/samples/bipbop/gear1/prog_index.m3u8"
val videoUrl2 = "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
val videoUrl3 = "http://39.164.160.249:9901/tsfile/live/0125_1.m3u8"
val videoUrl4 = "http://39.164.160.249:9901/tsfile/live/0122_1.m3u8"
val videoUrl5 = "http://fn.tmde.top:35455/nptv/dongnan.m3u8"
val videoUrl6 = "http://39.164.160.249:9901/tsfile/live/0128_1.m3u8"
val videoUrl7 = "http://185.189.225.150:85/8madrid/index.m3u8"
val anchorInfo = AnchorInfo(
avatar = avatar2,
nickname = "软糯小香批",
level = "Lv.12"
)
val topRank = listOf(
GiftUser(avatar),
GiftUser(avatar),
GiftUser(avatar)
)
val viewModel: LiveRoomViewModel = viewModel()
val demoLists =
listOf("牛逼666", "点赞!主播真棒", "厉害了,我的哥", "来啦,老弟", "送你一朵小红花")
var overlayVisible by remember { mutableStateOf(true) }
// 为浮层定义动画偏移(可选)
val offsetX by animateDpAsState(if (overlayVisible) 0.dp else 300.dp)
LaunchedEffect(Unit) {
viewModel.startTicker(avatar, rainbowColors, demoLists)
}
HeartLikeBox(
modifier = Modifier.fillMaxSize(),
imageRes = R.drawable.ic_heart
) {
Box(
modifier = Modifier
.fillMaxSize()
) {
AsyncImage(
model = avatar2,
contentDescription = "背景",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.blur(radius = 18.dp) // 高斯模糊
)
// 视频全屏
ExoPlayerVideo(videoUrl3)
// ---------- 包裹所有浮层的"叠加容器"及左右滑动 ----------
OverlayWithSlide {
// top 区域: 主播信息、排行榜
TopArea(
anchorInfo = anchorInfo,
rankList = topRank,
modifier = Modifier
.fillMaxWidth()
.padding(top = 40.dp, start = 12.dp, end = 30.dp)
)
// 聊天内容
Box(
Modifier
.fillMaxWidth()
.align(Alignment.BottomStart)
.padding(bottom = 50.dp, start = 10.dp) // 预留底部给输入区
.heightIn(max = 240.dp)
) {
ChatMessagesList(viewModel.chatMessages)
}
// 底部输入框和功能按钮
BottomArea(
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 25.dp)
.align(Alignment.BottomStart),
onSend = { text ->
viewModel.chatMessages.add(
ChatMessage(
id = viewModel.chatMessages.size + 1000,
avatar = avatar,
nickname = "我",
content = text
)
)
},
onLike = {
viewModel.addHeart(rainbowColors)
},
)
// 右下飘心动画
Box(
modifier = Modifier
.fillMaxSize()
) {
viewModel.hearts.forEach { heart ->
FloatingHeart(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 56.dp, bottom = 80.dp),
animationKey = heart.key, // 永远唯一且不复用
color = heart.color,
onFinished = { viewModel.removeHeart(heart.key) }
)
}
}
}
IconButton(
onClick = {
context.startService(Intent(context, FloatingService::class.java))
(context as? Activity)?.finish()
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 40.dp, end = 5.dp)
.size(30.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "关闭",
tint = Color.White,
)
}
}
}
}
// ========== 视频播放 ==========
@OptIn(UnstableApi::class)
@Composable
fun ExoPlayerVideo(url: String) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(Uri.parse(url))
setMediaItem(mediaItem)
prepare()
playWhenReady = true
}
}
// 监听生命周期,暂停/恢复播放
DisposableEffect(lifecycleOwner, exoPlayer) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
exoPlayer.playWhenReady = true
exoPlayer.play()
}
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.playWhenReady = false
exoPlayer.pause()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// 页面销毁时释放资源
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
AndroidView(
factory = {
PlayerView(context).apply {
useController = false
player = exoPlayer
}
},
modifier = Modifier.fillMaxSize(),
)
// Box(
// modifier = Modifier
// .fillMaxSize(),
// contentAlignment = Alignment.Center
// ) {
// PlayerSurface(
// exoPlayer,
// surfaceType = SURFACE_TYPE_SURFACE_VIEW,
// modifier = Modifier
// .fillMaxWidth()
// // 画面保持16:9
// .aspectRatio(16 / 9f),
// )
// }
}
// ================== 顶部主播&榜单 =================
@Composable
fun TopArea(
anchorInfo: AnchorInfo,
rankList: List<GiftUser>,
modifier: Modifier = Modifier
) {
val follow = remember { mutableStateOf(false) }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
// 主播
Row(
Modifier
.background(Color.White.copy(alpha = 0.22f), shape = RoundedCornerShape(25.dp))
.padding(vertical = 2.dp, horizontal = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserImage(
url = anchorInfo.avatar,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(2.dp, Color.White, CircleShape)
)
Spacer(modifier = Modifier.width(6.dp))
Column {
Text(
anchorInfo.nickname,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(5.dp))
Text(
anchorInfo.level,
color = Color.White,
fontSize = 12.sp,
modifier = Modifier
.background(
Color.Black.copy(alpha = 0.32f),
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
Spacer(modifier = Modifier.width(5.dp))
Button(
onClick = {
follow.value = !follow.value
},
colors = ButtonDefaults.buttonColors(
backgroundColor = Color(0xFFF8577C),
contentColor = Color.White
),
shape = RoundedCornerShape(20.dp),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp),
) {
val text = if (follow.value) "已关注" else "关注"
Text(text, fontSize = 13.sp)
}
}
// 榜单
Box(
modifier = Modifier
.background(Color.White.copy(alpha = 0.22f), shape = RoundedCornerShape(25.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row {
rankList.forEach { user ->
UserImage(
url = user.avatar,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.border(2.dp, Color.White, CircleShape)
.padding(horizontal = 2.dp)
)
}
}
}
}
}
// =============== 聊天列表 ===============
@Composable
fun ChatMessagesList(messages: List<ChatMessage>) {
val listState = rememberLazyListState()
// 每当有新消息时自动滑到顶部(reverseLayout下视觉“底部”)
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.padding(bottom = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
itemsIndexed(messages) { index, msg ->
Row(
modifier = Modifier
.padding(vertical = 2.dp)
.background(
color = Color.White.copy(alpha = 0.85f),
shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserImage(
url = msg.avatar,
modifier = Modifier
.size(22.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(5.dp))
Text(
msg.nickname,
fontSize = 13.sp,
color = Color(0xFFD82750),
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(5.dp))
Text(
msg.content,
fontSize = 14.sp,
color = Color.Black
)
}
}
}
}
// ============= 底部输入区与功能按钮 =============
@Composable
fun BottomArea(
onSend: (String) -> Unit,
onLike: () -> Unit,
modifier: Modifier = Modifier
) {
var msgInput by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
Box(
modifier = modifier
) {
Row(
modifier = Modifier.align(Alignment.BottomStart),
verticalAlignment = Alignment.CenterVertically
) {
// 输入框
Box(
Modifier
.weight(1f)
.background(Color.White.copy(alpha = 0.90f), RoundedCornerShape(24.dp))
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
BasicTextField(
value = msgInput,
onValueChange = { msgInput = it },
singleLine = true,
textStyle = TextStyle(fontSize = 15.sp, color = Color.Black),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
decorationBox = { innerTextField ->
if (msgInput.isEmpty()) {
Text(
text = "说点什么…",
color = Color.Gray,
fontSize = 15.sp
)
}
innerTextField()
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send
),
keyboardActions = KeyboardActions(
onSend = {
if (msgInput.isNotBlank()) {
onSend(msgInput)
msgInput = ""
keyboardController?.hide()
}
}
),
)
}
Spacer(modifier = Modifier.width(8.dp))
// 发送
CircleIconButton(
icon = Icons.AutoMirrored.Default.Send,
onClick = {
if (msgInput.isNotBlank()) {
onSend(msgInput)
msgInput = ""
keyboardController?.hide()
}
}
)
Spacer(modifier = Modifier.width(12.dp))
// 送礼
CircleIconButton(
icon = Icons.Default.CardGiftcard,
onClick = {
onLike()
}
)
Spacer(modifier = Modifier.width(8.dp))
// 菜单
Box(
modifier = Modifier
.size(38.dp)
.background(Color.White.copy(alpha = 0.90f), CircleShape)
.clickable { },
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.MoreHoriz, "", tint = Color.Black)
}
}
// 最右有飘心按钮区域(与其他按钮分开以免被遮)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = (-20).dp),
contentAlignment = Alignment.Center
) {
// 飘心可附加在此
}
}
}
// 圆形ICON按钮
@Composable
fun CircleIconButton(
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(38.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.92f), CircleShape)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(icon, "", tint = Color(0xFFDA3261))
}
}
// ============== 飘心动画 ==============
@Composable
fun FloatingHeart(
modifier: Modifier = Modifier,
animationKey: Int,
color: Color,
onFinished: (() -> Unit)? = null
) {
// 最大上升距离,Y 越大飞得越高
val travelY = 500f
// X 方向最大偏移,可以让曲线更宽弯
val travelX = 140f
//曲线的高度控制
val curveHeight = 220f
// 用于让左右曲线交错,偶数右曲线,奇数左曲线
val curveDir = if (animationKey % 2 == 0) 1 else -1
// 动画总时长(毫秒)
val animDuration = 3200
// 进度动画,范围 0~1
val progress = remember { Animatable(0f) }
LaunchedEffect(animationKey) {
// 启动动画
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = animDuration, easing = LinearEasing)
)
// 动画结束后回调移除
onFinished?.invoke()
}
val t = progress.value
// 大小渐变,由0.6x到1.2x,lerp 是线性插值
val scale = lerp(0.6f, 1.2f, t)
// 透明渐变,到最后一点点时淡出
val alpha = if (t < 0.8f) 1f else 1f - (t - 0.8f) * 20
val offset = calculateBezier(
t,
// P0:起点,原点 (x=0, y=0),即心形动画的起始位置,通常在底部中央
Offset(0f, 0f),
// P1:第一个控制点,决定心形初始向左或向右偏移(取决于curveDir),高度为-curveHeight,控制曲线的第一段拐弯
Offset(curveDir * travelX / 2, -curveHeight),
// P2:第二个控制点,方向与第一个相反,同样生成弯曲,Y更接近终点,制造反向回拉的弯
Offset(-curveDir * travelX / 2, -travelY + curveHeight),
// P3:曲线终点,决定最后停在哪里(左右、高度)
Offset(curveDir * travelX, -travelY)
)
Box(
modifier = modifier
.offset { IntOffset(offset.x.dp.roundToPx(), offset.y.dp.roundToPx()) }
.size((28 * scale).dp)
.alpha(alpha)
) {
Icon(
Icons.Default.Favorite,
contentDescription = "",
tint = color.copy(alpha = alpha),
modifier = Modifier.fillMaxSize()
)
}
}
fun calculateBezier(
t: Float,
start: Offset, control1: Offset, control2: Offset, end: Offset
): Offset {
val u = 1 - t
val x = u.pow(3) * start.x +
3 * u.pow(2) * t * control1.x +
3 * u * t.pow(2) * control2.x +
t.pow(3) * end.x
val y = u.pow(3) * start.y +
3 * u.pow(2) * t * control1.y +
3 * u * t.pow(2) * control2.y +
t.pow(3) * end.y
return Offset(x, y)
}
// =========== 通用圆形网络图片 =============
@Composable
fun UserImage(
url: String,
modifier: Modifier = Modifier
) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
)
}
@Composable
fun OverlayWithSlide(
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
var parentWidth by remember { mutableFloatStateOf(0f) }
Box(
Modifier
.fillMaxSize()
.onGloballyPositioned {
parentWidth = it.size.width.toFloat()
}
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
// 吸附动画,小于一半回弹,大于一半滑出
scope.launch {
if (offsetX.value > parentWidth / 2) {
// 只要超过一半就彻底隐藏
offsetX.animateTo(parentWidth, animationSpec = tween(300))
} else {
// 其他情况完全回弹
offsetX.animateTo(0f, animationSpec = tween(300))
}
}
},
onDrag = { change, dragAmount ->
val newX = (offsetX.value + dragAmount.x).coerceIn(0f, parentWidth)
scope.launch { offsetX.snapTo(newX) }
}
)
}
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
) {
content()
}
}
@Composable
fun FadeEdge(
modifier: Modifier = Modifier,
top: Boolean = true,
height: Dp = 32.dp // 渐变高度可调
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.background(
brush = Brush.verticalGradient(
colors = if (top)
listOf(Color.White.copy(alpha = 0.9f), Color.Transparent)
else
listOf(Color.Transparent, Color.White.copy(alpha = 0.9f))
)
)
)
}
fun tickerFlow(period: Long): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(period)
}
}
下面是悬浮窗代码
package com.example.test001
import android.annotation.SuppressLint
import android.app.Service
import android.content.Intent
import android.graphics.PixelFormat
import android.os.Build
import android.os.IBinder
import android.util.TypedValue
import android.view.*
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.OptIn
import androidx.cardview.widget.CardView
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.BehindLiveWindowException
import androidx.media3.ui.PlayerView
class FloatingService : Service() {
private lateinit var windowManager: WindowManager
private var floatView: View? = null
private var exoPlayer: ExoPlayer? = null
private val windowWidthDp = 300f
private val windowHeightDp = 170f
private val initialMarginTopDp = 1f
private val statusBarHeightDp = 1f // 状态栏预估高度
private val navigationBarHeightDp = 48f // 导航栏预估高度
private val edgeMarginDp = 5f
// 转换dp为px
private fun dp2px(dp: Float): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics
).toInt()
override fun onBind(intent: Intent?): IBinder? = null
@SuppressLint("InflateParams")
override fun onCreate() {
super.onCreate()
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
// 布局
val inflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater
val view = inflater.inflate(R.layout.layout_floating_widget, null)
// 悬浮窗参数
val params = WindowManager.LayoutParams(
dp2px(windowWidthDp),
dp2px(windowHeightDp),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
PixelFormat.TRANSLUCENT
)
params.gravity = Gravity.TOP or Gravity.START
params.x = dp2px(edgeMarginDp)
params.y = dp2px(initialMarginTopDp)
// 播放器区域
val playerView = view.findViewById<PlayerView>(R.id.player_view)
playerView.useController = false
playerView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT
)
exoPlayer = ExoPlayer.Builder(this).build().apply {
addListener(object : Player.Listener {
@OptIn(UnstableApi::class)
override fun onPlayerError(error: PlaybackException) {
if (error.cause is BehindLiveWindowException) {
// 直播流已经“追不上”,seek到最新并重播
seekToDefaultPosition()
prepare()
playWhenReady = true
}
}
})
}
playerView.player = exoPlayer
val videoUrl = "http://220.161.87.62:8800/hls/0/index.m3u8"
val videoUrl3 = "http://39.164.160.249:9901/tsfile/live/0125_1.m3u8"
val mediaItem = MediaItem.fromUri(videoUrl3)
exoPlayer?.setMediaItem(mediaItem)
exoPlayer?.prepare()
exoPlayer?.playWhenReady = true
val ivPlayPause = view.findViewById<ImageView>(R.id.iv_play_pause)
val ivClose = view.findViewById<ImageView>(R.id.iv_close)
val ivBackHome = view.findViewById<ImageView>(R.id.iv_back_home)
// ---- 1. 中间按钮,关闭按钮的显隐 ----
var isButtonVisible = true
fun setButtonVisible(visible: Boolean) {
val newVis = if (visible) View.VISIBLE else View.GONE
if (ivPlayPause.visibility != newVis) ivPlayPause.visibility = newVis
if (ivClose.visibility != newVis) ivClose.visibility = newVis
if (ivBackHome.visibility != newVis) ivBackHome.visibility = newVis
isButtonVisible = visible
}
setButtonVisible(true)
// 父布局点击显示/隐藏按钮(排除按钮自身事件)
view.setOnClickListener {
setButtonVisible(!isButtonVisible)
}
ivPlayPause.setOnClickListener {
// 不传递到父布局
it.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
// 切换播放
val isPlaying = exoPlayer?.isPlaying == true
exoPlayer?.playWhenReady = !isPlaying
ivPlayPause.setImageResource(
if (isPlaying)
android.R.drawable.ic_media_play
else
android.R.drawable.ic_media_pause
)
// 顺便让按钮显示一段时间后自动隐藏(可选)
setButtonVisible(true)
}
ivClose.setOnClickListener {
it.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
stopSelf()
}
ivBackHome.setOnClickListener {
val intent = Intent(this, TestLiveActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
// 关闭悬浮窗
stopSelf()
}
// ---- 2. 悬浮窗拖动,有边界限制 ----
view.setOnTouchListener(object : View.OnTouchListener {
var startX = 0
var startY = 0
var touchX = 0
var touchY = 0
var isMoving = false
var downRawY = 0f
// 获取屏幕大小
fun getScreenArea(): Pair<Int, Int> {
val displayMetrics = resources.displayMetrics
return displayMetrics.widthPixels to displayMetrics.heightPixels
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouch(v: View?, event: MotionEvent): Boolean {
val action = event.action
when (action) {
MotionEvent.ACTION_DOWN -> {
startX = params.x
startY = params.y
touchX = event.rawX.toInt()
touchY = event.rawY.toInt()
downRawY = event.rawY // 记录按下的y
isMoving = false
return false
}
MotionEvent.ACTION_MOVE -> {
val dX = event.rawX.toInt() - touchX
val dY = event.rawY.toInt() - touchY
params.x = startX + dX
params.y = startY + dY
val (_, _) = getScreenArea()
windowManager.updateViewLayout(view, params)
isMoving = true
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (isMoving) {
val (screenW, screenH) = getScreenArea()
val minX = dp2px(edgeMarginDp)
val minY = dp2px(statusBarHeightDp + edgeMarginDp)
val maxX = screenW - dp2px(windowWidthDp) - dp2px(edgeMarginDp)
val maxY =
screenH - dp2px(windowHeightDp) - dp2px(navigationBarHeightDp + edgeMarginDp)
// 只在拖动并越界且方向吻合时吸顶/吸底
when {
params.y < minY && event.rawY < downRawY -> { // 向上拖超越,吸顶
params.y = minY
}
params.y > maxY && event.rawY > downRawY -> { // 向下拖超越,吸底
params.y = maxY
}
// 横向吸边逻辑如有需求也可依此加
}
// X方向仍可照旧做限制
params.x = params.x.coerceIn(minX, maxX)
// 更新Window位置
windowManager.updateViewLayout(view, params)
isMoving = false
}
return false
}
}
return false
}
})
floatView = view
windowManager.addView(view, params)
}
override fun onDestroy() {
super.onDestroy()
floatView?.let { windowManager.removeView(it) }
exoPlayer?.release()
exoPlayer = null
}
}