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:
parent
c39cecf343
commit
f9e54f36fe
@ -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] 后仍完整覆盖容器」所需的最小缩放因子。
|
||||
*
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user