refactor(photo-editor): 重构照片编辑旋转体验,干掉外框卡片实现全视口自适应无缝旋转

- 干掉 Card 容器:彻底移除了带 1.dp 灰色 Border、20.dp 圆角黑底及 4.dp 阴影的外层 Card 盒,解除照片被固定容器包裹旋转的视觉限制。
- 视口自适应包围盒算法:在 RotationGeometry 中新增 calculateLayoutSize 纯函数,按任意旋转角度动态计算照片 AABB 包围盒适配合理布局大小,保证 ContentScale.Fit 内部 0 留白 Padding 且 0 黑边。
- 重构 EditableImage:照片直接在全屏视口中心做平滑自适应旋转,手写 Canvas 与裁剪框精准挂载在稳态照片视觉 Bounds 上。
- 补充 RotationGeometryTest 针对 calculateLayoutSize 视口包围盒自适应的单元测试。
This commit is contained in:
xfy 2026-07-24 11:12:19 +08:00
parent c39cecf343
commit f9e54f36fe
3 changed files with 125 additions and 69 deletions

View File

@ -45,6 +45,36 @@ object RotationGeometry {
fun isSwapped(baseRotation: Int): Boolean =
baseRotation % 180 != 0
/**
* 原始宽高比为 [srcAspect] 的照片在旋转 [angleDegrees] 角度时
* 包围盒AABB适应视口所需的未旋转 Layout 布局尺寸 (Width, Height)
*
* 该尺寸的宽高比恒等于 [srcAspect]保证 ContentScale.Fit 内部 0 留白 Padding
* 且旋转后的包围盒平滑居中适应视口无任何 Card 卡片容器遮挡与黑边
*/
fun calculateLayoutSize(
srcAspect: Float,
angleDegrees: Float,
viewportWidth: Float,
viewportHeight: Float
): Pair<Float, Float> {
if (viewportWidth <= 0f || viewportHeight <= 0f || srcAspect <= 0f) {
return Pair(0f, 0f)
}
val rad = Math.toRadians(angleDegrees.toDouble())
val c = abs(cos(rad)).toFloat()
val s = abs(sin(rad)).toFloat()
// 归一化 AABB 包围盒尺寸 (baseWidth = srcAspect, baseHeight = 1.0)
val wAabb = srcAspect * c + 1.0f * s
val hAabb = 1.0f * c + srcAspect * s
// 适应视口的整体缩放系数
val k = minOf(viewportWidth / wAabb, viewportHeight / hAabb)
return Pair(srcAspect * k, 1.0f * k)
}
/**
* 与容器同比例的图片矩形旋转 [offsetDegrees] 后仍完整覆盖容器所需的最小缩放因子
*

View File

@ -19,6 +19,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -31,6 +32,8 @@ 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.ui.draw.clipToBounds
import kotlin.math.roundToInt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
@ -273,34 +276,18 @@ private fun EditorBody(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp),
.padding(16.dp)
.clipToBounds(),
contentAlignment = Alignment.Center
) {
val srcAspect = state.sourceBitmap.width.toFloat() / state.sourceBitmap.height.toFloat()
val containerAspect by animateFloatAsState(
targetValue = RotationGeometry.stableAspect(srcAspect, state.rotationDegrees),
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
label = "rotateAspect"
EditableImage(
state = state,
mode = activeTab,
onCropChange = onCropChange,
onAddPoint = onAddPoint,
onEndStroke = onEndStroke,
onDisplaySizeChange = onDisplaySizeChange
)
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.Black),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
modifier = Modifier.aspectRatio(containerAspect)
) {
EditableImage(
state = state,
mode = activeTab,
containerAspect = containerAspect,
onCropChange = onCropChange,
onAddPoint = onAddPoint,
onEndStroke = onEndStroke,
onDisplaySizeChange = onDisplaySizeChange
)
}
if (saving) {
Surface(
color = Color.Black.copy(alpha = 0.7f),
@ -353,7 +340,6 @@ private fun EditorBody(
private fun EditableImage(
state: PhotoEditorState,
mode: EditTab,
containerAspect: Float,
onCropChange: (Float, Float, Float, Float) -> Unit,
onAddPoint: (Offset) -> Unit,
onEndStroke: () -> Unit,
@ -369,41 +355,46 @@ private fun EditableImage(
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.onSizeChanged { size ->
if (size.width > 0 && size.height > 0) {
onDisplaySizeChange(size.width.toFloat(), size.height.toFloat())
}
}
val srcAspect = state.sourceBitmap.width.toFloat() / state.sourceBitmap.height.toFloat()
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val viewportWidth = constraints.maxWidth.toFloat()
val viewportHeight = constraints.maxHeight.toFloat()
val angle = displayRotation.value
val baseRotation = RotationGeometry.baseRotation(angle)
val isSwapped = RotationGeometry.isSwapped(baseRotation)
val offset = RotationGeometry.offsetDegrees(angle)
val scale = RotationGeometry.coverScale(offset, containerAspect)
val (layoutWidth, layoutHeight) = remember(srcAspect, angle, viewportWidth, viewportHeight) {
RotationGeometry.calculateLayoutSize(srcAspect, angle, viewportWidth, viewportHeight)
}
// 稳态旋转下的真实照片显示尺寸(供 ViewModel / 笔触映射使用)
val isSteadySwapped = (state.rotationDegrees % 180 != 0)
val steadyWidth = if (isSteadySwapped) layoutHeight else layoutWidth
val steadyHeight = if (isSteadySwapped) layoutWidth else layoutHeight
LaunchedEffect(steadyWidth, steadyHeight) {
if (steadyWidth > 0f && steadyHeight > 0f) {
onDisplaySizeChange(steadyWidth, steadyHeight)
}
}
// 1. 照片本体:居中平滑旋转自适应
Image(
bitmap = state.sourceBitmap.asImageBitmap(),
contentDescription = "编辑中的照片",
modifier = Modifier
.layout { measurable, constraints ->
val targetWidth = if (isSwapped) constraints.maxHeight else constraints.maxWidth
val targetHeight = if (isSwapped) constraints.maxWidth else constraints.maxHeight
val placeable = measurable.measure(
Constraints.fixed(targetWidth, targetHeight)
)
layout(constraints.maxWidth, constraints.maxHeight) {
val x = (constraints.maxWidth - targetWidth) / 2
val y = (constraints.maxHeight - targetHeight) / 2
placeable.placeRelative(x, y)
.layout { measurable, _ ->
val w = layoutWidth.roundToInt()
val h = layoutHeight.roundToInt()
val placeable = measurable.measure(Constraints.fixed(w, h))
layout(w, h) {
placeable.place(0, 0)
}
}
.graphicsLayer {
rotationZ = angle
scaleX = scale
scaleY = scale
}
.pointerInput(mode) {
if (mode == EditTab.HANDWRITE) {
@ -421,26 +412,39 @@ private fun EditableImage(
contentScale = ContentScale.Fit
)
if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay(
state = state,
onCropChange = onCropChange,
modifier = Modifier.fillMaxSize()
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
state.strokes.forEach { stroke ->
drawPath(
path = stroke.toPath(),
color = stroke.color,
style = Stroke(
width = stroke.widthPx,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
// 2. 稳态 Overlay裁剪框 CropOverlay 与手写 Canvas 贴合在稳态照片尺寸上
Box(
modifier = Modifier
.layout { measurable, _ ->
val w = steadyWidth.roundToInt()
val h = steadyHeight.roundToInt()
val placeable = measurable.measure(Constraints.fixed(w, h))
layout(w, h) {
placeable.place(0, 0)
}
}
) {
if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay(
state = state,
onCropChange = onCropChange,
modifier = Modifier.fillMaxSize()
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
state.strokes.forEach { stroke ->
drawPath(
path = stroke.toPath(),
color = stroke.color,
style = Stroke(
width = stroke.widthPx,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}
}
}
}
}

View File

@ -131,4 +131,26 @@ class RotationGeometryTest {
assertEquals(-10f, RotationGeometry.offsetDegrees(80f), 1e-5f)
assertEquals(0f, RotationGeometry.offsetDegrees(90f), 1e-5f)
}
@Test
fun calculateLayoutSize_maintainsSourceAspect_and_fitsViewportAABB() {
val srcAspect = 1.3333f // 4:3 横图
val vWidth = 1000f
val vHeight = 1400f
// 0° 未旋转layout 宽 = 1000, 高 = 1000 / 1.3333 = 750
val (w0, h0) = RotationGeometry.calculateLayoutSize(srcAspect, 0f, vWidth, vHeight)
assertEquals(srcAspect, w0 / h0, 1e-3f)
assertEquals(1000f, w0, 1e-1f)
assertEquals(750f, h0, 1e-1f)
val (w90, h90) = RotationGeometry.calculateLayoutSize(srcAspect, 90f, vWidth, vHeight)
assertEquals(srcAspect, w90 / h90, 1e-3f)
assertEquals(1333.33f, w90, 1e-1f)
assertEquals(1000f, h90, 1e-1f)
// 视觉 AABB 包围盒不超过视口
val aabbWidth90 = h90
val aabbHeight90 = w90
assertTrue(aabbWidth90 <= vWidth + 1e-2f)
assertTrue(aabbHeight90 <= vHeight + 1e-2f)
}
}