From cec8debff09b8f12ae9da4497f1d6bd37c8d3758 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 22 Jul 2026 14:26:27 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(date-recorder):=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E5=9B=BE=E7=89=87=E7=81=AF=E7=AE=B1=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BC=A9=E6=94=BE/=E6=97=8B=E8=BD=AC=E6=89=8B?= =?UTF-8?q?=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击详情页大图弹出全屏深色遮罩,集成 panpf/zoomimage 的 SketchZoomAsyncImage,支持双指缩放、平移、捻合旋转(松手吸附 0/90/180/270°)与双击复位,点空白或返回键关闭。 新增 zoomimage-compose-sketch4 1.6.0 依赖(与项目 sketch 同作者, SketchZoomAsyncImage 是 AsyncImage 超集,额外提供缩放/旋转/大图分片)。 --- core/build.gradle.kts | 3 + .../plus/rua/project/ui/ImageLightbox.kt | 72 +++++++++++++++++++ .../plus/rua/project/ui/RecordDetailScreen.kt | 18 +++++ gradle/libs.versions.toml | 2 + 4 files changed, 95 insertions(+) create mode 100644 core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8ea3fc1..30f081c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -105,6 +105,9 @@ dependencies { implementation(libs.camera.view) implementation(libs.androidx.exifinterface) + // 日期记录器:详情页灯箱图片缩放/旋转(与 sketch 同作者 panpf,SketchZoomAsyncImage 是 AsyncImage 超集) + implementation(libs.zoomimage.compose.sketch4) + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}") testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.room.testing) diff --git a/core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt b/core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt new file mode 100644 index 0000000..e9f79a0 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/ImageLightbox.kt @@ -0,0 +1,72 @@ +package plus.rua.project.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.github.panpf.sketch.rememberAsyncImageState +import com.github.panpf.zoomimage.SketchZoomAsyncImage +import com.github.panpf.zoomimage.rememberSketchZoomState + +/** + * 全屏图片灯箱,支持双指缩放、平移与旋转查看。 + * + * 在全屏深色遮罩中居中显示图片,底层用 zoomimage 的 [SketchZoomAsyncImage] + * 提供手势:双指缩放、单指平移、双指捻合自由旋转(松手后吸附到最近的 + * 0/90/180/270°)、双击复位。点击图片外的空白区域或按返回键触发 [onDismiss]。 + * + * sketch 的 [rememberAsyncImageState] 负责加载,zoomimage 的 + * [rememberSketchZoomState] 负责缩放/平移/旋转——后者是 sketch `AsyncImage` + * 之外新增的必需参数。 + * + * @param photoUri 图片 URI;为 null 时直接返回不渲染 + * @param contentDescription 无障碍描述,传 null 表示纯装饰 + * @param onDismiss 关闭回调(点击空白区域或按返回键时触发) + * @param modifier 布局修饰符 + */ +@Composable +fun ImageLightbox( + photoUri: String?, + contentDescription: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + if (photoUri == null) return + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + decorFitsSystemWindows = false, + usePlatformDefaultWidth = false + ) + ) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ), + contentAlignment = Alignment.Center + ) { + val asyncImageState = rememberAsyncImageState() + val zoomState = rememberSketchZoomState() + SketchZoomAsyncImage( + uri = photoUri, + contentDescription = contentDescription, + state = asyncImageState, + zoomState = zoomState, + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt index 8168e90..c1a232e 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/RecordDetailScreen.kt @@ -1,5 +1,7 @@ package plus.rua.project.ui +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -81,6 +83,7 @@ fun RecordDetailScreen( ) val state by viewModel.uiState.collectAsStateWithLifecycle() var showDeleteDialog by remember { mutableStateOf(false) } + var showLightbox by remember { mutableStateOf(false) } // 删除完成后自动返回 if (state.deleted) { @@ -142,6 +145,7 @@ fun RecordDetailScreen( else -> DetailContent( state = state, + onPhotoClick = { showLightbox = true }, modifier = Modifier .fillMaxSize() .padding(innerPadding) @@ -165,11 +169,20 @@ fun RecordDetailScreen( } ) } + + if (showLightbox) { + ImageLightbox( + photoUri = state.photoUri, + contentDescription = state.record?.title, + onDismiss = { showLightbox = false } + ) + } } @Composable private fun DetailContent( state: DateRecordDetailUiState, + onPhotoClick: () -> Unit, modifier: Modifier = Modifier ) { val record = state.record ?: return @@ -183,6 +196,11 @@ private fun DetailContent( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onPhotoClick + ) ) Column(modifier = Modifier.padding(horizontal = 16.dp)) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a513813..62b1a53 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ sketch = "4.4.0" spotless = "8.8.0" tyme4kt = "1.5.0" versions = "0.54.0" +zoomimage = "1.6.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } @@ -57,6 +58,7 @@ room-testing = { module = "androidx.room:room-testing", version.ref = "room" } sketch-animated-webp = { module = "io.github.panpf.sketch4:sketch-animated-webp", version.ref = "sketch" } sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "sketch" } tyme4kt = { module = "cn.6tail:tyme4kt", version.ref = "tyme4kt" } +zoomimage-compose-sketch4 = { module = "io.github.panpf.zoomimage:zoomimage-compose-sketch4", version.ref = "zoomimage" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From b8509381d9285a91deafacffe4b9dbf325ab79cf Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 22 Jul 2026 14:34:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(photo-editor):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=A3=81=E5=89=AA=20tab=20=E6=B5=81=E7=A8=8B=E4=B8=8E=E8=A3=81?= =?UTF-8?q?=E5=89=AA=E6=A1=86=E6=8B=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个问题: 1. 切到裁剪 tab 后还要再点裁剪 icon 才进入裁剪——改为切 tab 即自动进入, 底部按钮由"开关裁剪"改为"✓ 确认裁剪",点击烘焙裁剪并保留在 tab。 2. 裁剪框无法移动——重写 CropOverlay 拖动逻辑: - 加 BODY 整体平移(拖框内区域平移整框) - onDrag 每帧从 state 读实时裁剪值叠加增量,修复闭包快照导致的抖动 - dragHandle 内聚到组件内部,消除跨重组延迟 - 抽出 hitHandle/moveCrop,四角与整体均带边界约束防交叉越界 ViewModel 新增 enterCrop/exitCrop/applyCrop,删除 toggleCrop。 --- .../plus/rua/project/PhotoEditorViewModel.kt | 43 ++++- .../plus/rua/project/ui/PhotoEditorScreen.kt | 174 +++++++++++++----- 2 files changed, 169 insertions(+), 48 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt index 94f9f03..3c626d7 100644 --- a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt @@ -80,14 +80,43 @@ class PhotoEditorViewModel( update { it.copy(rotationDegrees = it.rotationDegrees + delta) } } - /** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */ - fun toggleCrop() { + /** 进入裁剪模式:启用裁剪框并重置为默认居中区域。 */ + fun enterCrop() { update { - if (it.cropEnabled) { - 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) - } + 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() + ) } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt index 71608e2..9c3325f 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.ChevronLeft 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.RotateLeft 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.setValue import androidx.compose.ui.Alignment +import kotlin.math.absoluteValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -95,6 +95,14 @@ fun PhotoEditorScreen( var activeTab by remember { mutableStateOf(EditTab.ROTATE) } val savedPath = state.savedPath + // 切到裁剪 tab 自动进入裁剪模式;切走则退出(不应用裁剪) + LaunchedEffect(activeTab, state.editorState) { + when (activeTab) { + EditTab.CROP -> viewModel.enterCrop() + else -> viewModel.exitCrop() + } + } + if (savedPath != null) { LaunchedEffect(savedPath) { onSaved(savedPath) } } @@ -157,7 +165,7 @@ fun PhotoEditorScreen( activeTab = activeTab, onTabChange = { activeTab = it }, onRotate = viewModel::rotate, - onCropToggle = viewModel::toggleCrop, + onApplyCrop = viewModel::applyCrop, onCropChange = viewModel::updateCrop, onAddPoint = viewModel::addStrokePoint, onEndStroke = viewModel::endStroke, @@ -181,7 +189,7 @@ private fun EditorBody( activeTab: EditTab, onTabChange: (EditTab) -> Unit, onRotate: (Int) -> Unit, - onCropToggle: () -> Unit, + onApplyCrop: () -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit, onAddPoint: (Offset) -> Unit, onEndStroke: () -> Unit, @@ -245,7 +253,7 @@ private fun EditorBody( mode = activeTab, state = state, onRotate = onRotate, - onCropToggle = onCropToggle, + onApplyCrop = onApplyCrop, onUndoStroke = onUndoStroke, onStrokeColorChange = onStrokeColorChange, modifier = Modifier @@ -265,8 +273,6 @@ private fun EditableImage( onDisplaySizeChange: (Float, Float) -> Unit ) { val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap } - // 裁剪框拖动手柄(CROP 模式下使用) - var dragHandle by remember { mutableStateOf(null) } Box( modifier = Modifier @@ -299,8 +305,6 @@ private fun EditableImage( if (mode == EditTab.CROP && state.cropEnabled) { CropOverlay( state = state, - dragHandle = dragHandle, - onHandleChange = { dragHandle = it }, onCropChange = onCropChange, 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 回传。 */ private data class CropRect( @@ -334,16 +338,110 @@ private data class CropRect( 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 private fun CropOverlay( state: PhotoEditorState, - dragHandle: CropHandle?, - onHandleChange: (CropHandle?) -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit, modifier: Modifier = Modifier ) { + // 拖动手柄内聚在此组件,避免跨重组延迟 + var dragHandle by remember { mutableStateOf(CropHandle.NONE) } val left = state.cropLeft ?: 0.1f + val top = state.cropTop val right = state.cropRight ?: 0.9f + val bottom = state.cropBottom Canvas( modifier = modifier.pointerInput(Unit) { detectDragGestures( @@ -351,45 +449,42 @@ private fun CropOverlay( val size = this.size val ox = offset.x / size.width val oy = offset.y / size.height - onHandleChange( - when { - ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT - ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT - ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT - ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT - else -> CropHandle.NONE - } + dragHandle = hitHandle( + ox, oy, + state.cropLeft ?: 0.1f, + state.cropTop, + state.cropRight ?: 0.9f, + state.cropBottom ) }, onDrag = { change, drag -> + if (dragHandle == CropHandle.NONE) return@detectDragGestures change.consume() val size = this.size val dx = drag.x / size.width val dy = drag.y / size.height - val (nL, nT, nR, nB) = when (dragHandle) { - CropHandle.TOP_LEFT -> - CropRect(left + dx, state.cropTop + dy, right, state.cropBottom) - CropHandle.TOP_RIGHT -> - CropRect(left, state.cropTop + dy, right + dx, state.cropBottom) - CropHandle.BOTTOM_LEFT -> - CropRect(left + dx, state.cropTop, right, state.cropBottom + dy) - CropHandle.BOTTOM_RIGHT -> - CropRect(left, state.cropTop, right + dx, state.cropBottom + dy) - else -> return@detectDragGestures - } - onCropChange(nL, nT, nR, nB) + // 每帧从 state 读最新值,叠加增量 + val cur = moveCrop( + dx, dy, + state.cropLeft ?: 0.1f, + state.cropTop, + state.cropRight ?: 0.9f, + state.cropBottom, + dragHandle + ) + onCropChange(cur.left, cur.top, cur.right, cur.bottom) }, - onDragEnd = { onHandleChange(null) }, - onDragCancel = { onHandleChange(null) } + onDragEnd = { dragHandle = CropHandle.NONE }, + onDragCancel = { dragHandle = CropHandle.NONE } ) } ) { val w = size.width val h = size.height val l = left * w - val t = state.cropTop * h + val t = top * h 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(r, 0f), size = Size(w - r, h)) @@ -410,7 +505,7 @@ private fun ToolBar( mode: EditTab, state: PhotoEditorState, onRotate: (Int) -> Unit, - onCropToggle: () -> Unit, + onApplyCrop: () -> Unit, onUndoStroke: () -> Unit, onStrokeColorChange: (Color) -> Unit, modifier: Modifier = Modifier @@ -430,11 +525,8 @@ private fun ToolBar( } } EditTab.CROP -> { - FilledIconButton(onClick = onCropToggle) { - Icon( - if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop, - contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪" - ) + FilledIconButton(onClick = onApplyCrop) { + Icon(Icons.Filled.Done, contentDescription = "确认裁剪") } } EditTab.HANDWRITE -> { From d3ebd6829b40ea51bd2ecb8244314f5340cf50fb Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 22 Jul 2026 15:18:19 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(photo-editor):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E6=A1=86=E6=8B=96=E5=8A=A8=E7=9A=84=E9=97=AD?= =?UTF-8?q?=E5=8C=85=E5=BF=AB=E7=85=A7=E9=99=B7=E9=98=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pointerInput(Unit) 的手势 lambda 只在首次组合捕获一次 state, 后续 onDragStart 读的是过期快照,导致再次拖动从中心开始。 改用本地累积值绕开 StateFlow 往返:onDragStart 把 state 快照到 mutableFloatStateOf,onDrag 每帧在累积值上叠加增量,onDragEnd 一次性提交。onDragStart 通过 rememberUpdatedState 读最新 state。 enterCrop 改为幂等(已启用则保留当前框),避免 LaunchedEffect 因 editorState 变化重触时冲掉用户拖动结果。 --- .../plus/rua/project/PhotoEditorViewModel.kt | 5 +- .../plus/rua/project/ui/PhotoEditorScreen.kt | 63 ++++++++++++------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt index 3c626d7..80431dc 100644 --- a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt @@ -80,10 +80,11 @@ class PhotoEditorViewModel( update { it.copy(rotationDegrees = it.rotationDegrees + delta) } } - /** 进入裁剪模式:启用裁剪框并重置为默认居中区域。 */ + /** 进入裁剪模式:仅在尚未启用时初始化为默认居中区域,已启用则保留当前框。 */ fun enterCrop() { update { - it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f) + if (it.cropEnabled) it + else it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f) } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt index 9c3325f..33fb1a4 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt @@ -40,8 +40,10 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import kotlin.math.absoluteValue @@ -436,12 +438,22 @@ private fun CropOverlay( onCropChange: (Float, Float, Float, Float) -> Unit, modifier: Modifier = Modifier ) { - // 拖动手柄内聚在此组件,避免跨重组延迟 var dragHandle by remember { mutableStateOf(CropHandle.NONE) } - val left = state.cropLeft ?: 0.1f - val top = state.cropTop - val right = state.cropRight ?: 0.9f - val bottom = state.cropBottom + // 拖动期间的本地累积值;非拖动时为 null,绘制回退到 state + var dragLeft by remember { mutableFloatStateOf(0f) } + var dragTop by remember { mutableFloatStateOf(0f) } + var dragRight by remember { mutableFloatStateOf(0f) } + var dragBottom by remember { mutableFloatStateOf(0f) } + // pointerInput(Unit) 只捕获一次 state,用 rememberUpdatedState 让 + // 手势 lambda 内始终读到最新的 state(含上次拖动提交后的值) + val currentState by rememberUpdatedState(state) + + // 绘制用值:拖动中取本地累积,否则取 state + val left = if (dragHandle != CropHandle.NONE) dragLeft else (state.cropLeft ?: 0.1f) + val top = if (dragHandle != CropHandle.NONE) dragTop else state.cropTop + val right = if (dragHandle != CropHandle.NONE) dragRight else (state.cropRight ?: 0.9f) + val bottom = if (dragHandle != CropHandle.NONE) dragBottom else state.cropBottom + Canvas( modifier = modifier.pointerInput(Unit) { detectDragGestures( @@ -449,13 +461,16 @@ private fun CropOverlay( val size = this.size val ox = offset.x / size.width val oy = offset.y / size.height - dragHandle = hitHandle( - ox, oy, - state.cropLeft ?: 0.1f, - state.cropTop, - state.cropRight ?: 0.9f, - state.cropBottom - ) + val baseL = currentState.cropLeft ?: 0.1f + val baseT = currentState.cropTop + val baseR = currentState.cropRight ?: 0.9f + val baseB = currentState.cropBottom + // 进入拖动:把 state 当前值快照到本地累积 + dragLeft = baseL + dragTop = baseT + dragRight = baseR + dragBottom = baseB + dragHandle = hitHandle(ox, oy, baseL, baseT, baseR, baseB) }, onDrag = { change, drag -> if (dragHandle == CropHandle.NONE) return@detectDragGestures @@ -463,18 +478,20 @@ private fun CropOverlay( val size = this.size val dx = drag.x / size.width val dy = drag.y / size.height - // 每帧从 state 读最新值,叠加增量 - val cur = moveCrop( - dx, dy, - state.cropLeft ?: 0.1f, - state.cropTop, - state.cropRight ?: 0.9f, - state.cropBottom, - dragHandle - ) - onCropChange(cur.left, cur.top, cur.right, cur.bottom) + // 在本地累积值上叠加增量,不触发 StateFlow 往返 + val cur = moveCrop(dx, dy, dragLeft, dragTop, dragRight, dragBottom, dragHandle) + dragLeft = cur.left + dragTop = cur.top + dragRight = cur.right + dragBottom = cur.bottom + }, + onDragEnd = { + // 拖动结束:一次性提交回 ViewModel + if (dragHandle != CropHandle.NONE) { + onCropChange(dragLeft, dragTop, dragRight, dragBottom) + } + dragHandle = CropHandle.NONE }, - onDragEnd = { dragHandle = CropHandle.NONE }, onDragCancel = { dragHandle = CropHandle.NONE } ) } From 70c97ed9d523ebe6558d58c86f5ec86a0667da78 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 22 Jul 2026 15:20:13 +0800 Subject: [PATCH 4/4] =?UTF-8?q?revert(photo-editor):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E8=A3=81=E5=89=AA=20icon=20=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=E8=A3=81=E5=89=AA=E7=9A=84=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 撤销"切到裁剪 tab 自动进入裁剪"的改动,保留原来的两步操作: 点击下方裁剪 icon 进入裁剪模式,再次点击 ✓ 确认裁剪。 ViewModel 恢复 toggleCrop,删除无用的 enterCrop/exitCrop; 保留裁剪框拖动的 bug 修复(本地累积值 + rememberUpdatedState)。 --- .../plus/rua/project/PhotoEditorViewModel.kt | 18 +++++++---------- .../plus/rua/project/ui/PhotoEditorScreen.kt | 20 ++++++++++--------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt index 80431dc..71c3a48 100644 --- a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt @@ -80,18 +80,14 @@ class PhotoEditorViewModel( update { it.copy(rotationDegrees = it.rotationDegrees + delta) } } - /** 进入裁剪模式:仅在尚未启用时初始化为默认居中区域,已启用则保留当前框。 */ - fun enterCrop() { + /** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */ + fun toggleCrop() { update { - if (it.cropEnabled) it - 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) + if (it.cropEnabled) { + 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) + } } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt index 33fb1a4..a68e8c4 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt @@ -97,14 +97,6 @@ fun PhotoEditorScreen( var activeTab by remember { mutableStateOf(EditTab.ROTATE) } val savedPath = state.savedPath - // 切到裁剪 tab 自动进入裁剪模式;切走则退出(不应用裁剪) - LaunchedEffect(activeTab, state.editorState) { - when (activeTab) { - EditTab.CROP -> viewModel.enterCrop() - else -> viewModel.exitCrop() - } - } - if (savedPath != null) { LaunchedEffect(savedPath) { onSaved(savedPath) } } @@ -167,6 +159,7 @@ fun PhotoEditorScreen( activeTab = activeTab, onTabChange = { activeTab = it }, onRotate = viewModel::rotate, + onCropToggle = viewModel::toggleCrop, onApplyCrop = viewModel::applyCrop, onCropChange = viewModel::updateCrop, onAddPoint = viewModel::addStrokePoint, @@ -191,6 +184,7 @@ private fun EditorBody( activeTab: EditTab, onTabChange: (EditTab) -> Unit, onRotate: (Int) -> Unit, + onCropToggle: () -> Unit, onApplyCrop: () -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit, onAddPoint: (Offset) -> Unit, @@ -255,6 +249,7 @@ private fun EditorBody( mode = activeTab, state = state, onRotate = onRotate, + onCropToggle = onCropToggle, onApplyCrop = onApplyCrop, onUndoStroke = onUndoStroke, onStrokeColorChange = onStrokeColorChange, @@ -522,6 +517,7 @@ private fun ToolBar( mode: EditTab, state: PhotoEditorState, onRotate: (Int) -> Unit, + onCropToggle: () -> Unit, onApplyCrop: () -> Unit, onUndoStroke: () -> Unit, onStrokeColorChange: (Color) -> Unit, @@ -541,10 +537,16 @@ private fun ToolBar( Icon(Icons.Filled.RotateRight, contentDescription = "右转 90°") } } - EditTab.CROP -> { + EditTab.CROP -> if (state.cropEnabled) { + // 已进入裁剪模式:✓ 确认裁剪(烘焙到源图) FilledIconButton(onClick = onApplyCrop) { Icon(Icons.Filled.Done, contentDescription = "确认裁剪") } + } else { + // 未进入:点击进入裁剪模式 + FilledIconButton(onClick = onCropToggle) { + Icon(Icons.Filled.Crop, contentDescription = "进入裁剪") + } } EditTab.HANDWRITE -> { IconButton(onClick = onUndoStroke, enabled = state.strokes.isNotEmpty()) {