Initial implementation of Tilt-Shift Camera Android app
A dedicated camera app for tilt-shift photography with: - Real-time OpenGL ES 2.0 shader-based blur preview - Touch gesture controls (drag, rotate, pinch) for adjusting effect - CameraX integration for camera preview and high-res capture - EXIF metadata with GPS location support - MediaStore integration for saving to gallery - Jetpack Compose UI with haptic feedback Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
07e10ac9c3
38 changed files with 3489 additions and 0 deletions
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal file
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
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,
|
||||
DRAG_POSITION, // Single finger drag to move focus position
|
||||
ROTATE, // Two-finger rotation
|
||||
PINCH_SIZE, // Pinch near blur edges to resize
|
||||
PINCH_ZOOM // Pinch in center to zoom camera
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 initialZoom by remember { mutableFloatStateOf(1f) }
|
||||
var initialAngle by remember { mutableFloatStateOf(0f) }
|
||||
var initialSize by remember { mutableFloatStateOf(0.3f) }
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||
currentGesture = GestureType.NONE
|
||||
|
||||
var previousCentroid = firstDown.position
|
||||
var previousPointerCount = 1
|
||||
var accumulatedRotation = 0f
|
||||
var accumulatedZoom = 1f
|
||||
|
||||
initialAngle = params.angle
|
||||
initialSize = params.size
|
||||
initialZoom = 1f
|
||||
|
||||
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 -> {
|
||||
accumulatedRotation += rotation
|
||||
val newAngle = initialAngle + accumulatedRotation
|
||||
onParamsChange(params.copy(angle = newAngle))
|
||||
}
|
||||
GestureType.PINCH_SIZE -> {
|
||||
accumulatedZoom *= zoom
|
||||
val newSize = (initialSize * accumulatedZoom)
|
||||
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
|
||||
onParamsChange(params.copy(size = newSize))
|
||||
}
|
||||
GestureType.PINCH_ZOOM -> {
|
||||
onZoomChange(zoom)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Single finger
|
||||
pointers.size == 1 -> {
|
||||
if (currentGesture == GestureType.NONE) {
|
||||
currentGesture = GestureType.DRAG_POSITION
|
||||
}
|
||||
|
||||
if (currentGesture == GestureType.DRAG_POSITION) {
|
||||
val deltaY = (centroid.y - previousCentroid.y) / size.height
|
||||
val newPosition = (params.position + deltaY).coerceIn(0f, 1f)
|
||||
onParamsChange(params.copy(position = newPosition))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousCentroid = centroid
|
||||
previousPointerCount = pointers.size
|
||||
|
||||
// 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 {
|
||||
// Calculate distance from focus center line
|
||||
val focusCenterY = height * params.position
|
||||
val focusHalfHeight = height * params.size * 0.5f
|
||||
|
||||
// Rotate centroid to align with focus line
|
||||
val dx = centroid.x - width / 2f
|
||||
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.
|
||||
*/
|
||||
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val centerY = height * params.position
|
||||
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)
|
||||
|
||||
rotate(angleDegrees, pivot = Offset(width / 2f, centerY)) {
|
||||
// Draw blur zone indicators (top and bottom)
|
||||
drawRect(
|
||||
color = blurZoneColor,
|
||||
topLeft = Offset(0f, 0f),
|
||||
size = androidx.compose.ui.geometry.Size(width, centerY - focusHalfHeight)
|
||||
)
|
||||
drawRect(
|
||||
color = blurZoneColor,
|
||||
topLeft = Offset(0f, centerY + focusHalfHeight),
|
||||
size = androidx.compose.ui.geometry.Size(width, height - (centerY + focusHalfHeight))
|
||||
)
|
||||
|
||||
// Draw focus zone boundary lines
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(0f, centerY - focusHalfHeight),
|
||||
end = Offset(width, centerY - focusHalfHeight),
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
pathEffect = dashEffect
|
||||
)
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(0f, centerY + focusHalfHeight),
|
||||
end = Offset(width, centerY + focusHalfHeight),
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
pathEffect = dashEffect
|
||||
)
|
||||
|
||||
// Draw center focus line
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(0f, centerY),
|
||||
end = Offset(width, 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(width / 2f, centerY),
|
||||
style = Stroke(width = 2.dp.toPx())
|
||||
)
|
||||
|
||||
// Draw angle tick mark
|
||||
val tickLength = 15.dp.toPx()
|
||||
drawLine(
|
||||
color = focusLineColor,
|
||||
start = Offset(width / 2f, centerY - indicatorRadius + tickLength),
|
||||
end = Offset(width / 2f, centerY - indicatorRadius - 5.dp.toPx()),
|
||||
strokeWidth = 3.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue