tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt

301 lines
12 KiB
Kotlin
Raw Normal View History

package no.naiv.tiltshift.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurParameters
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/**
* Type of gesture being performed.
*/
private enum class GestureType {
NONE,
DRAG_POSITION, // Single finger drag to move focus center
ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch in center to zoom camera
}
// Sensitivity factor for size pinch (lower = less sensitive)
private const val SIZE_SENSITIVITY = 0.3f
private const val ZOOM_SENSITIVITY = 0.5f
/**
* Calculates the angle between two touch points.
*/
private fun angleBetweenPoints(p1: Offset, p2: Offset): Float {
return atan2(p2.y - p1.y, p2.x - p1.x)
}
/**
* Overlay that shows tilt-shift effect controls and handles gestures.
*/
@Composable
fun TiltShiftOverlay(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onZoomChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
var initialAngle by remember { mutableFloatStateOf(0f) }
var initialTouchAngle by remember { mutableFloatStateOf(0f) }
var initialSize by remember { mutableFloatStateOf(0.3f) }
var initialPositionX by remember { mutableFloatStateOf(0.5f) }
var initialPositionY by remember { mutableFloatStateOf(0.5f) }
Canvas(
modifier = modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
currentGesture = GestureType.NONE
var previousCentroid = firstDown.position
var accumulatedZoom = 1f
var rotationInitialized = false
initialAngle = params.angle
initialSize = params.size
initialPositionX = params.positionX
initialPositionY = params.positionY
do {
val event = awaitPointerEvent()
val pointers = event.changes.filter { it.pressed }
if (pointers.isEmpty()) break
val centroid = if (pointers.size >= 2) {
event.calculateCentroid()
} else {
pointers.first().position
}
when {
// Two or more fingers
pointers.size >= 2 -> {
val p1 = pointers[0].position
val p2 = pointers[1].position
val currentTouchAngle = angleBetweenPoints(p1, p2)
val zoom = event.calculateZoom()
// Determine gesture type based on touch positions
if (currentGesture == GestureType.NONE || currentGesture == GestureType.DRAG_POSITION) {
currentGesture = determineGestureType(
centroid,
size.width.toFloat(),
size.height.toFloat(),
params
)
rotationInitialized = false
}
when (currentGesture) {
GestureType.ROTATE -> {
// Initialize rotation reference on first frame
if (!rotationInitialized) {
initialTouchAngle = currentTouchAngle
initialAngle = params.angle
rotationInitialized = true
} else {
// Direct angle mapping: effect angle = initial + (current touch angle - initial touch angle)
val angleDelta = currentTouchAngle - initialTouchAngle
val newAngle = initialAngle + angleDelta
onParamsChange(params.copy(angle = newAngle))
}
}
GestureType.PINCH_SIZE -> {
val dampenedZoom = 1f + (zoom - 1f) * SIZE_SENSITIVITY
accumulatedZoom *= dampenedZoom
val newSize = (initialSize * accumulatedZoom)
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
onParamsChange(params.copy(size = newSize))
}
GestureType.PINCH_ZOOM -> {
val dampenedZoom = 1f + (zoom - 1f) * ZOOM_SENSITIVITY
onZoomChange(dampenedZoom)
}
else -> {}
}
}
// Single finger - drag to move focus center 1:1
pointers.size == 1 -> {
if (currentGesture == GestureType.NONE) {
currentGesture = GestureType.DRAG_POSITION
// Reset initial position at drag start
initialPositionX = params.positionX
initialPositionY = params.positionY
previousCentroid = centroid
}
if (currentGesture == GestureType.DRAG_POSITION) {
// 1:1 drag - finger movement directly maps to position change
val deltaX = (centroid.x - previousCentroid.x) / size.width
val deltaY = (centroid.y - previousCentroid.y) / size.height
val newX = (params.positionX + deltaX).coerceIn(0f, 1f)
val newY = (params.positionY + deltaY).coerceIn(0f, 1f)
onParamsChange(params.copy(positionX = newX, positionY = newY))
}
}
}
previousCentroid = centroid
// Consume all pointer changes
pointers.forEach { it.consume() }
} while (event.type != PointerEventType.Release)
currentGesture = GestureType.NONE
}
}
) {
drawTiltShiftOverlay(params)
}
}
/**
* Determines the type of two-finger gesture based on touch position.
*
* Zones (from center outward):
* - Very center (< 30% of focus height): Rotation
* - Near focus line (30% - 200% of focus height): Size adjustment
* - Far outside (> 200%): Camera zoom
*/
private fun determineGestureType(
centroid: Offset,
width: Float,
height: Float,
params: BlurParameters
): GestureType {
// Calculate distance from focus center
val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f
// Rotate centroid to align with focus line
val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
val distFromCenter = kotlin.math.abs(rotatedY)
return when {
// Very center of focus zone -> rotation (small area)
distFromCenter < focusHalfHeight * 0.3f -> {
GestureType.ROTATE
}
// Anywhere near the blur effect -> size adjustment (large area)
distFromCenter < focusHalfHeight * 2.0f -> {
GestureType.PINCH_SIZE
}
// Far outside -> camera zoom
else -> {
GestureType.PINCH_ZOOM
}
}
}
/**
* Draws the tilt-shift visualization overlay.
* Uses extended geometry so rotated elements don't clip at screen edges.
*/
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
val width = size.width
val height = size.height
val centerX = width * params.positionX
val centerY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay
val focusLineColor = Color(0xFFFFB300) // Amber
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = kotlin.math.sqrt(width * width + height * height)
val extendedHalf = diagonal // Extend lines/rects well beyond screen
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw blur zone indicators (top and bottom) - extended horizontally
drawRect(
color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf),
size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf)
)
drawRect(
color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf)
)
// Draw focus zone boundary lines - extended horizontally
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
// Draw center focus line - extended horizontally
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = 3.dp.toPx()
)
// Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx()
drawCircle(
color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius,
center = Offset(centerX, centerY),
style = Stroke(width = 2.dp.toPx())
)
// Draw angle tick mark
val tickLength = 15.dp.toPx()
drawLine(
color = focusLineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength),
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 3.dp.toPx()
)
}
}