2026-01-28 15:26:41 +01:00
|
|
|
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.calculateRotation
|
|
|
|
|
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.cos
|
|
|
|
|
import kotlin.math.sin
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Type of gesture being performed.
|
|
|
|
|
*/
|
|
|
|
|
private enum class GestureType {
|
|
|
|
|
NONE,
|
2026-01-28 15:55:17 +01:00
|
|
|
DRAG_POSITION, // Single finger drag to move focus center
|
2026-01-28 15:26:41 +01:00
|
|
|
ROTATE, // Two-finger rotation
|
|
|
|
|
PINCH_SIZE, // Pinch near blur edges to resize
|
|
|
|
|
PINCH_ZOOM // Pinch in center to zoom camera
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:32:31 +01:00
|
|
|
// Sensitivity factors for gesture controls (lower = less sensitive)
|
2026-01-28 15:55:17 +01:00
|
|
|
// Rotation uses 1:1 tracking (no dampening) for natural feel
|
|
|
|
|
private const val POSITION_SENSITIVITY = 0.5f // Drag to move focus center
|
|
|
|
|
private const val SIZE_SENSITIVITY = 0.3f // Pinch to resize blur zone
|
|
|
|
|
private const val ZOOM_SENSITIVITY = 0.5f // Pinch to zoom camera
|
2026-01-28 15:32:31 +01:00
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
/**
|
|
|
|
|
* 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 initialSize by remember { mutableFloatStateOf(0.3f) }
|
2026-01-28 15:55:17 +01:00
|
|
|
var initialPositionX by remember { mutableFloatStateOf(0.5f) }
|
|
|
|
|
var initialPositionY by remember { mutableFloatStateOf(0.5f) }
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
Canvas(
|
|
|
|
|
modifier = modifier
|
|
|
|
|
.fillMaxSize()
|
|
|
|
|
.pointerInput(Unit) {
|
|
|
|
|
awaitEachGesture {
|
|
|
|
|
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
|
|
|
|
currentGesture = GestureType.NONE
|
|
|
|
|
|
|
|
|
|
var previousCentroid = firstDown.position
|
|
|
|
|
var accumulatedRotation = 0f
|
|
|
|
|
var accumulatedZoom = 1f
|
2026-01-28 15:55:17 +01:00
|
|
|
var accumulatedDragX = 0f
|
2026-01-28 15:32:31 +01:00
|
|
|
var accumulatedDragY = 0f
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
initialAngle = params.angle
|
|
|
|
|
initialSize = params.size
|
2026-01-28 15:55:17 +01:00
|
|
|
initialPositionX = params.positionX
|
|
|
|
|
initialPositionY = params.positionY
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
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 rotation = event.calculateRotation()
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
when (currentGesture) {
|
|
|
|
|
GestureType.ROTATE -> {
|
2026-01-28 15:55:17 +01:00
|
|
|
// 1:1 rotation tracking - no dampening
|
|
|
|
|
accumulatedRotation += rotation
|
2026-01-28 15:26:41 +01:00
|
|
|
val newAngle = initialAngle + accumulatedRotation
|
|
|
|
|
onParamsChange(params.copy(angle = newAngle))
|
|
|
|
|
}
|
|
|
|
|
GestureType.PINCH_SIZE -> {
|
2026-01-28 15:32:31 +01:00
|
|
|
// Apply dampening to size change
|
|
|
|
|
val dampenedZoom = 1f + (zoom - 1f) * SIZE_SENSITIVITY
|
|
|
|
|
accumulatedZoom *= dampenedZoom
|
2026-01-28 15:26:41 +01:00
|
|
|
val newSize = (initialSize * accumulatedZoom)
|
|
|
|
|
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
|
|
|
|
|
onParamsChange(params.copy(size = newSize))
|
|
|
|
|
}
|
|
|
|
|
GestureType.PINCH_ZOOM -> {
|
2026-01-28 15:32:31 +01:00
|
|
|
// Apply dampening to camera zoom
|
|
|
|
|
val dampenedZoom = 1f + (zoom - 1f) * ZOOM_SENSITIVITY
|
|
|
|
|
onZoomChange(dampenedZoom)
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
else -> {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:55:17 +01:00
|
|
|
// Single finger - drag to move focus center (2D)
|
2026-01-28 15:26:41 +01:00
|
|
|
pointers.size == 1 -> {
|
|
|
|
|
if (currentGesture == GestureType.NONE) {
|
|
|
|
|
currentGesture = GestureType.DRAG_POSITION
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentGesture == GestureType.DRAG_POSITION) {
|
2026-01-28 15:55:17 +01:00
|
|
|
val deltaX = (centroid.x - previousCentroid.x) / size.width
|
2026-01-28 15:26:41 +01:00
|
|
|
val deltaY = (centroid.y - previousCentroid.y) / size.height
|
2026-01-28 15:55:17 +01:00
|
|
|
accumulatedDragX += deltaX * POSITION_SENSITIVITY
|
2026-01-28 15:32:31 +01:00
|
|
|
accumulatedDragY += deltaY * POSITION_SENSITIVITY
|
2026-01-28 15:55:17 +01:00
|
|
|
val newX = (initialPositionX + accumulatedDragX).coerceIn(0f, 1f)
|
|
|
|
|
val newY = (initialPositionY + accumulatedDragY).coerceIn(0f, 1f)
|
|
|
|
|
onParamsChange(params.copy(positionX = newX, positionY = newY))
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
*/
|
|
|
|
|
private fun determineGestureType(
|
|
|
|
|
centroid: Offset,
|
|
|
|
|
width: Float,
|
|
|
|
|
height: Float,
|
|
|
|
|
params: BlurParameters
|
|
|
|
|
): GestureType {
|
2026-01-28 15:55:17 +01:00
|
|
|
// Calculate distance from focus center
|
|
|
|
|
val focusCenterX = width * params.positionX
|
|
|
|
|
val focusCenterY = height * params.positionY
|
2026-01-28 15:26:41 +01:00
|
|
|
val focusHalfHeight = height * params.size * 0.5f
|
|
|
|
|
|
|
|
|
|
// Rotate centroid to align with focus line
|
2026-01-28 15:55:17 +01:00
|
|
|
val dx = centroid.x - focusCenterX
|
2026-01-28 15:26:41 +01:00
|
|
|
val dy = centroid.y - focusCenterY
|
|
|
|
|
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
|
|
|
|
|
|
|
|
|
|
val distFromCenter = kotlin.math.abs(rotatedY)
|
|
|
|
|
|
|
|
|
|
return when {
|
|
|
|
|
// Near the edges of the blur zone -> size adjustment
|
|
|
|
|
distFromCenter > focusHalfHeight * 0.7f && distFromCenter < focusHalfHeight * 1.5f -> {
|
|
|
|
|
GestureType.PINCH_SIZE
|
|
|
|
|
}
|
|
|
|
|
// Inside the focus zone -> rotation
|
|
|
|
|
distFromCenter < focusHalfHeight * 0.7f -> {
|
|
|
|
|
GestureType.ROTATE
|
|
|
|
|
}
|
|
|
|
|
// Outside -> camera zoom
|
|
|
|
|
else -> {
|
|
|
|
|
GestureType.PINCH_ZOOM
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Draws the tilt-shift visualization overlay.
|
2026-01-28 15:46:43 +01:00
|
|
|
* Uses extended geometry so rotated elements don't clip at screen edges.
|
2026-01-28 15:26:41 +01:00
|
|
|
*/
|
|
|
|
|
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
|
|
|
|
|
val width = size.width
|
|
|
|
|
val height = size.height
|
|
|
|
|
|
2026-01-28 15:55:17 +01:00
|
|
|
val centerX = width * params.positionX
|
|
|
|
|
val centerY = height * params.positionY
|
2026-01-28 15:26:41 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-01-28 15:46:43 +01:00
|
|
|
// 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
|
|
|
|
|
// Top blur zone: from far above to the top edge of focus area
|
2026-01-28 15:26:41 +01:00
|
|
|
drawRect(
|
|
|
|
|
color = blurZoneColor,
|
2026-01-28 15:46:43 +01:00
|
|
|
topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf),
|
|
|
|
|
size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf)
|
2026-01-28 15:26:41 +01:00
|
|
|
)
|
2026-01-28 15:46:43 +01:00
|
|
|
// Bottom blur zone: from bottom edge of focus area to far below
|
2026-01-28 15:26:41 +01:00
|
|
|
drawRect(
|
|
|
|
|
color = blurZoneColor,
|
2026-01-28 15:46:43 +01:00
|
|
|
topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
|
|
|
|
|
size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf)
|
2026-01-28 15:26:41 +01:00
|
|
|
)
|
|
|
|
|
|
2026-01-28 15:46:43 +01:00
|
|
|
// Draw focus zone boundary lines - extended horizontally
|
2026-01-28 15:26:41 +01:00
|
|
|
drawLine(
|
|
|
|
|
color = focusLineColor,
|
2026-01-28 15:46:43 +01:00
|
|
|
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
|
|
|
|
|
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
|
2026-01-28 15:26:41 +01:00
|
|
|
strokeWidth = 2.dp.toPx(),
|
|
|
|
|
pathEffect = dashEffect
|
|
|
|
|
)
|
|
|
|
|
drawLine(
|
|
|
|
|
color = focusLineColor,
|
2026-01-28 15:46:43 +01:00
|
|
|
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
|
|
|
|
|
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
|
2026-01-28 15:26:41 +01:00
|
|
|
strokeWidth = 2.dp.toPx(),
|
|
|
|
|
pathEffect = dashEffect
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-28 15:46:43 +01:00
|
|
|
// Draw center focus line - extended horizontally
|
2026-01-28 15:26:41 +01:00
|
|
|
drawLine(
|
|
|
|
|
color = focusLineColor,
|
2026-01-28 15:46:43 +01:00
|
|
|
start = Offset(centerX - extendedHalf, centerY),
|
|
|
|
|
end = Offset(centerX + extendedHalf, centerY),
|
2026-01-28 15:26:41 +01:00
|
|
|
strokeWidth = 3.dp.toPx()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Draw rotation indicator at center
|
|
|
|
|
val indicatorRadius = 30.dp.toPx()
|
|
|
|
|
drawCircle(
|
|
|
|
|
color = focusLineColor.copy(alpha = 0.5f),
|
|
|
|
|
radius = indicatorRadius,
|
2026-01-28 15:46:43 +01:00
|
|
|
center = Offset(centerX, centerY),
|
2026-01-28 15:26:41 +01:00
|
|
|
style = Stroke(width = 2.dp.toPx())
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Draw angle tick mark
|
|
|
|
|
val tickLength = 15.dp.toPx()
|
|
|
|
|
drawLine(
|
|
|
|
|
color = focusLineColor,
|
2026-01-28 15:46:43 +01:00
|
|
|
start = Offset(centerX, centerY - indicatorRadius + tickLength),
|
|
|
|
|
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
|
2026-01-28 15:26:41 +01:00
|
|
|
strokeWidth = 3.dp.toPx()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|