refactor: 优化语音录制按钮与聊天头

- 优化VoiceRecordButton语音录制按钮
- 优化ChatHeader聊天头组件

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-10 17:10:02 +08:00
parent 261d812dd9
commit a29cb6ec29
2 changed files with 186 additions and 75 deletions

View File

@@ -32,6 +32,7 @@ import com.huaga.life_echo.ui.theme.*
* @param title 标题 * @param title 标题
* @param isOnline 在线状态 * @param isOnline 在线状态
* @param onBackClick 返回按钮点击回调 * @param onBackClick 返回按钮点击回调
* @param onNewConversationClick 新建对话按钮点击回调,为 null 时不显示该按钮
* @param modifier 修饰符 * @param modifier 修饰符
* @param isTransparent 是否透明化默认false * @param isTransparent 是否透明化默认false
* @param transparencyType 透明化类型0=完全透明, 1=半透明, 2=渐变透明 * @param transparencyType 透明化类型0=完全透明, 1=半透明, 2=渐变透明
@@ -42,6 +43,7 @@ fun ChatHeader(
title: String, title: String,
isOnline: Boolean = true, isOnline: Boolean = true,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onNewConversationClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isTransparent: Boolean = false, isTransparent: Boolean = false,
transparencyType: Int = 0, transparencyType: Int = 0,
@@ -140,8 +142,23 @@ fun ChatHeader(
} }
} }
// 右侧占位,保持标题居中 // 右侧:新建对话按钮或占位,保持标题居中
Spacer(modifier = Modifier.size(48.dp)) if (onNewConversationClick != null) {
IconButton(
onClick = onNewConversationClick,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(AppDimensions.buttonRadius))
) {
Icon(
imageVector = AppIcons.Add,
contentDescription = "新建对话",
tint = AppWhite
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
}
} }
} }
} }

View File

