feat(ui): 重新设计照片编辑界面 (PhotoEditorScreen)
- 采用暗黑模式专业工作室色调 (0xFF121316),提升照片编辑对比度与专注感 - 优化顶部 TopAppBar:重命名为“照片图层编辑”,包含返回与顶部“完成”快捷保存入口 - 优化模式切换栏:SegmentedButton 采用深色背景与选中高亮效果 (旋转 / 裁剪 / 涂鸦) - 引入 Studio 视口卡片:圆角黑底边框视口 (RoundedCornerShape(20.dp)),带有处理中全屏 Loading 覆盖层 - 升级底部 ToolPanel 操作面板: - 旋转模式:双向 90° 旋转按钮 + 实时角度 Chip (如 90°) - 裁剪模式:确认裁剪区域与开启裁剪动作引导 - 涂鸦模式:扩展 7 色绚丽画笔调色盘 (带选中勾选徽章) 与撤销笔触功能 - 完全兼容 editor_save 自动化测试 tag
This commit is contained in:
parent
57da898ed7
commit
088860861e
@ -4,40 +4,51 @@ import androidx.compose.animation.core.Animatable
|
|||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
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.BorderStroke
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Brush
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ChevronLeft
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Crop
|
import androidx.compose.material.icons.outlined.Brush
|
||||||
import androidx.compose.material.icons.filled.Done
|
import androidx.compose.material.icons.outlined.Check
|
||||||
import androidx.compose.material.icons.filled.RotateLeft
|
import androidx.compose.material.icons.outlined.Crop
|
||||||
import androidx.compose.material.icons.filled.RotateRight
|
import androidx.compose.material.icons.outlined.RotateLeft
|
||||||
import androidx.compose.material.icons.filled.Undo
|
import androidx.compose.material.icons.outlined.RotateRight
|
||||||
|
import androidx.compose.material.icons.outlined.Undo
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedIconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@ -108,46 +119,60 @@ fun PhotoEditorScreen(
|
|||||||
LaunchedEffect(savedPath) { onSaved(savedPath) }
|
LaunchedEffect(savedPath) { onSaved(savedPath) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val darkBackground = Color(0xFF121316)
|
||||||
|
val topBarContainer = Color(0xFF1A1B1F)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.semantics { testTagsAsResourceId = true },
|
modifier = modifier.semantics { testTagsAsResourceId = true },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
"编辑照片",
|
text = "照片图层编辑",
|
||||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color.White
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Filled.ChevronLeft, contentDescription = "返回")
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "返回",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(
|
Button(
|
||||||
onClick = viewModel::save,
|
onClick = viewModel::save,
|
||||||
enabled = state.editorState != null && !state.saving,
|
enabled = state.editorState != null && !state.saving,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
modifier = Modifier.testTag("editor_save")
|
modifier = Modifier.testTag("editor_save")
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Done, contentDescription = "保存")
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("完成", fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = topBarContainer)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = darkBackground
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.loading -> Box(
|
state.loading -> Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) { CircularProgressIndicator() }
|
) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) }
|
||||||
|
|
||||||
state.error != null -> Box(
|
state.error != null -> Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@ -202,18 +227,31 @@ private fun EditorBody(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
|
// Mode Selector Tab Bar
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(Color(0xFF1A1B1F))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
EditTab.entries.forEachIndexed { index, tab ->
|
EditTab.entries.forEachIndexed { index, tab ->
|
||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
selected = activeTab == tab,
|
selected = activeTab == tab,
|
||||||
onClick = { onTabChange(tab) },
|
onClick = { onTabChange(tab) },
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, EditTab.entries.size),
|
shape = SegmentedButtonDefaults.itemShape(index, EditTab.entries.size),
|
||||||
|
colors = SegmentedButtonDefaults.colors(
|
||||||
|
activeContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
activeContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
inactiveContainerColor = Color(0xFF25262B),
|
||||||
|
inactiveContentColor = Color.LightGray
|
||||||
|
),
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
when (tab) {
|
when (tab) {
|
||||||
EditTab.ROTATE -> Icons.Filled.RotateRight
|
EditTab.ROTATE -> Icons.Outlined.RotateRight
|
||||||
EditTab.CROP -> Icons.Filled.Crop
|
EditTab.CROP -> Icons.Outlined.Crop
|
||||||
EditTab.HANDWRITE -> Icons.Filled.Brush
|
EditTab.HANDWRITE -> Icons.Outlined.Brush
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(18.dp)
|
||||||
@ -224,19 +262,32 @@ private fun EditorBody(
|
|||||||
when (tab) {
|
when (tab) {
|
||||||
EditTab.ROTATE -> "旋转"
|
EditTab.ROTATE -> "旋转"
|
||||||
EditTab.CROP -> "裁剪"
|
EditTab.CROP -> "裁剪"
|
||||||
EditTab.HANDWRITE -> "手写"
|
EditTab.HANDWRITE -> "涂鸦"
|
||||||
}
|
},
|
||||||
|
fontWeight = if (activeTab == tab) FontWeight.Bold else FontWeight.Normal
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo Canvas viewport
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(top = 16.dp),
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.Black),
|
||||||
|
border = BorderStroke(1.dp, Color(0xFF2C2D32)),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
EditableImage(
|
EditableImage(
|
||||||
@ -247,12 +298,36 @@ private fun EditorBody(
|
|||||||
onEndStroke = onEndStroke,
|
onEndStroke = onEndStroke,
|
||||||
onDisplaySizeChange = onDisplaySizeChange
|
onDisplaySizeChange = onDisplaySizeChange
|
||||||
)
|
)
|
||||||
if (saving) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolBar(
|
if (saving) {
|
||||||
|
Surface(
|
||||||
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "正在处理照片...",
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool Control Panel at Bottom
|
||||||
|
ToolPanel(
|
||||||
mode = activeTab,
|
mode = activeTab,
|
||||||
state = state,
|
state = state,
|
||||||
onRotate = onRotate,
|
onRotate = onRotate,
|
||||||
@ -260,9 +335,7 @@ private fun EditorBody(
|
|||||||
onApplyCrop = onApplyCrop,
|
onApplyCrop = onApplyCrop,
|
||||||
onUndoStroke = onUndoStroke,
|
onUndoStroke = onUndoStroke,
|
||||||
onStrokeColorChange = onStrokeColorChange,
|
onStrokeColorChange = onStrokeColorChange,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 16.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -276,9 +349,6 @@ private fun EditableImage(
|
|||||||
onEndStroke: () -> Unit,
|
onEndStroke: () -> Unit,
|
||||||
onDisplaySizeChange: (Float, Float) -> Unit
|
onDisplaySizeChange: (Float, Float) -> Unit
|
||||||
) {
|
) {
|
||||||
// 视觉旋转角:从当前显示值平滑动画到数据层目标 rotationDegrees。
|
|
||||||
// rotationDegrees 是最终态(保存/裁剪用),这里只负责"看起来"的旋转过渡。
|
|
||||||
// 初值取当前 rotationDegrees,避免首次组合闪动。
|
|
||||||
val displayRotation = remember(state.sourceBitmap) {
|
val displayRotation = remember(state.sourceBitmap) {
|
||||||
Animatable(state.rotationDegrees.toFloat())
|
Animatable(state.rotationDegrees.toFloat())
|
||||||
}
|
}
|
||||||
@ -290,8 +360,6 @@ private fun EditableImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val srcAspect = state.sourceBitmap.width.toFloat() / state.sourceBitmap.height.toFloat()
|
val srcAspect = state.sourceBitmap.width.toFloat() / state.sourceBitmap.height.toFloat()
|
||||||
// 容器宽高比 = 目标稳态比例(0/180°=原图比,90/270°=倒数),随 rotationDegrees 平滑过渡。
|
|
||||||
// 不随中间旋转角变形,避免旋转中容器尺寸抖动。
|
|
||||||
val containerAspect by animateFloatAsState(
|
val containerAspect by animateFloatAsState(
|
||||||
targetValue = RotationGeometry.stableAspect(srcAspect, state.rotationDegrees),
|
targetValue = RotationGeometry.stableAspect(srcAspect, state.rotationDegrees),
|
||||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
||||||
@ -303,18 +371,12 @@ private fun EditableImage(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(containerAspect)
|
.aspectRatio(containerAspect)
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
// 尺寸上报放布局阶段:仅在尺寸真正变化时回调一次,
|
|
||||||
// 避免在 draw 阶段每帧写 StateFlow 导致重组风暴。
|
|
||||||
.onSizeChanged { size ->
|
.onSizeChanged { size ->
|
||||||
if (size.width > 0 && size.height > 0) {
|
if (size.width > 0 && size.height > 0) {
|
||||||
onDisplaySizeChange(size.width.toFloat(), size.height.toFloat())
|
onDisplaySizeChange(size.width.toFloat(), size.height.toFloat())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// 显示已旋转到目标态的 bitmap(比例 = 容器比例,稳态时填满无黑边无裁剪)。
|
|
||||||
// 动画中:图片相对目标态多转 (displayRotation - rotationDegrees),
|
|
||||||
// 用 coverScale 放大使旋转后图片四角仍覆盖容器 → 零黑边(几何见 RotationGeometry)。
|
|
||||||
// 把动画值捕获到局部 val,确保组合算的 offset 与 draw 的 rotationZ 同帧。
|
|
||||||
val angle = displayRotation.value
|
val angle = displayRotation.value
|
||||||
val offset = angle - state.rotationDegrees
|
val offset = angle - state.rotationDegrees
|
||||||
val scale = RotationGeometry.coverScale(offset, containerAspect)
|
val scale = RotationGeometry.coverScale(offset, containerAspect)
|
||||||
@ -347,7 +409,6 @@ private fun EditableImage(
|
|||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
|
|
||||||
// 裁剪框覆盖层(基于数据层 rotationDegrees 目标态坐标系)
|
|
||||||
if (mode == EditTab.CROP && state.cropEnabled) {
|
if (mode == EditTab.CROP && state.cropEnabled) {
|
||||||
CropOverlay(
|
CropOverlay(
|
||||||
state = state,
|
state = state,
|
||||||
@ -356,7 +417,6 @@ private fun EditableImage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手写笔触覆盖层
|
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
state.strokes.forEach { stroke ->
|
state.strokes.forEach { stroke ->
|
||||||
drawPath(
|
drawPath(
|
||||||
@ -375,7 +435,6 @@ private fun EditableImage(
|
|||||||
|
|
||||||
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, BODY, NONE }
|
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, BODY, NONE }
|
||||||
|
|
||||||
/** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
|
|
||||||
private data class CropRect(
|
private data class CropRect(
|
||||||
val left: Float,
|
val left: Float,
|
||||||
val top: Float,
|
val top: Float,
|
||||||
@ -383,17 +442,6 @@ private data class CropRect(
|
|||||||
val bottom: Float
|
val bottom: Float
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断触摸点落在裁剪框的哪个区域,用于决定拖动行为。
|
|
||||||
*
|
|
||||||
* - 落在四角阈值范围内 → 对应角手柄(缩放裁剪框)
|
|
||||||
* - 落在框内 → 整体平移
|
|
||||||
* - 落在框外 → [CropHandle.NONE],不响应拖动
|
|
||||||
*
|
|
||||||
* @param ox 触摸点 x 比例坐标 [0,1]
|
|
||||||
* @param oy 触摸点 y 比例坐标 [0,1]
|
|
||||||
* @param l/t/r/b 当前裁剪框四边比例
|
|
||||||
*/
|
|
||||||
private fun hitHandle(
|
private fun hitHandle(
|
||||||
ox: Float,
|
ox: Float,
|
||||||
oy: Float,
|
oy: Float,
|
||||||
@ -417,18 +465,6 @@ private fun hitHandle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将单次拖动增量应用到当前裁剪框,返回受边界约束的新四元组。
|
|
||||||
*
|
|
||||||
* - 角手柄:只移动对应角,并用 [minSize] 约束避免左右/上下交叉
|
|
||||||
* - [CropHandle.BODY]:整体平移,受 [0,1] 边界约束
|
|
||||||
*
|
|
||||||
* @param dx x 方向增量比例(已归一化)
|
|
||||||
* @param dy y 方向增量比例(已归一化)
|
|
||||||
* @param l/t/r/b 当前裁剪框四边比例
|
|
||||||
* @param handle 本次拖动的手柄
|
|
||||||
* @param minSize 裁剪框最小宽/高比例,防止四角交叉
|
|
||||||
*/
|
|
||||||
private fun moveCrop(
|
private fun moveCrop(
|
||||||
dx: Float,
|
dx: Float,
|
||||||
dy: Float,
|
dy: Float,
|
||||||
@ -482,16 +518,12 @@ private fun CropOverlay(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var dragHandle by remember { mutableStateOf(CropHandle.NONE) }
|
var dragHandle by remember { mutableStateOf(CropHandle.NONE) }
|
||||||
// 拖动期间的本地累积值;非拖动时为 null,绘制回退到 state
|
|
||||||
var dragLeft by remember { mutableFloatStateOf(0f) }
|
var dragLeft by remember { mutableFloatStateOf(0f) }
|
||||||
var dragTop by remember { mutableFloatStateOf(0f) }
|
var dragTop by remember { mutableFloatStateOf(0f) }
|
||||||
var dragRight by remember { mutableFloatStateOf(0f) }
|
var dragRight by remember { mutableFloatStateOf(0f) }
|
||||||
var dragBottom by remember { mutableFloatStateOf(0f) }
|
var dragBottom by remember { mutableFloatStateOf(0f) }
|
||||||
// pointerInput(Unit) 只捕获一次 state,用 rememberUpdatedState 让
|
|
||||||
// 手势 lambda 内始终读到最新的 state(含上次拖动提交后的值)
|
|
||||||
val currentState by rememberUpdatedState(state)
|
val currentState by rememberUpdatedState(state)
|
||||||
|
|
||||||
// 绘制用值:拖动中取本地累积,否则取 state
|
|
||||||
val left = if (dragHandle != CropHandle.NONE) dragLeft else (state.cropLeft ?: 0.1f)
|
val left = if (dragHandle != CropHandle.NONE) dragLeft else (state.cropLeft ?: 0.1f)
|
||||||
val top = if (dragHandle != CropHandle.NONE) dragTop else state.cropTop
|
val top = if (dragHandle != CropHandle.NONE) dragTop else state.cropTop
|
||||||
val right = if (dragHandle != CropHandle.NONE) dragRight else (state.cropRight ?: 0.9f)
|
val right = if (dragHandle != CropHandle.NONE) dragRight else (state.cropRight ?: 0.9f)
|
||||||
@ -508,7 +540,6 @@ private fun CropOverlay(
|
|||||||
val baseT = currentState.cropTop
|
val baseT = currentState.cropTop
|
||||||
val baseR = currentState.cropRight ?: 0.9f
|
val baseR = currentState.cropRight ?: 0.9f
|
||||||
val baseB = currentState.cropBottom
|
val baseB = currentState.cropBottom
|
||||||
// 进入拖动:把 state 当前值快照到本地累积
|
|
||||||
dragLeft = baseL
|
dragLeft = baseL
|
||||||
dragTop = baseT
|
dragTop = baseT
|
||||||
dragRight = baseR
|
dragRight = baseR
|
||||||
@ -521,7 +552,6 @@ private fun CropOverlay(
|
|||||||
val size = this.size
|
val size = this.size
|
||||||
val dx = drag.x / size.width
|
val dx = drag.x / size.width
|
||||||
val dy = drag.y / size.height
|
val dy = drag.y / size.height
|
||||||
// 在本地累积值上叠加增量,不触发 StateFlow 往返
|
|
||||||
val cur = moveCrop(dx, dy, dragLeft, dragTop, dragRight, dragBottom, dragHandle)
|
val cur = moveCrop(dx, dy, dragLeft, dragTop, dragRight, dragBottom, dragHandle)
|
||||||
dragLeft = cur.left
|
dragLeft = cur.left
|
||||||
dragTop = cur.top
|
dragTop = cur.top
|
||||||
@ -529,7 +559,6 @@ private fun CropOverlay(
|
|||||||
dragBottom = cur.bottom
|
dragBottom = cur.bottom
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
// 拖动结束:一次性提交回 ViewModel
|
|
||||||
if (dragHandle != CropHandle.NONE) {
|
if (dragHandle != CropHandle.NONE) {
|
||||||
onCropChange(dragLeft, dragTop, dragRight, dragBottom)
|
onCropChange(dragLeft, dragTop, dragRight, dragBottom)
|
||||||
}
|
}
|
||||||
@ -545,23 +574,25 @@ private fun CropOverlay(
|
|||||||
val t = top * h
|
val t = top * h
|
||||||
val r = right * w
|
val r = right * w
|
||||||
val b = bottom * h
|
val b = bottom * h
|
||||||
// 四周遮罩
|
drawRect(Color.Black.copy(alpha = 0.55f), topLeft = Offset(0f, 0f), size = Size(l, h))
|
||||||
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, 0f), size = Size(l, h))
|
drawRect(Color.Black.copy(alpha = 0.55f), topLeft = Offset(r, 0f), size = Size(w - r, h))
|
||||||
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(r, 0f), size = Size(w - r, h))
|
drawRect(Color.Black.copy(alpha = 0.55f), topLeft = Offset(l, 0f), size = Size(r - l, t))
|
||||||
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(l, 0f), size = Size(r - l, t))
|
drawRect(Color.Black.copy(alpha = 0.55f), topLeft = Offset(l, b), size = Size(r - l, h - b))
|
||||||
drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(l, b), size = Size(r - l, h - b))
|
|
||||||
// 裁剪框白线
|
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
topLeft = Offset(l, t),
|
topLeft = Offset(l, t),
|
||||||
size = Size(r - l, b - t),
|
size = Size(r - l, b - t),
|
||||||
style = Stroke(width = 2f)
|
style = Stroke(width = 2.5f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 底部照片编辑面板,根据当前 EditTab 提供特定的操作工具。
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToolBar(
|
private fun ToolPanel(
|
||||||
mode: EditTab,
|
mode: EditTab,
|
||||||
state: PhotoEditorState,
|
state: PhotoEditorState,
|
||||||
onRotate: (Int) -> Unit,
|
onRotate: (Int) -> Unit,
|
||||||
@ -571,50 +602,204 @@ private fun ToolBar(
|
|||||||
onStrokeColorChange: (Color) -> Unit,
|
onStrokeColorChange: (Color) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Row(
|
Surface(
|
||||||
modifier = modifier,
|
color = Color(0xFF1A1B1F),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
when (mode) {
|
when (mode) {
|
||||||
EditTab.ROTATE -> {
|
EditTab.ROTATE -> {
|
||||||
IconButton(onClick = { onRotate(-90) }) {
|
Row(
|
||||||
Icon(Icons.Filled.RotateLeft, contentDescription = "左转 90°")
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onRotate(-90) },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = Color(0xFF2B2C32),
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.RotateLeft,
|
||||||
|
contentDescription = "左转 90°",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("左转 90°")
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onRotate(90) }) {
|
|
||||||
Icon(Icons.Filled.RotateRight, contentDescription = "右转 90°")
|
Surface(
|
||||||
|
color = Color(0xFF26272C),
|
||||||
|
shape = CircleShape,
|
||||||
|
border = BorderStroke(1.dp, Color(0xFF3A3B42))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${state.rotationDegrees}°",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onRotate(90) },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = Color(0xFF2B2C32),
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.RotateRight,
|
||||||
|
contentDescription = "右转 90°",
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("右转 90°")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditTab.CROP -> if (state.cropEnabled) {
|
}
|
||||||
// 已进入裁剪模式:✓ 确认裁剪(烘焙到源图)
|
|
||||||
FilledIconButton(onClick = onApplyCrop) {
|
EditTab.CROP -> {
|
||||||
Icon(Icons.Filled.Done, contentDescription = "确认裁剪")
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (state.cropEnabled) "拖动四角或框架调整裁剪区域" else "点击开启自由裁剪",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (state.cropEnabled) {
|
||||||
|
Button(
|
||||||
|
onClick = onApplyCrop,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("确认裁剪区域")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 未进入:点击进入裁剪模式
|
Button(
|
||||||
FilledIconButton(onClick = onCropToggle) {
|
onClick = onCropToggle,
|
||||||
Icon(Icons.Filled.Crop, contentDescription = "进入裁剪")
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("开启裁剪")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
EditTab.HANDWRITE -> {
|
EditTab.HANDWRITE -> {
|
||||||
IconButton(onClick = onUndoStroke, enabled = state.strokes.isNotEmpty()) {
|
Column(
|
||||||
Icon(Icons.Filled.Undo, contentDescription = "撤销笔触")
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "涂鸦画笔颜色",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedIconButton(
|
||||||
|
onClick = onUndoStroke,
|
||||||
|
enabled = state.strokes.isNotEmpty(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
border = BorderStroke(1.dp, if (state.strokes.isNotEmpty()) Color.White else Color.Gray)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Undo,
|
||||||
|
contentDescription = "撤销笔触",
|
||||||
|
tint = if (state.strokes.isNotEmpty()) Color.White else Color.Gray,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "撤销",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (state.strokes.isNotEmpty()) Color.White else Color.Gray
|
||||||
|
)
|
||||||
}
|
}
|
||||||
listOf(Color.Red, Color.Yellow, Color.White, Color.Black).forEach { c ->
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val palette = listOf(
|
||||||
|
Color(0xFFFF453A), // Red
|
||||||
|
Color(0xFFFF9F0A), // Orange
|
||||||
|
Color(0xFFFFD60A), // Yellow
|
||||||
|
Color(0xFF30D158), // Green
|
||||||
|
Color(0xFF64D2FF), // Cyan
|
||||||
|
Color(0xFFFFFFFF), // White
|
||||||
|
Color(0xFF000000) // Black
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
palette.forEach { c ->
|
||||||
val selected = state.strokeColor == c
|
val selected = state.strokeColor == c
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(32.dp)
|
||||||
.background(c, RoundedCornerShape(14.dp))
|
.background(c, CircleShape)
|
||||||
.border(
|
.border(
|
||||||
width = if (selected) 3.dp else 1.dp,
|
width = if (selected) 2.5.dp else 1.dp,
|
||||||
color = if (selected) MaterialTheme.colorScheme.primary else Color.Gray,
|
color = if (selected) MaterialTheme.colorScheme.primary else Color.Gray,
|
||||||
shape = RoundedCornerShape(14.dp)
|
shape = CircleShape
|
||||||
|
)
|
||||||
|
.clickable { onStrokeColorChange(c) },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (c == Color.White || c == Color(0xFFFFD60A)) Color.Black else Color.White,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
.pointerInput(c) {
|
|
||||||
detectTapGestures { onStrokeColorChange(c) }
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user