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
|
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.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@ -46,7 +50,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
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
|
||||||
@ -55,8 +58,10 @@ import androidx.compose.ui.graphics.StrokeCap
|
|||||||
import androidx.compose.ui.graphics.StrokeJoin
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
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.compose.viewModel
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
import plus.rua.project.PhotoEditorState
|
import plus.rua.project.PhotoEditorState
|
||||||
import plus.rua.project.PhotoEditorViewModel
|
import plus.rua.project.PhotoEditorViewModel
|
||||||
|
import plus.rua.project.RotationGeometry
|
||||||
import plus.rua.project.toPath
|
import plus.rua.project.toPath
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -269,19 +276,61 @@ private fun EditableImage(
|
|||||||
onEndStroke: () -> Unit,
|
onEndStroke: () -> Unit,
|
||||||
onDisplaySizeChange: (Float, Float) -> 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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(rotatedBmp.width.toFloat() / rotatedBmp.height.toFloat())
|
.aspectRatio(containerAspect)
|
||||||
.background(Color.Black)
|
.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(
|
Image(
|
||||||
bitmap = rotatedBmp.asImageBitmap(),
|
bitmap = rotatedBmp.asImageBitmap(),
|
||||||
contentDescription = "编辑中的照片",
|
contentDescription = "编辑中的照片",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
rotationZ = angle
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.pointerInput(mode) {
|
.pointerInput(mode) {
|
||||||
if (mode == EditTab.HANDWRITE) {
|
if (mode == EditTab.HANDWRITE) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
@ -295,10 +344,10 @@ private fun EditableImage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentScale = ContentScale.Fit
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
|
|
||||||
// 裁剪框覆盖层
|
// 裁剪框覆盖层(基于数据层 rotationDegrees 目标态坐标系)
|
||||||
if (mode == EditTab.CROP && state.cropEnabled) {
|
if (mode == EditTab.CROP && state.cropEnabled) {
|
||||||
CropOverlay(
|
CropOverlay(
|
||||||
state = state,
|
state = state,
|
||||||
@ -307,9 +356,8 @@ private fun EditableImage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手写笔触覆盖层 + 同步显示尺寸
|
// 手写笔触覆盖层
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
onDisplaySizeChange(size.width, size.height)
|
|
||||||
state.strokes.forEach { stroke ->
|
state.strokes.forEach { stroke ->
|
||||||
drawPath(
|
drawPath(
|
||||||
path = stroke.toPath(),
|
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