@@ -4,6 +4,7 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -14,11 +15,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.huaga.life_echo.ui.icons.AppIcons import com.huaga.life_echo.ui.icons.AppIcons
import com.huaga.life_echo.ui.theme.* import com.huaga.life_echo.ui.theme.*
@@ -32,10 +38,25 @@ enum class RecordingState {
CANCEL // 准备取消 CANCEL // 准备取消
} }
/**
* 在录音浮层上松开时的动作:由滑动到的区域决定
*/
enum class ReleaseAction {
/** 取消,不发送 */
CANCEL,
/** 发送语音消息 */
SEND_VOICE,
/** 转文字并发送文本消息 */
SEND_AS_TEXT
}
/** /**
* 语音录音按钮组件(仿微信按住说话) * 语音录音按钮组件(仿微信按住说话)
* 松开即发送:浮层仅做视觉反馈,不拦截触摸,由按钮直接接收 release 并触发发送。 * 松开即发送:浮层仅做视觉反馈,不拦截触摸,由按钮直接接收 release 并触发发送。
* *
* 关键实现:通过 consume() 消费所有 pointer 事件,防止父容器(如滚动列表)
* 偷走手势,确保按住期间能正确检测释放和上滑取消。
*
* @param onStartRecording 开始录音回调 * @param onStartRecording 开始录音回调
* @param onStopRecording 停止录音并发送回调 * @param onStopRecording 停止录音并发送回调
* @param onCancelRecording 取消录音回调 * @param onCancelRecording 取消录音回调
@@ -56,10 +77,6 @@ fun VoiceRecordButton(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var recordingState by remember { mutableStateOf(RecordingState.IDLE) } var recordingState by remember { mutableStateOf(RecordingState.IDLE) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
// 取消阈值(上滑超过这个距离就取消)
val cancelThreshold = with(LocalDensity.current) { 80.dp.toPx() }
// 动画 // 动画
val scale by animateFloatAsState( val scale by animateFloatAsState(
@@ -82,7 +99,6 @@ fun VoiceRecordButton(
LaunchedEffect(isRecording) { LaunchedEffect(isRecording) {
if (!isRecording && recordingState != RecordingState.IDLE) { if (!isRecording && recordingState != RecordingState.IDLE) {
recordingState = RecordingState.IDLE recordingState = RecordingState.IDLE
dragOffsetY = 0f
} }
} }
// 通知上层当前录音状态(用于全屏浮层展示,浮层不消费触摸,松开由本按钮接收并立即发送) // 通知上层当前录音状态(用于全屏浮层展示,浮层不消费触摸,松开由本按钮接收并立即发送)
@@ -99,41 +115,26 @@ fun VoiceRecordButton(
.scale(scale) .scale(scale)
.clip(RoundedCornerShape(36.dp)) .clip(RoundedCornerShape(36.dp))
.background(backgroundColor) .background(backgroundColor)
.pointerInput(enabled, cancelThreshold) { .pointerInput(enabled) {
if (!enabled) return@pointerInput if (!enabled) return@pointerInput
// 按下即开始录音;同一 pointer 的 release 会先到本按钮,必须在此处理才能终止录音
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent(PointerEventPass.Initial)
val changes = event.changes val changes = event.changes
val justDown = changes.any { it.changedToDown() }
// 按下:开始录音(任一指头按下) val justUp = changes.any { it.changedToUp() }
val justPressed = changes.any { it.pressed && !it.previousPressed } val allUp = changes.all { !it.pressed }
if (justPressed) { if (justDown) {
changes.forEach { it.consume() }
recordingState = RecordingState.RECORDING recordingState = RecordingState.RECORDING
dragOffsetY = 0f
onStartRecording() onStartRecording()
} } else if (recordingState == RecordingState.RECORDING && (justUp || allUp)) {
// 抬起:结束或取消 changes.forEach { it.consume() }
val justReleased = changes.any { !it.pressed && it.previousPressed } onStopRecording()
if (justReleased) {
when (recordingState) {
RecordingState.CANCEL -> onCancelRecording()
RecordingState.RECORDING -> onStopRecording()
else -> {}
}
recordingState = RecordingState.IDLE recordingState = RecordingState.IDLE
dragOffsetY = 0f } else if (recordingState == RecordingState.RECORDING) {
} changes.forEach { it.consume() }
// 跟踪 Y 方向位移用于上滑取消
if (recordingState == RecordingState.RECORDING || recordingState == RecordingState.CANCEL) {
val deltaY = changes.sumOf { (it.position.y - it.previousPosition.y).toDouble() }.toFloat()
dragOffsetY += deltaY
recordingState = if (-dragOffsetY > cancelThreshold) {
RecordingState.CANCEL
} else {
RecordingState.RECORDING
}
} }
} }
} }
@@ -175,64 +176,157 @@ fun VoiceRecordButton(
} }
/** /**
* 录音浮层纯视觉内容:全屏半透明遮罩 + 居中计时卡片 * 录音浮层:按住说话后覆盖在聊天之上的全屏层,只检测本层手势
* 不处理触摸(无 pointerInput松开由 VoiceRecordButton 接收并立即发送 * - 按住底部按钮维持录音,浮层出现后底层不再响应操作
* 供上层在根布局用 Box 展示,避免 Popup 拦截导致需二次点击才发送 * - 滑动到「取消」松开 = 不发送;滑动到「转文字」松开 = 转成文字并发送;否则松开发送语音
*
* @param onRelease 手指抬起时回调,根据当前滑入的区域传入对应动作
*/ */
@Composable @Composable
fun RecordingOverlayContent( fun RecordingOverlayContent(
state: RecordingState,
duration: Int, duration: Int,
onRelease: (ReleaseAction) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( var highlightZone by remember { mutableStateOf<ReleaseAction?>(null) }
modifier = modifier.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center BoxWithConstraints(modifier = modifier) {
) { val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
Box( Box(
modifier = Modifier modifier = Modifier
.size(160.dp) .fillMaxSize()
.clip(RoundedCornerShape(16.dp)) .then(
.background( Modifier.pointerInput(widthPx) {
if (state == RecordingState.CANCEL) awaitPointerEventScope {
ErrorRed.copy(alpha = 0.9f) while (true) {
else val event = awaitPointerEvent(PointerEventPass.Initial)
DeepPurple.copy(alpha = 0.9f) event.changes.forEach { it.consume() }
), val w = widthPx
contentAlignment = Alignment.Center val position = event.changes.firstOrNull()?.position ?: Offset.Zero
if (w > 0) {
val third = w / 3f
highlightZone = when {
position.x < third -> ReleaseAction.CANCEL
position.x > third * 2f -> ReleaseAction.SEND_AS_TEXT
else -> ReleaseAction.SEND_VOICE
}
}
if (event.changes.any { it.changedToUp() }) {
val action = highlightZone ?: ReleaseAction.SEND_VOICE
highlightZone = null
onRelease(action)
break
}
}
}
}
)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) { ) {
Column( Spacer(modifier = Modifier.weight(0.4f))
horizontalAlignment = Alignment.CenterHorizontally, // 中部:计时 + 波形 + 提示
verticalArrangement = Arrangement.Center Box(
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(20.dp))
.background(DeepPurple.copy(alpha = 0.92f)),
contentAlignment = Alignment.Center
) { ) {
if (state == RecordingState.CANCEL) { Column(
Icon( horizontalAlignment = Alignment.CenterHorizontally,
imageVector = AppIcons.Close, verticalArrangement = Arrangement.Center
contentDescription = null, ) {
tint = AppWhite, RecordingIndicator(modifier = Modifier.size(48.dp))
modifier = Modifier.size(48.dp) Spacer(modifier = Modifier.height(12.dp))
Text(
text = formatDuration(duration),
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
color = AppWhite
) )
} else { Spacer(modifier = Modifier.height(8.dp))
RecordingIndicator( Text(
modifier = Modifier.size(48.dp) text = "左滑取消 · 右滑转文字",
fontSize = 13.sp,
color = AppWhite.copy(alpha = 0.85f)
) )
} }
Spacer(modifier = Modifier.height(12.dp)) }
Text( Spacer(modifier = Modifier.weight(0.2f))
text = formatDuration(duration), // 底部三区:取消 | 松开发送 | 转文字(需按住底部按钮维持 = 本层即“底部按钮”的延伸)
fontSize = 24.sp, Row(
fontWeight = FontWeight.Bold, modifier = Modifier
color = AppWhite .fillMaxWidth()
.height(72.dp)
.background(Color.Black.copy(alpha = 0.35f)),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
ZoneChip(
label = "取消",
icon = AppIcons.Close,
isHighlight = highlightZone == ReleaseAction.CANCEL,
tintHighlight = ErrorRed
) )
Spacer(modifier = Modifier.height(8.dp)) ZoneChip(
Text( label = "松开发送",
text = if (state == RecordingState.CANCEL) "松开取消" else "上滑取消", icon = AppIcons.Mic,
fontSize = 13.sp, isHighlight = highlightZone == ReleaseAction.SEND_VOICE,
color = AppWhite.copy(alpha = 0.8f) tintHighlight = MediumPurple
)
ZoneChip(
label = "转文字",
icon = AppIcons.Keyboard,
isHighlight = highlightZone == ReleaseAction.SEND_AS_TEXT,
tintHighlight = Lavender
) )
} }
} }
} }
}
}
@Composable
private fun ZoneChip(
label: String,
icon: ImageVector,
isHighlight: Boolean,
tintHighlight: Color
) {
val bg = if (isHighlight) tintHighlight.copy(alpha = 0.35f) else Color.Transparent
val tint = if (isHighlight) tintHighlight else AppWhite.copy(alpha = 0.9f)
Box(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(bg)
.padding(horizontal = 20.dp, vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = tint
)
}
}
} }
/** /**