File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
package kr.co.app4ad.photoslide
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kr.co.app4ad.photoslide.ui.theme.PhotoSlideTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PhotoSlideTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Black
) {
PhotoSlideApp()
}
}
}
}
}
/**
* 1. 전체 앱 상태 관리 및 라우팅 담당 (State Holder)
*/
@Composable
fun PhotoSlideApp() {
var selectedImageUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var currentIndex by remember { mutableIntStateOf(0) }
val photoPickerLauncher = rememberImagePickerLauncher { uris ->
if (uris.isNotEmpty()) {
selectedImageUris = uris
currentIndex = 0
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (selectedImageUris.isEmpty()) {
EmptyPhotoView(
onPickClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
)
} else {
ActiveSlideView(
imageUris = selectedImageUris,
currentIndex = currentIndex,
onNextClick = {
currentIndex = (currentIndex + 1) % selectedImageUris.size
},
onChangePhotosClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
)
}
}
}
/**
* 2. 사진 불러오기 (Picker) 로직 모듈
*/
@Composable
fun rememberImagePickerLauncher(onUrisSelected: (List<Uri>) -> Unit) =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(),
onResult = onUrisSelected
)
/**
* 3. 사진이 없을 때 안내 및 선택 화면 모듈
*/
@Composable
fun EmptyPhotoView(onPickClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = "슬라이드쇼에 사용할 사진을 선택해 주세요",
color = Color.White,
modifier = Modifier.padding(bottom = 16.dp)
)
Button(onClick = onPickClick) {
Text("사진 여러 장 고르기")
}
}
}
/**
* 4. 사진 슬라이드 쇼 렌더링, 타이머 프로그레스바 및 전환 애니메이션 모듈
*/
@Composable
fun ActiveSlideView(
imageUris: List<Uri>,
currentIndex: Int,
onNextClick: () -> Unit,
onChangePhotosClick: () -> Unit
) {
val slideDuration = 10000L // 사진 한 장당 표시 시간 (3초)
val progress = remember { Animatable(0f) }
// 사진이 2장 이상일 때, 인덱스가 바뀔 때마다 0부터 1까지 차오르는 타이머 애니메이션 실행
LaunchedEffect(currentIndex, imageUris) {
if (imageUris.size > 1) {
progress.snapTo(0f)
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = slideDuration.toInt(), easing = LinearEasing)
)
onNextClick()
}
}
val currentUri = imageUris[currentIndex]
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
val maxWidth = maxWidth
val maxHeight = maxHeight
// Crossfade를 적용하여 사진이 바뀔 때 부드럽게 페이드 인/아웃 되도록 전환 효과 부여
Crossfade(
targetState = currentUri,
animationSpec = tween(durationMillis = 1000), // 전환에 걸리는 시간 (0.6초)
label = "PhotoTransition"
) { uri ->
PhotoImageView(
uri = uri,
maxWidth = maxWidth,
maxHeight = maxHeight
)
}
// 화면 상단에 남은 시간을 시각적으로 보여주는 프로그레스 인디케이터 바
if (imageUris.size > 1) {
LinearProgressIndicator(
progress = { progress.value },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.align(Alignment.TopCenter),
color = Color.White,
trackColor = Color.Gray.copy(alpha = 0.3f)
)
}
SlideControlBar(
currentIndex = currentIndex,
totalSize = imageUris.size,
onNextClick = onNextClick,
onChangePhotosClick = onChangePhotosClick
)
}
}
/**
* 5. 단일 사진 렌더링, getPhotoOrientation 함수 반영 및 오버레이 표시 모듈
*/
@Composable
fun PhotoImageView(
uri: Uri,
maxWidth: Dp,
maxHeight: Dp
) {
val context = LocalContext.current
// getPhotoOrientation 함수를 사용하여 회전 각도 추출
var rotation = remember(uri) {
getPhotoOrientation(context, uri)
}
rotation = 0F
Log.d ("tag rotation" +
"", "rotation = $rotation" )
// EXIF orientation 값을 로그 또는 디버그 표시용으로 읽기 위한 추가 로직
val orientationVal = remember(uri) {
try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
ExifInterface(inputStream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} ?: 1
} catch (e: Exception) {
1
}
}
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(uri)
.crossfade(true)
.build()
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
val isRotated = rotation == 90f || rotation == 270f
Image(
painter = painter,
contentDescription = "슬라이드 사진",
modifier = Modifier
.fillMaxSize()
.rotate(rotation),
contentScale = if (isRotated) ContentScale.Inside else ContentScale.Fit
)
// 사진 위에 orientation 값과 회전 각도 표시
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "Orientation: $orientationVal (Rotation: ${rotation.toInt()}°)",
color = Color.White,
fontSize = 14.sp
)
}
}
}
/**
* 5-1 EXIF 정보로 회전각 읽어오기
*/
fun getPhotoOrientation(context: Context, uri: Uri): Float {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val exif = ExifInterface(inputStream)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
} ?: 0f
} catch (e: Exception) {
0f
}
}
/**
* 6. 하단 제어 바 (사진 전환 및 재선택 버튼) 모듈
*/
@Composable
fun SlideControlBar(
currentIndex: Int,
totalSize: Int,
onNextClick: () -> Unit,
onChangePhotosClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.BottomCenter
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = onChangePhotosClick) {
Text("사진 다시 고르기")
}
Button(onClick = onNextClick) {
Text("다음 사진 (${currentIndex + 1}/$totalSize)")
}
}
}
}