feat(photo-editor): 旋转图片时加平滑动画,裁剪填满零黑边
- 旋转角用 Animatable + tween(300ms) 平滑过渡,容器宽高比用 animateFloatAsState 在目标稳态比例间过渡,消除硬切 - 黑边根因:旋转矩形填不满自身 AABB 的四角(45° 时黑色占 51%)。 用户选「裁剪填满」方案:图片显示 rotatedBitmap(目标态), graphicsLayer 旋转 + coverScale 放大使四角覆盖容器 → 零黑边 - 提取旋转几何为纯函数 RotationGeometry(无 Android 依赖): stableAspect + coverScale,便于单测 - 回归测试 RotationGeometryTest 锁住「任意角度旋转图片四角 覆盖容器四角」断言(maxViolation <= 1e-4),防止黑边回归 - 稳态(offset=0) scale=1 零裁剪;动画中(±45°) scale≈1.65 裁边缘 保存逻辑未变:PhotoProcessor.render 仍用 rotationDegrees 烘焙。
This commit is contained in:
parent
ca00839c9b
commit
b6146a29ae
50
core/src/main/kotlin/plus/rua/project/RotationGeometry.kt
Normal file
50
core/src/main/kotlin/plus/rua/project/RotationGeometry.kt
Normal file
@ -0,0 +1,50 @@
|
||||
package plus.rua.project
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* 图片旋转显示几何计算(纯函数,无 Android 依赖,可单测)。
|
||||
*
|
||||
* 解决「旋转动画中容器露黑边」问题的数学核心:旋转矩形无法填满它自己的轴对齐
|
||||
* 外接矩形(AABB)的四个角,因此若图片按 AABB 缩放必留 4 个黑三角。本对象提供
|
||||
* 两个公式,让图片在任意角度都恰好覆盖容器(零黑边),代价是动画中斜角处的裁剪。
|
||||
*/
|
||||
object RotationGeometry {
|
||||
|
||||
/**
|
||||
* 目标稳态下容器的宽高比。
|
||||
*
|
||||
* 旋转 90° 的倍数后,图片宽高在 0/180° 与 90/270° 间互换。
|
||||
*
|
||||
* @param srcAspect 源图宽高比(宽/高)
|
||||
* @param rotationDegrees 累计旋转角,90 的倍数
|
||||
* @return 该稳态角度下容器的宽高比
|
||||
*/
|
||||
fun stableAspect(srcAspect: Float, rotationDegrees: Int): Float =
|
||||
if (rotationDegrees % 180 == 0) srcAspect else 1f / srcAspect
|
||||
|
||||
/**
|
||||
* 让「与容器同比例的图片矩形旋转 [offsetDegrees] 后仍完整覆盖容器」所需的最小缩放因子。
|
||||
*
|
||||
* 推导:图片 base 矩形 = 容器尺寸(宽 [aspect]、高 1,归一化)。绕中心旋转 θ 后,
|
||||
* 容器四角(±aspect/2, ±1/2)反旋转到图片坐标系,必须落在缩放后图片的半尺寸内。
|
||||
* 解得最小缩放:
|
||||
* ```
|
||||
* k = max( |cosθ| + |sinθ|/aspect, |sinθ|·aspect + |cosθ| )
|
||||
* ```
|
||||
* - θ = 0(稳态)→ k = 1(零裁剪零黑边)
|
||||
* - θ = ±45°(旋转中点)→ k 最大(斜角处裁剪最多,但零黑边)
|
||||
*
|
||||
* @param offsetDegrees 图片当前显示角度相对目标稳态的偏移;稳态为 0
|
||||
* @param aspect 容器宽高比
|
||||
* @return 最小覆盖缩放因子,恒 >= 1
|
||||
*/
|
||||
fun coverScale(offsetDegrees: Float, aspect: Float): Float {
|
||||
val rad = Math.toRadians(offsetDegrees.toDouble())
|
||||
val c = abs(cos(rad)).toFloat()
|
||||
val s = abs(sin(rad)).toFloat()
|
||||
return maxOf(c + s / aspect, s * aspect + c)
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
package plus.rua.project.ui
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@ -46,7 +50,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
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
|
||||
@ -55,8 +58,10 @@ import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
@ -66,8 +71,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import kotlin.math.absoluteValue
|
||||
import plus.rua.project.PhotoEditorState
|
||||
import plus.rua.project.PhotoEditorViewModel
|
||||
import plus.rua.project.RotationGeometry
|
||||
import plus.rua.project.toPath
|
||||
|
||||
/**
|
||||
@ -269,19 +276,61 @@ private fun EditableImage(
|
||||
onEndStroke: () -> Unit,
|
||||
onDisplaySizeChange: (Float, Float) -> Unit
|
||||
) {
|
||||
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap }
|
||||
// 视觉旋转角:从当前显示值平滑动画到数据层目标 rotationDegrees。
|
||||
// rotationDegrees 是最终态(保存/裁剪用),这里只负责"看起来"的旋转过渡。
|
||||
// 初值取当前 rotationDegrees,避免首次组合闪动。
|
||||
val displayRotation = remember(state.sourceBitmap) {
|
||||
Animatable(state.rotationDegrees.toFloat())
|
||||
}
|
||||
LaunchedEffect(state.rotationDegrees) {
|
||||
displayRotation.animateTo(
|
||||
targetValue = state.rotationDegrees.toFloat(),
|
||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
val srcAspect = state.sourceBitmap.width.toFloat() / state.sourceBitmap.height.toFloat()
|
||||
// 容器宽高比 = 目标稳态比例(0/180°=原图比,90/270°=倒数),随 rotationDegrees 平滑过渡。
|
||||
// 不随中间旋转角变形,避免旋转中容器尺寸抖动。
|
||||
val containerAspect by animateFloatAsState(
|
||||
targetValue = RotationGeometry.stableAspect(srcAspect, state.rotationDegrees),
|
||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
||||
label = "rotateAspect"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(rotatedBmp.width.toFloat() / rotatedBmp.height.toFloat())
|
||||
.aspectRatio(containerAspect)
|
||||
.background(Color.Black)
|
||||
// 尺寸上报放布局阶段:仅在尺寸真正变化时回调一次,
|
||||
// 避免在 draw 阶段每帧写 StateFlow 导致重组风暴。
|
||||
.onSizeChanged { size ->
|
||||
if (size.width > 0 && size.height > 0) {
|
||||
onDisplaySizeChange(size.width.toFloat(), size.height.toFloat())
|
||||
}
|
||||
}
|
||||
) {
|
||||
// 显示已旋转到目标态的 bitmap(比例 = 容器比例,稳态时填满无黑边无裁剪)。
|
||||
// 动画中:图片相对目标态多转 (displayRotation - rotationDegrees),
|
||||
// 用 coverScale 放大使旋转后图片四角仍覆盖容器 → 零黑边(几何见 RotationGeometry)。
|
||||
// 把动画值捕获到局部 val,确保组合算的 offset 与 draw 的 rotationZ 同帧。
|
||||
val angle = displayRotation.value
|
||||
val offset = angle - state.rotationDegrees
|
||||
val scale = RotationGeometry.coverScale(offset, containerAspect)
|
||||
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) {
|
||||
state.rotatedBitmap
|
||||
}
|
||||
Image(
|
||||
bitmap = rotatedBmp.asImageBitmap(),
|
||||
contentDescription = "编辑中的照片",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
rotationZ = angle
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.pointerInput(mode) {
|
||||
if (mode == EditTab.HANDWRITE) {
|
||||
detectDragGestures(
|
||||
@ -295,10 +344,10 @@ private fun EditableImage(
|
||||
)
|
||||
}
|
||||
},
|
||||
contentScale = ContentScale.Fit
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 裁剪框覆盖层
|
||||
// 裁剪框覆盖层(基于数据层 rotationDegrees 目标态坐标系)
|
||||
if (mode == EditTab.CROP && state.cropEnabled) {
|
||||
CropOverlay(
|
||||
state = state,
|
||||
@ -307,9 +356,8 @@ private fun EditableImage(
|
||||
)
|
||||
}
|
||||
|
||||
// 手写笔触覆盖层 + 同步显示尺寸
|
||||
// 手写笔触覆盖层
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
onDisplaySizeChange(size.width, size.height)
|
||||
state.strokes.forEach { stroke ->
|
||||
drawPath(
|
||||
path = stroke.toPath(),
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
package plus.rua.project
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* [RotationGeometry] 单元测试。
|
||||
*
|
||||
* 核心回归断言:用 [RotationGeometry.coverScale] 缩放后,旋转图片的四个角
|
||||
* 在任意偏移角度下都覆盖容器四角 —— 即「零黑边」。这是「旋转中容器露黑边」
|
||||
* bug 的几何重现与防护:缩放因子不足时角点会落进容器内 → 容器角露黑。
|
||||
*/
|
||||
class RotationGeometryTest {
|
||||
|
||||
@Test
|
||||
fun stableAspect_noRotation_returnsSourceAspect() {
|
||||
assertEquals(1.5f, RotationGeometry.stableAspect(1.5f, 0))
|
||||
assertEquals(1.5f, RotationGeometry.stableAspect(1.5f, 180))
|
||||
assertEquals(1.5f, RotationGeometry.stableAspect(1.5f, 360))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stableAspect_90Degrees_invertsAspect() {
|
||||
// 4:3 横图 → 3:4 竖向
|
||||
assertEquals(0.75f, RotationGeometry.stableAspect(1.3333f, 90), 1e-3f)
|
||||
assertEquals(0.75f, RotationGeometry.stableAspect(1.3333f, 270), 1e-3f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coverScale_atStableOffset_isOne() {
|
||||
// 稳态(offset=0)零裁剪零黑边
|
||||
listOf(0.5f, 0.75f, 1.0f, 1.3333f, 2.0f).forEach { aspect ->
|
||||
assertEquals(1.0f, RotationGeometry.coverScale(0f, aspect), 1e-5f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coverScale_duringAnimation_isAtLeastOne() {
|
||||
// 动画中任意偏移都 >= 1(裁剪填满,不缩出黑边)
|
||||
val aspect = 0.75f
|
||||
for (offset in -89..89) {
|
||||
val k = RotationGeometry.coverScale(offset.toFloat(), aspect)
|
||||
assertTrue(k >= 1.0f, "offset=$offset scale=$k 应 >= 1")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归核心:缩放后旋转的图片四角必须覆盖容器四角,否则容器角露黑。
|
||||
*
|
||||
* 模型:图片 base = 容器矩形(宽 aspect、高 1,居中于原点),先放大 k 倍,
|
||||
* 再旋转 offset。容器四角(±aspect/2, ±1/2)反旋转到图片坐标后,
|
||||
* 必须落在缩放图片的半尺寸(±k·aspect/2, ±1/2)内。
|
||||
*/
|
||||
@Test
|
||||
fun coverScale_rotatedImageCornersCoverContainer_noBlackAtAnyAngle() {
|
||||
val aspects = listOf(0.5f, 0.75f, 1.0f, 1.3333f, 2.0f, 3.0f)
|
||||
val maxViolation = aspects.maxOf { aspect ->
|
||||
(-90..90).maxOf { offsetDeg ->
|
||||
val offset = offsetDeg.toFloat()
|
||||
val k = RotationGeometry.coverScale(offset, aspect)
|
||||
val rad = offset * PI.toFloat() / 180f
|
||||
val ca = cos(rad); val sa = sin(rad)
|
||||
// 容器四角反旋转到图片坐标系,与缩放图片半尺寸比较(>0 表示露出黑边)
|
||||
listOf(
|
||||
aspect / 2 to 0.5f,
|
||||
aspect / 2 to -0.5f,
|
||||
-aspect / 2 to 0.5f,
|
||||
-aspect / 2 to -0.5f
|
||||
).maxOf { (x, y) ->
|
||||
val xp = x * ca + y * sa
|
||||
val yp = -x * sa + y * ca
|
||||
maxOf(abs(xp) - k * aspect / 2, abs(yp) - k * 0.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无任何角点超出缩放图片 → 无黑边
|
||||
assertTrue(maxViolation <= 1e-4f, "存在角点露出黑边,最大越界=$maxViolation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coverScale_symmetricAroundZero() {
|
||||
// 正负偏移对称(旋转方向不影响裁剪量)
|
||||
val aspect = 1.3333f
|
||||
for (offset in 1..89) {
|
||||
assertEquals(
|
||||
RotationGeometry.coverScale(offset.toFloat(), aspect),
|
||||
RotationGeometry.coverScale(-offset.toFloat(), aspect),
|
||||
1e-5f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user