fix(photo-editor): 修复裁剪 tab 流程与裁剪框拖动
两个问题: 1. 切到裁剪 tab 后还要再点裁剪 icon 才进入裁剪——改为切 tab 即自动进入, 底部按钮由"开关裁剪"改为"✓ 确认裁剪",点击烘焙裁剪并保留在 tab。 2. 裁剪框无法移动——重写 CropOverlay 拖动逻辑: - 加 BODY 整体平移(拖框内区域平移整框) - onDrag 每帧从 state 读实时裁剪值叠加增量,修复闭包快照导致的抖动 - dragHandle 内聚到组件内部,消除跨重组延迟 - 抽出 hitHandle/moveCrop,四角与整体均带边界约束防交叉越界 ViewModel 新增 enterCrop/exitCrop/applyCrop,删除 toggleCrop。
This commit is contained in:
parent
cec8debff0
commit
b8509381d9
@ -80,14 +80,43 @@ class PhotoEditorViewModel(
|
|||||||
update { it.copy(rotationDegrees = it.rotationDegrees + delta) }
|
update { it.copy(rotationDegrees = it.rotationDegrees + delta) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */
|
/** 进入裁剪模式:启用裁剪框并重置为默认居中区域。 */
|
||||||
fun toggleCrop() {
|
fun enterCrop() {
|
||||||
update {
|
update {
|
||||||
if (it.cropEnabled) {
|
it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f)
|
||||||
it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f)
|
}
|
||||||
} else {
|
}
|
||||||
it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f)
|
|
||||||
}
|
/** 退出裁剪模式:清空裁剪框(不应用裁剪)。 */
|
||||||
|
fun exitCrop() {
|
||||||
|
update {
|
||||||
|
it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认裁剪:将当前裁剪框烘焙到源图,用裁剪结果替换 [PhotoEditorState.sourceBitmap],
|
||||||
|
* 重置旋转与裁剪框,清空手写笔触(坐标系已变化)。
|
||||||
|
*/
|
||||||
|
fun applyCrop() {
|
||||||
|
update {
|
||||||
|
if (!it.cropEnabled) return@update it
|
||||||
|
val cropped = PhotoProcessor.crop(
|
||||||
|
bitmap = it.rotatedBitmap,
|
||||||
|
left = it.cropLeft!!,
|
||||||
|
top = it.cropTop,
|
||||||
|
right = it.cropRight!!,
|
||||||
|
bottom = it.cropBottom
|
||||||
|
)
|
||||||
|
it.copy(
|
||||||
|
sourceBitmap = cropped,
|
||||||
|
rotationDegrees = 0,
|
||||||
|
cropLeft = null,
|
||||||
|
cropTop = 0f,
|
||||||
|
cropRight = null,
|
||||||
|
cropBottom = 1f,
|
||||||
|
strokes = emptyList()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Brush
|
import androidx.compose.material.icons.filled.Brush
|
||||||
import androidx.compose.material.icons.filled.ChevronLeft
|
import androidx.compose.material.icons.filled.ChevronLeft
|
||||||
import androidx.compose.material.icons.filled.Crop
|
import androidx.compose.material.icons.filled.Crop
|
||||||
import androidx.compose.material.icons.filled.CropFree
|
|
||||||
import androidx.compose.material.icons.filled.Done
|
import androidx.compose.material.icons.filled.Done
|
||||||
import androidx.compose.material.icons.filled.RotateLeft
|
import androidx.compose.material.icons.filled.RotateLeft
|
||||||
import androidx.compose.material.icons.filled.RotateRight
|
import androidx.compose.material.icons.filled.RotateRight
|
||||||
@ -45,6 +44,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@ -95,6 +95,14 @@ fun PhotoEditorScreen(
|
|||||||
var activeTab by remember { mutableStateOf(EditTab.ROTATE) }
|
var activeTab by remember { mutableStateOf(EditTab.ROTATE) }
|
||||||
val savedPath = state.savedPath
|
val savedPath = state.savedPath
|
||||||
|
|
||||||
|
// 切到裁剪 tab 自动进入裁剪模式;切走则退出(不应用裁剪)
|
||||||
|
LaunchedEffect(activeTab, state.editorState) {
|
||||||
|
when (activeTab) {
|
||||||
|
EditTab.CROP -> viewModel.enterCrop()
|
||||||
|
else -> viewModel.exitCrop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (savedPath != null) {
|
if (savedPath != null) {
|
||||||
LaunchedEffect(savedPath) { onSaved(savedPath) }
|
LaunchedEffect(savedPath) { onSaved(savedPath) }
|
||||||
}
|
}
|
||||||
@ -157,7 +165,7 @@ fun PhotoEditorScreen(
|
|||||||
activeTab = activeTab,
|
activeTab = activeTab,
|
||||||
onTabChange = { activeTab = it },
|
onTabChange = { activeTab = it },
|
||||||
onRotate = viewModel::rotate,
|
onRotate = viewModel::rotate,
|
||||||
onCropToggle = viewModel::toggleCrop,
|
onApplyCrop = viewModel::applyCrop,
|
||||||
onCropChange = viewModel::updateCrop,
|
onCropChange = viewModel::updateCrop,
|
||||||
onAddPoint = viewModel::addStrokePoint,
|
onAddPoint = viewModel::addStrokePoint,
|
||||||
onEndStroke = viewModel::endStroke,
|
onEndStroke = viewModel::endStroke,
|
||||||
@ -181,7 +189,7 @@ private fun EditorBody(
|
|||||||
activeTab: EditTab,
|
activeTab: EditTab,
|
||||||
onTabChange: (EditTab) -> Unit,
|
onTabChange: (EditTab) -> Unit,
|
||||||
onRotate: (Int) -> Unit,
|
onRotate: (Int) -> Unit,
|
||||||
onCropToggle: () -> Unit,
|
onApplyCrop: () -> Unit,
|
||||||
onCropChange: (Float, Float, Float, Float) -> Unit,
|
onCropChange: (Float, Float, Float, Float) -> Unit,
|
||||||
onAddPoint: (Offset) -> Unit,
|
onAddPoint: (Offset) -> Unit,
|
||||||
onEndStroke: () -> Unit,
|
onEndStroke: () -> Unit,
|
||||||
@ -245,7 +253,7 @@ private fun EditorBody(
|
|||||||
mode = activeTab,
|
mode = activeTab,
|
||||||
state = state,
|
state = state,
|
||||||
onRotate = onRotate,
|
onRotate = onRotate,
|
||||||
onCropToggle = onCropToggle,
|
onApplyCrop = onApplyCrop,
|
||||||
onUndoStroke = onUndoStroke,
|
onUndoStroke = onUndoStroke,
|
||||||
onStrokeColorChange = onStrokeColorChange,
|
onStrokeColorChange = onStrokeColorChange,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -265,8 +273,6 @@ private fun EditableImage(
|
|||||||
onDisplaySizeChange: (Float, Float) -> Unit
|
onDisplaySizeChange: (Float, Float) -> Unit
|
||||||
) {
|
) {
|
||||||
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
|
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
|
||||||
// 裁剪框拖动手柄(CROP 模式下使用)
|
|
||||||
var dragHandle by remember { mutableStateOf<CropHandle?>(null) }
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -299,8 +305,6 @@ private fun EditableImage(
|
|||||||
if (mode == EditTab.CROP && state.cropEnabled) {
|
if (mode == EditTab.CROP && state.cropEnabled) {
|
||||||
CropOverlay(
|
CropOverlay(
|
||||||
state = state,
|
state = state,
|
||||||
dragHandle = dragHandle,
|
|
||||||
onHandleChange = { dragHandle = it },
|
|
||||||
onCropChange = onCropChange,
|
onCropChange = onCropChange,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
@ -324,7 +328,7 @@ private fun EditableImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, NONE }
|
private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, BODY, NONE }
|
||||||
|
|
||||||
/** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
|
/** 裁剪框四元组,支持解构以简化 onDrag 回传。 */
|
||||||
private data class CropRect(
|
private data class CropRect(
|
||||||
@ -334,16 +338,110 @@ 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(
|
||||||
|
ox: Float,
|
||||||
|
oy: Float,
|
||||||
|
l: Float,
|
||||||
|
t: Float,
|
||||||
|
r: Float,
|
||||||
|
b: Float
|
||||||
|
): CropHandle {
|
||||||
|
val corner = 0.1f
|
||||||
|
val atTopLeft = (ox - l).absoluteValue < corner && (oy - t).absoluteValue < corner
|
||||||
|
val atTopRight = (ox - r).absoluteValue < corner && (oy - t).absoluteValue < corner
|
||||||
|
val atBottomLeft = (ox - l).absoluteValue < corner && (oy - b).absoluteValue < corner
|
||||||
|
val atBottomRight = (ox - r).absoluteValue < corner && (oy - b).absoluteValue < corner
|
||||||
|
return when {
|
||||||
|
atTopLeft -> CropHandle.TOP_LEFT
|
||||||
|
atTopRight -> CropHandle.TOP_RIGHT
|
||||||
|
atBottomLeft -> CropHandle.BOTTOM_LEFT
|
||||||
|
atBottomRight -> CropHandle.BOTTOM_RIGHT
|
||||||
|
ox in l..r && oy in t..b -> CropHandle.BODY
|
||||||
|
else -> CropHandle.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将单次拖动增量应用到当前裁剪框,返回受边界约束的新四元组。
|
||||||
|
*
|
||||||
|
* - 角手柄:只移动对应角,并用 [minSize] 约束避免左右/上下交叉
|
||||||
|
* - [CropHandle.BODY]:整体平移,受 [0,1] 边界约束
|
||||||
|
*
|
||||||
|
* @param dx x 方向增量比例(已归一化)
|
||||||
|
* @param dy y 方向增量比例(已归一化)
|
||||||
|
* @param l/t/r/b 当前裁剪框四边比例
|
||||||
|
* @param handle 本次拖动的手柄
|
||||||
|
* @param minSize 裁剪框最小宽/高比例,防止四角交叉
|
||||||
|
*/
|
||||||
|
private fun moveCrop(
|
||||||
|
dx: Float,
|
||||||
|
dy: Float,
|
||||||
|
l: Float,
|
||||||
|
t: Float,
|
||||||
|
r: Float,
|
||||||
|
b: Float,
|
||||||
|
handle: CropHandle,
|
||||||
|
minSize: Float = 0.1f
|
||||||
|
): CropRect {
|
||||||
|
val left = l.coerceIn(0f, 1f)
|
||||||
|
val top = t.coerceIn(0f, 1f)
|
||||||
|
val right = r.coerceIn(0f, 1f)
|
||||||
|
val bottom = b.coerceIn(0f, 1f)
|
||||||
|
return when (handle) {
|
||||||
|
CropHandle.TOP_LEFT -> {
|
||||||
|
val nl = (left + dx).coerceIn(0f, right - minSize)
|
||||||
|
val nt = (top + dy).coerceIn(0f, bottom - minSize)
|
||||||
|
CropRect(nl, nt, right, bottom)
|
||||||
|
}
|
||||||
|
CropHandle.TOP_RIGHT -> {
|
||||||
|
val nr = (right + dx).coerceIn(left + minSize, 1f)
|
||||||
|
val nt = (top + dy).coerceIn(0f, bottom - minSize)
|
||||||
|
CropRect(left, nt, nr, bottom)
|
||||||
|
}
|
||||||
|
CropHandle.BOTTOM_LEFT -> {
|
||||||
|
val nl = (left + dx).coerceIn(0f, right - minSize)
|
||||||
|
val nb = (bottom + dy).coerceIn(top + minSize, 1f)
|
||||||
|
CropRect(nl, top, right, nb)
|
||||||
|
}
|
||||||
|
CropHandle.BOTTOM_RIGHT -> {
|
||||||
|
val nr = (right + dx).coerceIn(left + minSize, 1f)
|
||||||
|
val nb = (bottom + dy).coerceIn(top + minSize, 1f)
|
||||||
|
CropRect(left, top, nr, nb)
|
||||||
|
}
|
||||||
|
CropHandle.BODY -> {
|
||||||
|
val w = right - left
|
||||||
|
val h = bottom - top
|
||||||
|
val nl = (left + dx).coerceIn(0f, 1f - w)
|
||||||
|
val nt = (top + dy).coerceIn(0f, 1f - h)
|
||||||
|
CropRect(nl, nt, nl + w, nt + h)
|
||||||
|
}
|
||||||
|
CropHandle.NONE -> CropRect(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CropOverlay(
|
private fun CropOverlay(
|
||||||
state: PhotoEditorState,
|
state: PhotoEditorState,
|
||||||
dragHandle: CropHandle?,
|
|
||||||
onHandleChange: (CropHandle?) -> Unit,
|
|
||||||
onCropChange: (Float, Float, Float, Float) -> Unit,
|
onCropChange: (Float, Float, Float, Float) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
// 拖动手柄内聚在此组件,避免跨重组延迟
|
||||||
|
var dragHandle by remember { mutableStateOf(CropHandle.NONE) }
|
||||||
val left = state.cropLeft ?: 0.1f
|
val left = state.cropLeft ?: 0.1f
|
||||||
|
val top = state.cropTop
|
||||||
val right = state.cropRight ?: 0.9f
|
val right = state.cropRight ?: 0.9f
|
||||||
|
val bottom = state.cropBottom
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = modifier.pointerInput(Unit) {
|
modifier = modifier.pointerInput(Unit) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
@ -351,45 +449,42 @@ private fun CropOverlay(
|
|||||||
val size = this.size
|
val size = this.size
|
||||||
val ox = offset.x / size.width
|
val ox = offset.x / size.width
|
||||||
val oy = offset.y / size.height
|
val oy = offset.y / size.height
|
||||||
onHandleChange(
|
dragHandle = hitHandle(
|
||||||
when {
|
ox, oy,
|
||||||
ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT
|
state.cropLeft ?: 0.1f,
|
||||||
ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT
|
state.cropTop,
|
||||||
ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT
|
state.cropRight ?: 0.9f,
|
||||||
ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT
|
state.cropBottom
|
||||||
else -> CropHandle.NONE
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDrag = { change, drag ->
|
onDrag = { change, drag ->
|
||||||
|
if (dragHandle == CropHandle.NONE) return@detectDragGestures
|
||||||
change.consume()
|
change.consume()
|
||||||
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
|
||||||
val (nL, nT, nR, nB) = when (dragHandle) {
|
// 每帧从 state 读最新值,叠加增量
|
||||||
CropHandle.TOP_LEFT ->
|
val cur = moveCrop(
|
||||||
CropRect(left + dx, state.cropTop + dy, right, state.cropBottom)
|
dx, dy,
|
||||||
CropHandle.TOP_RIGHT ->
|
state.cropLeft ?: 0.1f,
|
||||||
CropRect(left, state.cropTop + dy, right + dx, state.cropBottom)
|
state.cropTop,
|
||||||
CropHandle.BOTTOM_LEFT ->
|
state.cropRight ?: 0.9f,
|
||||||
CropRect(left + dx, state.cropTop, right, state.cropBottom + dy)
|
state.cropBottom,
|
||||||
CropHandle.BOTTOM_RIGHT ->
|
dragHandle
|
||||||
CropRect(left, state.cropTop, right + dx, state.cropBottom + dy)
|
)
|
||||||
else -> return@detectDragGestures
|
onCropChange(cur.left, cur.top, cur.right, cur.bottom)
|
||||||
}
|
|
||||||
onCropChange(nL, nT, nR, nB)
|
|
||||||
},
|
},
|
||||||
onDragEnd = { onHandleChange(null) },
|
onDragEnd = { dragHandle = CropHandle.NONE },
|
||||||
onDragCancel = { onHandleChange(null) }
|
onDragCancel = { dragHandle = CropHandle.NONE }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
val w = size.width
|
val w = size.width
|
||||||
val h = size.height
|
val h = size.height
|
||||||
val l = left * w
|
val l = left * w
|
||||||
val t = state.cropTop * h
|
val t = top * h
|
||||||
val r = right * w
|
val r = right * w
|
||||||
val b = state.cropBottom * h
|
val b = bottom * h
|
||||||
// 四周遮罩
|
// 四周遮罩
|
||||||
drawRect(Color.Black.copy(alpha = 0.5f), 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.5f), 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))
|
||||||
@ -410,7 +505,7 @@ private fun ToolBar(
|
|||||||
mode: EditTab,
|
mode: EditTab,
|
||||||
state: PhotoEditorState,
|
state: PhotoEditorState,
|
||||||
onRotate: (Int) -> Unit,
|
onRotate: (Int) -> Unit,
|
||||||
onCropToggle: () -> Unit,
|
onApplyCrop: () -> Unit,
|
||||||
onUndoStroke: () -> Unit,
|
onUndoStroke: () -> Unit,
|
||||||
onStrokeColorChange: (Color) -> Unit,
|
onStrokeColorChange: (Color) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
@ -430,11 +525,8 @@ private fun ToolBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditTab.CROP -> {
|
EditTab.CROP -> {
|
||||||
FilledIconButton(onClick = onCropToggle) {
|
FilledIconButton(onClick = onApplyCrop) {
|
||||||
Icon(
|
Icon(Icons.Filled.Done, contentDescription = "确认裁剪")
|
||||||
if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop,
|
|
||||||
contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditTab.HANDWRITE -> {
|
EditTab.HANDWRITE -> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user