굉장히 굉장히 욕심낸 기능이 하나 있었는데 그것은 바로 커스텀 갤러리 ..
커스텀 갤러리 중에서도 크롭 .. 크롭 ..
왜냐면 사진을 1:1의 정방향으로만 등록할 수 있는데 그 부분을 사용자가 컨트롤하지 못한다?
바로 앱삭제 날릴 것 같았음 (물론 아직 사용자 1이긴한데 (나))
그런데 라이브러리를 사용하지 않고 하자니 엄청난 여정이 시작된ㄷ ㅏ... ...
왜 라이브러리를 사용하지 않았냐고 묻는다면 마음에 안들었다
나는 그냥 해당 화면에 크롭 기능만 있으면 좋겠는데, 살펴보니
1. 하나같이 다이얼로그의 형태로 보여지고 + 크롭된다거나
2. 자기들만의 크롭 화면이 따로 있었다는 것
3. 연속으로 크롭이 가능한가?에 대해선 안될 것 같았음 (이미지로만 보긴했지만)
그래서 시작된 커스텀 갤러리, 크롭, 미리보기 레스고
91ft - 선물 저장소 - Google Play 앱
소중한 사람들과 주고받은 선물을 기록해보세요.
play.google.com
개발을 시작하기에 앞서 필요한 요구사항을 작성해보았다.
요구사항 작성하기 전에 일단 개발했다가 시간을 날려먹기 일쑤였기때문에 작게라도 요구사항을 작성하고 시작하는 편 .. 이되었다
크롭과 관련된 요구사항은 아래와 같다.
요구사항
1. 갤러리 위에 프리뷰 영역이 보여지며, 사진을 위/아래로 스크롤하여 크롭 영역을 지정한다.
2. 미리보기 화면에서 크롭된 이미지가 보여진다.
3. 선물 등록 화면에서 크롭된 이미지가 등록된다.
1. 갤러리 위에 프리뷰 영역이 보여지며, 사진을 위/아래로 스크롤하여 크롭 영역을 지정한다.
사실 스크롤로 크롭 영역을 지정하기 이전에는 이미지 하나씩 크롭하도록 구현했었다.
위의 사진처럼 각 이미지에 맞춰 원하는대로 크롭할 수 있도록 크롭 박스를 보여주도록 구현했었는데, 생각보다 이것이 복잡했다 ..
구현은 되었었는데 뭐가 복잡했냐고 묻는다면 기획 부분이 복잡했다.
어떻게 데이터를 넘겨줄거고 다시 되돌아왔을 때 어떻게 할거고와 같은 그런 정책들을 정하는 것이 어려웠다.
그래서 .. 이건 나중에 디벨롭하는 것으로 결정하고 간단하게 스크롤로 크롭을 구현하는 것으로 변경했다.
Preview
상단의 이미지가 보여지는 프리뷰 부분은 좌/우로 스크롤할 수 있는 ViewPager로 구현했고, pagerState를 정의해주었다.
val pagerState = rememberPagerState {
if (uiState.selectedImages.isEmpty()) 0
else uiState.selectedImages.size
}
ScrollState
현재 최대 3장의 이미지만 선택이 가능하도록 제한해두었기 때문에, 크롭이 진행되어야 하는 이미지도 총 3장이다.
val scrollStates = remember { mutableStateMapOf<Int, ScrollState>() }
각각의 이미지가 ScrollState를 가지고 있어야하므로 Map을 통해 상태를 정의해주었다.
Int는 각 이미지가 List로 되어있었기 때문에 List의 index를 key로 사용하였다.
LaunchedEffect(Unit) {
launch {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect { page ->
val scrollState = scrollStates.getOrPut(page) { ScrollState(initial = 0) }
// 새로 선택된 이미지일 경우, 스크롤 초기화
if (!uiState.initializedPages.contains(page)) {
vm.addInitializedPage(page)
scrollState.scrollTo(0)
}
}
}
}
이미지를 새로 선택할 때마다 scrollState를 초기화해주었다.
Q. 왜 새로 선택할 때마다 scrollState를 초기화해주었는가?
기존 이미지를 스크롤하고 새로운 이미지를 선택했을 때, 새로운 이미지의 스크롤이 0으로 초기화되지 않고 기존 이미지의 스크롤 영역으로 이미 스크롤되어 화면에 표시되어있었다.
예를들어, 1번 이미지의 스크롤을 설정하고 2번 이미지를 새로 선택했을 때 기대 값과 실제 값이 일치하지 않았다.
기대하는 스크롤 위치 : 이미지의 상단
실제 스크롤 위치 : 1번 이미지의 스크롤 위치
초기화가 완료되어 사용자가 스크롤을 통해 크롭 영역을 지정하고 저장 버튼을 클릭하면 크롭이 진행된다.
이때 스크롤의 위치는 각각의 ScrollState의 value에 저장되어있다.
처음에 이 ScrollState의 value가 그래서 어디를 말하는건지 이해가 안되었었다.
검색을 해보니 이미지의 상단을 기준으로 value를 결정한다는 것을 알았다.
1-1. 이미지 크롭 진행
private suspend fun cropAndSaveImage(
context: Context,
image: GalleryImageUiModel,
scrollValue: Int,
viewportSize: Int,
): Uri = withContext(Dispatchers.IO) {
// 1) URI → Bitmap
val bitmap = ImageConverter.uriToBitmap(context, image.uri, true)
// 이미지의 높이 Offset
val offsetY = scrollValue.coerceIn(0, bitmap.height)
// 2) 크롭
val cropped = withContext(Dispatchers.Default) {
createCropImage(bitmap, offsetY, viewportSize)
}
// 3) Bitmap → 파일 URI
ImageConverter.bitmapToFileUri(context, cropped)
}
- image : 이미지 관련 데이터를 가진 model
- scrollValue : 각 이미지에 대한 ScrollState
- viewportSize : 이미지가 보여지는 프리뷰에 대한 size
data class GalleryImageUiModel(
val id: Long,
val folderName: String,
val uri: Uri,
val width: Int,
val height: Int,
val orientation: Int,
)
1-1-1. uriToBitmap()
현재 ContentProvider를 통해 이미지의 Uri를 알고있기 때문에 이를 Bitmap으로 변경해주는 작업을 진행하였다.
fun uriToBitmap(context: Context, uri: Uri, isResized: Boolean = false): Bitmap {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
if (isResized) {
try {
val onHeaderDecodedListener =
OnHeaderDecodedListener { decoder, imageInfo, _ ->
val width = imageInfo.size.width.toFloat()
val height = imageInfo.size.height.toFloat()
val maxSize = 1080
val scale = min(maxSize / width, maxSize / height)
val targetWidth = (width * scale).toInt()
val targetHeight = (height * scale).toInt()
decoder.setTargetSize(targetWidth, targetHeight)
}
ImageDecoder.decodeBitmap(source, onHeaderDecodedListener)
} catch (e: Exception) {
Log.d("ImageConverter", "Exception: ${e.stackTraceToString()}")
ImageDecoder.decodeBitmap(source)
}
} else {
ImageDecoder.decodeBitmap(source)
}
} else {
// API 28 미만
MediaStore.Images.Media.getBitmap(context.contentResolver, uri)
}
}
여기서 isResized 는 보지 않아도 된다 .. 다른 곳에서도 이 메서드를 사용하고있는데 사이드이펙트가 발생할까봐 넣어둔 변수이다 ..
나중에 고쳐야지 ..
API 28을 기준으로 ImageDecoder와 MediaStore를 구분하여 사용해주어야 한다!
해상도는 1080을 max로 잡아주었다.
화면에 표시될 때 엄청나게 높은 화질이 필요 없을 것 같기도하고 용량을 줄여야하기때문에 1080으로 지정하여 비율에 맞춰 줄여주었다.
이렇게 설정해주니 메모리 사용량도 줄고 크롭하는 전체 과정의 시간이 단축되었다.
1-1-2. 크롭의 시작지점 (offsetY) 계산
// 이미지의 높이 Offset (startY, endY)
val offsetY = scrollValue.coerceIn(0, bitmap.height)
ScrollState의 value를 통해 크롭의 시작점을 알 수 있는데 혹시 모르는 예외 상황이 발생할 수 있기 때문에 범위를 지정해주었다.
1-1-3. 크롭 진행
private fun createCropImage(
bitmap: Bitmap,
imageStartY: Int,
viewportSize: Int,
): Bitmap {
val imageWidth = bitmap.width
val imageHeight = bitmap.height
val widthRatio = viewportSize.toFloat() / imageWidth
val heightRatio = viewportSize.toFloat() / imageHeight
// 현재 CROP 으로 보여주고 있으니 max를 통해 ratio 계산
val scaleFactor = max(widthRatio, heightRatio)
// 화면에 보여지는 이미지의 크기
val displayImageWidth = imageWidth * scaleFactor
// 사용자가 스크롤한 만큼 더해줌
val cropTopInScaled = imageStartY
val cropLeftInScaled = (displayImageWidth - viewportSize) / 2
val cropY = (cropTopInScaled / scaleFactor).toInt().coerceIn(0, bitmap.height - 1)
val cropX = (cropLeftInScaled / scaleFactor).toInt().coerceIn(0, bitmap.width - 1)
val cropSizeInBitmap = (viewportSize / scaleFactor).toInt()
// 잘릴 범위가 원본 범위를 벗어나지 않도록 보정
val finalCropWidth = min(cropSizeInBitmap, imageWidth - cropX)
val finalCropHeight = min(cropSizeInBitmap, imageHeight - cropY)
return Bitmap.createBitmap(bitmap, cropX, cropY, finalCropWidth, finalCropHeight)
}
이 크롭 부분을 작성하면서 이미지 관련해서 많이 공부되었다.
특히 크롭 부분은 하단에 참고에 첨부한 유튜브를 보면서 하나하나 계산해보면서 이해했다 ..
테스트한 기기와 이미지로 하나하나 계산을 해보자면 아래와 같다.
그래서 결론적으로 (0, 266)의 위치에서 (795, 1060) 까지 크롭이 진행된다 !
Bitmap을 미리보기 화면으로 넘겨주기에는 무거운 것 같아서 파일로 생성하여 Uri을 넘겨주는 방법을 선택했다.
1-1-4. BitmapToFileUri
fun bitmapToFileUri(
context: Context,
bitmap: Bitmap,
fileName: String = "edited_${System.currentTimeMillis()}"
): Uri {
val file = bitmapToFile(context, bitmap, fileName)
return file.toUri()
}
2. 미리보기 화면에서 크롭된 이미지가 보여진다.
전달받은 Uri를 화면에 뿌려주기만 하면 크롭된 이미지 완성 !
참고
'Android > 91ft' 카테고리의 다른 글
[91ft] 최신 버전 정보를 통해 업데이트 여부 판단하기 (Firebase Remote Config) (2) | 2025.05.06 |
---|---|
[91ft] MVVM에서 MVI로 변경 (0) | 2025.04.21 |
[Senty] xml 기반의 view에서 Compose로 100% 모든 뷰를 변경 (0) | 2024.06.16 |
제대로 갈아엎는, 이름까지 갈아엎어버릴 나의 첫 앱, Senty (0) | 2024.05.30 |
개인정보처리방침 (0) | 2023.04.05 |