본문 바로가기

[Android] requiredWidth를 활용해 화면 크기를 초과하는 이미지 슬라이딩 애니메이션 구현하기

@Marchbreeze2025. 5. 24. 02:05

구현 영상

다음과 같이 Compose에서 배경보다 큰 이미지를 부모 레이아웃 제약 없이 전체 높이에 맞추고, 배경 내에서 가로로 슬라이딩되는 무한 애니메이션을 적용하는 방법을 구현했습니다.

 


1. 부모 레이아웃 무시하는 이미지 컴포넌트 설정

Compose의 기본 Modifier.fillMaxSize()만으로는 부모 비율과 다른 가로·세로 비율의 이미지를 전체 화면에 배경처럼 고정하기 어렵습니다. 아래와 같은 단계를 통해 부모 제약을 직접 무시하고, 원하는 크기로 이미지를 배치합니다.

(1) 이미지 Painter 생성 및 원본 크기 측정

@Composable
fun MovingBackgroundImage(
    @DrawableRes imageRes: Int,
    modifier: Modifier = Modifier,
) {
    // 이미지 Painter 생성 및 크기 측정
    val painter = painterResource(id = imageRes)
    val imageSizePx = painter.intrinsicSize
  
	  //...
}
  • intrinsicSize는 이미지의 원본 픽셀 크기를 제공하므로, 화면 높이에 맞춘 가로 길이를 비율에 맞춰 계산할 수 있습니다.

 

(2) BoxWithConstraints를 통해 부모뷰 크기 측정

BoxWithConstraints(modifier = modifier.fillMaxSize()) {
    val density = LocalDensity.current

    // 화면 크기를 픽셀 단위로 변환
    val parentHeightPx = constraints.maxHeight.toFloat()
    val parentWidthPx = constraints.maxWidth.toFloat()

    // 화면 높이에 맞춘 이미지의 가로 길이 측정 (비율 보존)
    val imageWidthPx = parentHeightPx * (imageSizePx.width / imageSizePx.height)
    val imageWidthDp = with(density) { imageWidthPx.toDp() }
    val imageHeightDp = with(density) { constraints.maxHeight.toDp() }
 
		//...
 }
  • constraints.maxWidth·maxHeight 부모 컴포저블이 실제 확보한 픽셀 크기를 가져옵니다.
  • 이후 LocalDensity와 조합하여, 픽셀 값을 DP로 변환할 수 있습니다.

 

(3) 부모의 제약을 무시한 이미지 컴포넌트 크기 지정

Image(
    painter = painter,
    contentDescription = null,
    modifier = Modifier
        .requiredHeight(imageHeightDp)
        .requiredWidth(imageWidthDp)
)
  • 부모의 높이를 기준으로, 이미지 원본 비율을 유지하며 가로 길이를 계산합니다.
  • toDp()로 DP 단위로 변환해 Modifier.requiredWidth과 requiredHeight에 적용합니다.
  • required 계열 Modifier는 부모 제약에 상관없이 정확한 크기를 강제하므로, 이미지의 양 끝 여백을 설정한 채 구현할 수 있습니다.

 

 


2. 이미지 가로 슬라이딩 애니메이션 구현

확장된 이미지 너비와 부모 너비 간의 차이만큼 좌우로 이동하며, 무한 반복 애니메이션을 설정합니다.

(1) 수평 오프셋 이동값 측정

val imageWidthGapPx = (imageWidthPx - parentWidthPx).coerceAtLeast(0f)
  • 화면 가로 크기와 크기 보정된 이미지 가로 크기의 차이만큼 수평 이동하도록, 이동값을 측정합니다.
  • imageWidthPx > parentWidthPx인 경우에만 gap이 발생하며, coerceAtLeast(0f)로 음수 값을 0으로 보정합니다.

 

(2) 애니메이션 오프셋 설정

val infiniteTransition = rememberInfiniteTransition()
val animatedOffset by infiniteTransition.animateFloat(
    initialValue = imageWidthGapPx / 2,
    targetValue = -imageWidthGapPx / 2,
    animationSpec = infiniteRepeatable(
        animation = tween(
            durationMillis = 12000,
            easing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f),
        ),
        repeatMode = RepeatMode.Reverse
    )
)
  • 좌우로 -gap/2 ~ +gap/2 범위 내에서 움직이는 애니메이션을 설정합니다.
  • 이때 지정된 CubicBezierEasing(SlowInSlowOut)으로, 자연스러운 가속·감속 효과를 적용합니다. (하단 링크 참고)
  • 링크 : Easing in to Easing Curves in Jetpack Compose 🎢

 

(3) 이미지 적용

Image(
    painter = painter,
    contentDescription = null,
    modifier = Modifier
        .requiredHeight(imageHeightDp)
        .requiredWidth(imageWidthDp)
        .offset { IntOffset(x = animatedOffset.roundToInt(), y = 0) }
)
  • offset 람다에서 IntOffset을 반환해, 실시간으로 위치를 업데이트합니다.

 

 


intrinsicSize BoxWithConstraints로 정확한 크기를 계산하고, rememberInfiniteTransition 애니메이션으로 자연스러운 움직임을 더했습니다.

이 패턴을 활용해서, 앱의 로그인 화면이나 배너 등에서 배경 이미지를 고정하지 않고 부드럽게 좌우로 움직이는 효과를 구현했습니다.

Marchbreeze
Marchbreeze

안드로이드 개발자, 김상호입니다.

https://github.com/Marchbreeze

목차