refactor: 优化语音录制按钮与聊天头
- 优化VoiceRecordButton语音录制按钮 - 优化ChatHeader聊天头组件 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,9 +142,24 @@ fun ChatHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 右侧占位,保持标题居中
|
// 右侧:新建对话按钮或占位,保持标题居中
|
||||||
|
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))
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,63 +176,156 @@ 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
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(0.4f))
|
||||||
|
// 中部:计时 + 波形 + 提示
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(200.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(DeepPurple.copy(alpha = 0.92f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
if (state == RecordingState.CANCEL) {
|
RecordingIndicator(modifier = Modifier.size(48.dp))
|
||||||
Icon(
|
|
||||||
imageVector = AppIcons.Close,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = AppWhite,
|
|
||||||
modifier = Modifier.size(48.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
RecordingIndicator(
|
|
||||||
modifier = Modifier.size(48.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(duration),
|
text = formatDuration(duration),
|
||||||
fontSize = 24.sp,
|
fontSize = 26.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = AppWhite
|
color = AppWhite
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (state == RecordingState.CANCEL) "松开取消" else "上滑取消",
|
text = "左滑取消 · 右滑转文字",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = AppWhite.copy(alpha = 0.8f)
|
color = AppWhite.copy(alpha = 0.85f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(0.2f))
|
||||||
|
// 底部三区:取消 | 松开发送 | 转文字(需按住底部按钮维持 = 本层即“底部按钮”的延伸)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.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
|
||||||
|
)
|
||||||
|
ZoneChip(
|
||||||
|
label = "松开发送",
|
||||||
|
icon = AppIcons.Mic,
|
||||||
|
isHighlight = highlightZone == ReleaseAction.SEND_VOICE,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user