먼저,
이미지를 로딩하는 라이브러리로는 🔗Coil을 사용했다.
implementation("io.coil-kt.coil3:coil-compose:{latest version}")
implementation("io.coil-kt.coil3:coil-network-okhttp:{latest version}")
ImageViewer의 기능은 다음과 같다.
- 이미지를 표시
- 두 손가락으로 확대/축소
- 1배수 미만으로 축소하면 손가락을 뗐을때 1배수크기/정위치로 원복
- 확대한 상태에서 이미지 위치를 이동
- 확대한 상태에서 이미지 위치를 이동하고 손을 뗐을 때, 화면 가장자리에서 이미지가 떨어지면 위치를 되돌림
- 더블 탭 하면 이미지를 1배수크기/정위치로 원복
- 확대 후 이미지 이동 시, 이미지가 화면 밖을 넘어가지 않도록 함
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animate
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.toSize
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
@Composable
fun ImageViewer(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxSize()
.background(Color.DarkGray)
) {
val scope = rememberCoroutineScope()
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset(0f, 0f)) }
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data("https://dummyimage.com/400x600/f5c1f5/ffffff")
.build()
)
var viewerSize = remember { Size.Zero }
val density = LocalDensity.current
val painterState by painter.state.collectAsState()
val imageSize by remember(painterState) {
derivedStateOf {
// 화면에 표시되고 있는 이미지의 크기를 얻음
// 이미지는 뷰어 컴포저블에 Fit 하도록 표시되고 있다고 간주함(ContentScale.Fit)
val imageSize = Size(
(painter.intrinsicSize.width * density.density),
(painter.intrinsicSize.height * density.density)
)
val widthRatio = viewerSize.width / imageSize.width
val heightRatio = viewerSize.height / imageSize.height
if (widthRatio < heightRatio) {
// 화면 가로를 채운다
Size(
width = viewerSize.width,
height = (imageSize.height * widthRatio)
)
} else {
// 화면 세로를 채운다
Size(
width = (imageSize.width * heightRatio),
height = viewerSize.height
)
}
}
}
Image(
painter = painter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.onSizeChanged {
viewerSize = it.toSize()
}
.pointerInput(Unit) {
// 위치 이동, 확대/축소 처리
detectTransformGestures { _, pan, zoom, _ ->
// 이미지 줌의 최대/최소 값을 지정
scale = (scale * zoom).coerceIn(0.5f, 3f)
offset = if (scale == 1f) {
// 1배수가 되면 이미지를 정위치로 되돌림
Offset(0f, 0f)
} else if (scale > 1f) {
// 확대한 채로 이미지 이동 시,
// 이미지가 화면 밖을 넘어가지 않도록 제한
val newOffset = offset + pan
val maxX = (imageSize.width * scale - viewerSize.width) / 2f
val maxY = (imageSize.height * scale - viewerSize.height) / 2f
val boundedX = if (maxX < 0f) {
if (newOffset.x < 0) {
max(maxX, newOffset.x)
} else {
min(maxX.absoluteValue, newOffset.x)
}
} else {
newOffset.x
}
val boundedY = if (maxY < 0f) {
if (newOffset.y < 0) {
max(maxY, newOffset.y)
} else {
min(maxY.absoluteValue, newOffset.y)
}
} else {
newOffset.y
}
Offset(boundedX, boundedY)
} else {
offset + pan
}
}
}
.pointerInput(Unit) {
// 손가락을 놓았을 때 처리
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Release) {
if (scale < 1f) {
// 1배수 미만으로 작게 축소 하고 손을 뗐을 때,
// 1배수로 돌아오도록 처리
scope.launch {
animate(
initialValue = scale,
targetValue = 1f
) { value, _ ->
scale = value
}
}
scope.launch {
animate(
typeConverter = Offset.VectorConverter,
initialValue = offset,
targetValue = Offset.Zero
) { value, _ ->
offset = value
}
}
}
// 확대한 채로 이미지를 드래그 하고 손을 뗐을 때,
// 화면 가장자리에서 이미지가 떨어지면 위치를 되돌리는 처리
val maxX = max(
(imageSize.width * scale - viewerSize.width) / 2f,
0f
)
val maxY = max(
(imageSize.height * scale - viewerSize.height) / 2f,
0f
)
scope.launch {
animate(
typeConverter = Offset.VectorConverter,
initialValue = offset,
targetValue = Offset(
// 이미지가 화면을 꽉채우지 않은 상태에서 이미지 드래그 시
// 중앙으로 돌아오지는 않음
x = if (maxX == 0f) {
offset.x
} else {
offset.x.coerceIn(-maxX, maxX)
},
y = if (maxY == 0f) {
offset.y
} else {
offset.y.coerceIn(-maxY, maxY)
}
// 이미지가 화면을 꽉채우지 않은 상태에서 이미지 드래그 시
// 중앙으로 돌아옴
//x = offset.x.coerceIn(-maxX, maxX),
//y = offset.y.coerceIn(-maxY, maxY)
)
) { value, _ ->
offset = value
}
}
}
}
}
}
.pointerInput(Unit) {
// 더블 탭을 하면 크기를 1배수로, 위치를 정위치로 되돌리는 처리
detectTapGestures(
onDoubleTap = {
scope.launch {
animate(
initialValue = scale,
targetValue = 1f
) { value, _ ->
scale = value
}
}
scope.launch {
animate(
typeConverter = Offset.VectorConverter,
initialValue = offset,
targetValue = Offset.Zero
) { value, _ ->
offset = value
}
}
}
)
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
}
)
}
}
ImageViewer가 필요할 것 같은데, 외부 라이브러리는 쓰기 싫어서 간단하게 만들어봤다.
얼렁뚱땅 만든거라 부족한 부분이 있을 것 같다..
'Android' 카테고리의 다른 글
| [Android] 멀티모듈에서 gradle의 BuildType 공통화 (2) | 2025.06.12 |
|---|---|
| [Android] java.lang.ClassCastException (0) | 2025.06.12 |
| [Android] Compose TextField 커스텀 (2) | 2025.06.12 |
| [Android] LazyColumn안에 LazyVerticalGrid 넣기(nested scroll) (0) | 2025.06.12 |
| [Android] Retrofit2 Multipart사용하기 (Java) (0) | 2025.06.12 |