유튜브나 인스타그램같은 것을 보게 되면 데이터가 나오기 전에 회색 모양의 모습이 나오는 것을 볼 수 있다.

보통 이런 UI를 Skeleton UI라고 하는데, Shimmer UI랑 차이점이 무엇일까??
검색을 통해 알아본 바로는 아래의 차이점이 있다.
Skeleton UI: 콘텐츠가 오기 전, 레이아웃의 뼈대(고정된 회색 블록) 를 보여 주는 패턴. 어디에 이미지/텍스트가 들어올지 자리를 미리 잡아 레이아웃 점프를 줄여요. 애니메이션은 필수 아님(대부분 정적).
Shimmer UI: 그 뼈대(placeholder) 위에 빛이 스치는 듯한 애니메이션 그라디언트를 얹어 “로드 중” 느낌을 주는 효과. 즉, 흔히 skeleton + shimmer 효과로 함께 쓰이지만, 개념적으로는 skeleton=구조, shimmer=효과.
찾아본 바로는 Shimmer UI는 Skeleton UI에서 애니메이션 효과를 주는 것이라고 생각하면 될 것같다.
인스타그램 같은 경우는 돋보기를 누르면 왼쪽에서 오른쪽으로 살짝 밝아지는 Shimmer 효과를 주는 것을 볼 수 있다. (그 외에도 살짝 밝아지거나 어두워지는 방향도 있음)
이런 기능은 추후에도 많이 구현이 될 것이라 생각하고 간단한 Shimmer UI를 구현해보자
Youtube : https://www.youtube.com/watch?v=NyO99OJPPec
블로그 : https://heegs.tistory.com/174
먼저 Modifier를 알아보면 아래와 같다.
관련 공식 문서링크는 아래와 같다.
https://developer.android.com/develop/ui/compose/modifiers?hl=ko
수정자를 사용하면 컴포저블을 장식하거나 강화할 수 있습니다. 수정자를 통해 다음과 같은 종류의 작업을 실행할 수 있습니다.
- 컴포저블의 크기, 레이아웃, 동작 및 모양 변경
- 접근성 라벨과 같은 정보 추가
- 사용자 입력 처리
- 요소를 클릭 가능, 스크롤 가능, 드래그 가능 또는 확대/축소 가능하게 만드는 높은 수준의 상호작용 추가
즉, 컴포저블의 크기를 측정하고 이에 대한 레이아웃 동작 및 모양을 변경하기 위해 Modifier를 확장자로 사용해서 shimmerEffect 함수를 구현했다.
shimmerEffect 함수 코드는 아래와 같다.
fun Modifier.shimmerEffect(): Modifier = composed {
var size by remember {
mutableStateOf(IntSize.Zero)
}
val transition = rememberInfiniteTransition()
val startOffsetX by transition.animateFloat(
initialValue = -2 * size.width.toFloat(),
targetValue = 2 * size.width.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000
)
)
)
background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFFB8B5B5),
Color(0xFF8F8B8B),
Color(0xFFB8B5B5),
),
start = Offset(startOffsetX, 0f),
end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
)
)
.onGloballyPositioned {
size = it.size
}
}
위에서 부터 천천히 알아보자.
먼저 size는 컴포저블의 크기를 측정한 값을 저장하는 용도로 사용이 되었다.
Box를 이용해 크기를 지정을 했다면 아래와 같이 x,y의 정보를 얻는 것이다.

이 정보를 아래 코드에 보면 onGloballyPositioned를 통해 얻을 수 있다. 이 값은 해당 컴포저블의 크기와 위치 정보를 가지고 있다. 만약 크기의 정보만 사용하고 싶은 경우는 onSizeChanged를 써서 값을 가져올 수 있다. 아마 onSizeChanged가 좀 더 가볍다고는 알고있다.
여기서는 size 정보만 가져오기 때문에 onSizeChanged로 대체 가능하다. onSizeChanged는 IntSize를 받을 수 있기 때문에 그대로 it을 하면 된다.
onSizeChanged {
size = it
}
다음은 애니메이션 역할을 하는 transition코드다. transition은 애니메이션 반복처리를 할 때 주로 사용한다. 공식 문서 링크는 아래 참조하자.
https://developer.android.com/develop/ui/compose/animation/value-based?hl=ko#rememberinfinitetransition
val transition = rememberInfiniteTransition()
val startOffsetX by transition.animateFloat(
initialValue = -2 * size.width.toFloat(),
targetValue = 2 * size.width.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000
)
)
)
위 코드처럼 Float값이 변경될 때 하는 애니메이션 이외에도 color및. T의 객체에 따른 애니메이션 동작이 가능하다.

animateFloat의 내부 코드는 아래와 같다. 참고로 내부 코드를 들어가면 간단한 사용 예시 코드를 보여주므로 참고해서 구현이 가능하다.
@Composable
fun InfiniteTransition.animateFloat(
initialValue: Float,
targetValue: Float,
animationSpec: InfiniteRepeatableSpec<Float>,
label: String = "FloatAnimation"
): State<Float> =
animateValue(initialValue, targetValue, Float.VectorConverter, animationSpec, label)
initialValue과 targetValue는 초기 위치에서 targetValue까지 애니메이션이 동작하는 방향이다. 애니메이션은 animationSpec으로 동작이 이루어진다.
여기서 animationSpec은 InfiniteRepeatableSpec으로 되어 있다. 해당 관련 문서는 아래 링크를 참조하자.
https://developer.android.com/develop/ui/compose/animation/customize?hl=ko#infiniterepeatable
repeatMode로 리버스도 가능하게 해서 꽤나 유용하게 사용할 수 있다.
이제 이 애니메이션을 통해 background 코드를 작성하면 된다.
background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFFB8B5B5),
Color(0xFF8F8B8B),
Color(0xFFB8B5B5),
),
start = Offset(startOffsetX, 0f),
end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
)
)
먼저 Brush를 사용하여 gradient 배경화면을 만들고 start와 end에 animationFloat인 startOffsetX를 사용하면 애니메이션 위치에 맞게 Brush가 이동이 되는 방법이다.
Brush에 관련된 문서는 아래 링크를 참고하자.
https://developer.android.com/develop/ui/compose/graphics/draw/brush?hl=ko
이제 이 modifier를 이용한 UI 컴포저블을 만들고 실행해보자.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
var isLoading by remember {
mutableStateOf(true)
}
LaunchedEffect(Unit) {
delay(5000)
isLoading = false
}
ShimmerEffectTheme {
Scaffold { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(20) {
ShimmerListItem(
isLoading = isLoading,
contentAfterLoading = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(100.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "This is a long text to show that our shimmer display" +
"is looking perfectly fine"
)
}
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
}
@Composable
fun ShimmerListItem(
isLoading: Boolean,
contentAfterLoading: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
//isLoading이면 Shimmer UI 적용하기
if(isLoading) {
Row(
modifier = modifier
.padding(16.dp)
) {
Box(
modifier = Modifier
.size(100.dp)
//여기 Modifier에 shimmerEffect() 함수 사용
.shimmerEffect()
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier
.weight(1f)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.shimmerEffect()
)
Spacer(Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(20.dp)
.shimmerEffect()
)
}
}
} else {
contentAfterLoading()
}
}
한 5초동안 loading 상태 후 화면이 보이는 UI를 그렸다. 아래는 실행 화면이다.
만든 코드는 아래 깃허브를 통해 확인
https://github.com/Yoon-Chan/AndroidToyProjects/tree/main/ShimmerEffec