Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b433e4f
✨ feat(ui): 스켈레톤 로딩 효과 개선 및 마스킹 방식 적용
ugmin1030 Jul 14, 2026
8635394
✨ feat(ui): 폴더 아이템 스켈레톤 컴포넌트 추가
ugmin1030 Jul 14, 2026
024bd99
✨ feat(ui): 폴더 로딩 그리드 컴포넌트 추가
ugmin1030 Jul 14, 2026
998ef12
📝 docs(ui): SkeletonFolderItem 내 KDoc 및 상세 주석 추가
ugmin1030 Jul 14, 2026
a65324c
📝 docs(ui): skeleton Modifier KDoc 추가 및 주석 개선
ugmin1030 Jul 14, 2026
a14aa3b
✨ feat(ui): ShadowStyle 정의 및 Modifier.shadow 확장 함수 추가
ugmin1030 Jul 16, 2026
9a57c47
📝 docs(ui): skeleton Modifier 렌더링 조건 설명 보강
ugmin1030 Jul 25, 2026
23ad155
📝 docs(ui): 로딩 폴더 그리드 간격 계산 설명 정정
ugmin1030 Jul 25, 2026
3e98113
♻️ refactor(ui): 빈 폴더 카드에 기본 팔레트 재사용
ugmin1030 Jul 25, 2026
5bf6101
🎨 style(ui): 기본 폴더 마스크 그라데이션 통일
ugmin1030 Jul 25, 2026
8e7a6a2
✨ feat(ui): Compose 폴더 마스크 도형 추가
ugmin1030 Jul 25, 2026
ed2ffa2
♻️ refactor(ui): 폴더 카드 마스크를 Compose로 전환
ugmin1030 Jul 25, 2026
d092ee5
✨ feat(ui): 폴더 마스크 기본 스타일과 Preview 추가
ugmin1030 Jul 25, 2026
06a2e41
✨ feat(ui): 폴더 로딩 카드를 정적 placeholder로 개편
ugmin1030 Jul 25, 2026
d9588aa
✨ feat(ui): 폴더 마스크에 Figma 그림자와 기본 채움 적용
ugmin1030 Jul 25, 2026
72bbd3e
✨ feat(ui): 폴더 마스크 backdrop blur 합성
ugmin1030 Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions design/src/main/java/com/linku/design/modifier/Shadow.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.linku.design.modifier

import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.DefaultShadowColor
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Immutable
data class ShadowStyle(
val elevation: Dp,
val shape: Shape = RectangleShape,
val clip: Boolean = elevation > 0.dp,
val ambientColor: Color = DefaultShadowColor,
val spotColor: Color = DefaultShadowColor
)

fun Modifier.shadow(
style: ShadowStyle
): Modifier = shadow(
elevation = style.elevation,
shape = style.shape,
clip = style.clip,
ambientColor = style.ambientColor,
spotColor = style.spotColor
)
156 changes: 92 additions & 64 deletions design/src/main/java/com/linku/design/modifier/Skeleton.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,57 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.linku.design.theme.linkuColors
import com.linku.design.theme.linkuFont
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer

/**
* UI 구성 요소에 스켈레톤(Skeleton) 로딩 효과를 적용합니다.
* 로딩 상태의 컴포저블에 스켈레톤 shimmer 효과를 적용하는 [Modifier]입니다.
*
* [isLoading]이 true일 때, 선형 그라데이션 애니메이션을 콘텐츠 위에 덮어씌워
* 데이터가 로딩 중임을 나타내는 플레이스홀더 효과를 생성합니다.
* 이 modifier는 대상 컴포저블의 실제 콘텐츠를 먼저 그린 뒤, 그 콘텐츠가 차지하는 픽셀 영역에만
* shimmer 그라데이션을 합성합니다. 즉, 단순히 크기만 가진 빈 [androidx.compose.foundation.layout.Box]에
* 이 modifier를 붙이면 마스크로 사용할 픽셀이 없기 때문에 스켈레톤이 보이지 않을 수 있습니다.
* placeholder로 사용할 영역은 [androidx.compose.material3.Surface], `background`, `Image`처럼
* 실제로 그려지는 콘텐츠를 포함해야 합니다.
*
* @param isLoading 스켈레톤 효과를 활성화할지 여부입니다.
* @param baseColor 스켈레톤의 기본 배경색입니다.
* @param highlightColor 스켈레톤 위를 지나가는 하이라이트(반짝임)의 색상입니다.
* @param durationMillis 하이라이트 애니메이션이 한 번 반복되는 데 걸리는 시간(밀리초)입니다.
* @return 스켈레톤 효과가 적용된 [Modifier]를 반환합니다.
* 스켈레톤의 모양은 이 modifier가 적용된 컴포저블이 최종적으로 그리는 픽셀을 기준으로 결정됩니다.
* 따라서 size, shape, clip, alpha, child content의 배치가 그대로 스켈레톤 마스크가 됩니다. 예를 들어
* [androidx.compose.foundation.shape.RoundedCornerShape]로 clip된 Surface에 적용하면 둥근 사각형
* 스켈레톤이 되고, 원형 Surface에 적용하면 원형 스켈레톤이 됩니다.
*
* @sample
* val isLoading = remember { mutableStateOf(false) }
* Box(
* [isLoading]이 `false`이면 추가 draw 단계와 무한 애니메이션을 만들지 않고 기존 [Modifier]
* 체인을 그대로 반환합니다. 로딩이 끝난 뒤 실제 UI를 보여줄 때도 같은 modifier 체인을 유지할 수
* 있도록 하기 위한 동작이며, 로딩 여부에 따라 호출부의 modifier 구성을 분기하지 않아도 됩니다.
*
* 내부에서는 [CompositingStrategy.Offscreen]으로 별도 버퍼에 대상 컴포저블을 먼저 렌더링한 뒤,
* [BlendMode.SrcIn]을 사용해 실제로 그려진 픽셀 영역에만 [Brush.linearGradient]를 합성합니다.
* 이 처리가 없으면 shimmer 그라데이션이 현재 컴포저블 바깥이나 다른 컴포저블의 배경까지 영향을
* 줄 수 있습니다. [BlendMode.SrcIn]은 이미 그려진 원본 콘텐츠의 alpha를 마스크로 삼기 때문에,
* 원본 콘텐츠가 투명한 부분에는 shimmer가 합성되지 않습니다.
*
* 사용 예:
* ```
* Surface(
* modifier = Modifier
* .fillMaxSize()
* .skeleton(isLoading = isLoading)
* )
* .size(width = 120.dp, height = 16.dp)
* .skeleton(isLoading = uiState.isLoading),
* shape = RoundedCornerShape(4.dp),
* color = Color.LightGray
* ) {}
* ```
*
* @param isLoading `true`이면 shimmer 스켈레톤을 표시하고, `false`이면 원본 modifier를 그대로 사용합니다.
* @param baseColor 스켈레톤의 기본 배경색입니다. shimmer 그라데이션의 시작과 끝 색상으로 사용됩니다.
* @param highlightColor shimmer가 지나가는 중앙 하이라이트 색상입니다. [baseColor]보다 밝게 지정하면
* 이동하는 빛 효과가 더 분명해집니다.
* @param durationMillis 대각선으로 기울어진 shimmer 그라데이션이 왼쪽에서 오른쪽으로 한 번 이동하는 데
* 걸리는 시간입니다. 값이 작을수록 shimmer가 빠르게 움직입니다.
*/
fun Modifier.skeleton(
isLoading: Boolean,
Expand All @@ -50,56 +64,70 @@ fun Modifier.skeleton(
durationMillis: Int = 1000
): Modifier = composed {

if (!isLoading) return@composed this
if (!isLoading) {
return@composed this
}

// 애니메이션 상태
val transition = rememberInfiniteTransition(label = "skeleton")
val transition = rememberInfiniteTransition(
label = "skeleton"
)

val progress = transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis, easing = LinearEasing)
animation = tween(
durationMillis = durationMillis,
easing = LinearEasing
)
),
label = "progress"
label = "skeletonProgress"
)

this.drawWithCache {
this
.graphicsLayer {
/*
* 현재 컴포저블을 별도 버퍼에 먼저 그립니다.
*
* BlendMode가 화면 전체나 다른 컴포저블의 배경에 영향을 주지 않고,
* 현재 컴포저블 내부에서만 동작하도록 하기 위해 필요합니다.
*/
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithCache {
val width = size.width
val height = size.height

val width = size.width
val height = size.height
val brush = Brush.linearGradient(
colors = listOf(
baseColor,
highlightColor,
baseColor
),
start = Offset(
x = progress.value * width - width,
y = 0f
),
end = Offset(
x = progress.value * width,
y = height
)
)

val brush = Brush.linearGradient(
colors = listOf(baseColor, highlightColor, baseColor),
start = Offset(progress.value * width - width, 0f),
end = Offset(progress.value * width, height)
)
onDrawWithContent {
/*
* 먼저 원본 컴포저블을 그립니다.
* 이 원본의 알파 값이 그라데이션을 입힐 마스크가 됩니다.
*/
drawContent()

onDrawWithContent {
// 기존 UI를 덮어버림 (skeleton 상태)
drawRect(brush)
/*
* 원본 컴포저블이 실제로 그려진 픽셀 영역에만 그라데이션을 합성합니다.
*/
drawRect(
brush = brush,
blendMode = BlendMode.SrcIn
)
}
}
}
}

@Preview(showBackground = true)
@Composable
private fun SkeletonPreview() {
val isLoading = remember { mutableStateOf(false) }

Text(
modifier = Modifier
.fillMaxSize()
.noRippleClickable {
isLoading.value = !isLoading.value
}
.skeleton(
isLoading = isLoading.value
),
text = "Click",
textAlign = TextAlign.Center,
fontSize = 25.sp,
fontFamily = MaterialTheme.linkuFont.font,
color = MaterialTheme.linkuColors.black
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.linku.file.ui.content

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.linku.file.ui.item.items.SkeletonFolderItem
import com.linku.file.ui.theme.LinkU_AndroidTheme

/**
* 폴더 카드 사이의 세로 간격 기준값입니다.
*
* 실제 폴더 그리드의 카드 간격과 로딩 placeholder 간격을 맞추기 위한 값입니다. 스켈레톤 화면과 실제 데이터
* 화면의 간격이 다르면 로딩 완료 시 카드 위치가 튀어 보일 수 있으므로, 세로 방향은 고정 dp 간격으로 유지합니다.
*/
private const val INTER_LAYER_PADDING = 18.51

/**
* 사용 가능한 가로 폭 대비 스켈레톤 카드 사이의 가로 간격 비율입니다.
*
* 2열 그리드에서 카드 너비가 화면 폭에 따라 달라지기 때문에 고정 dp 대신 좌우 content padding을 제외한
* 전체 가용 폭에 이 비율을 곱해 수평 간격을 만듭니다. `10f / 174f`는 실제 폴더 그리드와 같은 계산식을
* 유지하기 위한 계수이며, 개별 174dp 카드에 정확히 10dp 간격을 적용한다는 의미는 아닙니다.
*/
private const val ITEM_RATIO = 10f / 174f

/**
* 폴더 데이터를 불러오는 동안 표시되는 로딩 스켈레톤 그리드 화면입니다.
*
* 실제 폴더 목록이 도착하기 전까지 [SkeletonFolderItem]을 반복해서 보여줍니다. 화면 구조는 실제 폴더
* 목록과 같은 2열 [LazyVerticalGrid]를 사용하므로, 로딩 상태에서 데이터 표시 상태로 전환될 때 사용자가
* 보는 카드 위치와 스크롤 흐름이 크게 달라지지 않습니다.
*
* 이 컴포저블은 [BoxWithConstraints]로 부모가 제공한 최대 너비를 읽고, [contentPadding]의 start/end
* 여백 합계를 뺀 실제 콘텐츠 폭을 계산합니다. 수평 간격은
* `(maxWidth - startPadding - endPadding) * ITEM_RATIO`로 계산하므로, 화면 크기나 좌우 padding이
* 달라져도 실제 폴더 그리드와 같은 비율을 유지합니다.
*
* 세로 간격은 [INTER_LAYER_PADDING]을 dp로 변환해 고정값으로 사용합니다. 수직 방향은 화면 폭 변화의 영향을
* 직접 받지 않기 때문에, 실제 폴더 그리드와 같은 카드 간격을 안정적으로 유지하는 데 초점을 둡니다.
*
* 현재는 실제 데이터 개수와 무관하게 로딩 placeholder 10개를 그리드에 제공하여, 2열 기준 총 5개의
* 논리 행을 구성합니다. [LazyVerticalGrid]가 실제로 compose하는 행의 수는 화면에 보이는 범위에 따라
* 달라질 수 있습니다.
*
* @param modifier 그리드 전체 컨테이너에 적용할 [Modifier]입니다.
* @param contentPadding 그리드 내부 콘텐츠에 적용할 여백입니다. start/end padding의 합을 `maxWidth`에서
* 뺀 뒤 실제 아이템이 배치되는 콘텐츠 영역을 기준으로 수평 간격을 계산합니다.
*/
@Composable
internal fun LoadingFoldersGrid(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(top = 20.dp, start = 20.dp, end = 20.dp, bottom = 60.dp),
) {
val layoutDirection = LocalLayoutDirection.current

BoxWithConstraints(
modifier = modifier.fillMaxSize()
) {
val horizontalPadding =
contentPadding.calculateStartPadding(layoutDirection) +
contentPadding.calculateEndPadding(layoutDirection)
val availableWidth = maxWidth - horizontalPadding
val horizontalSpacing = availableWidth * ITEM_RATIO

LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(INTER_LAYER_PADDING.dp),
horizontalArrangement = Arrangement.spacedBy(horizontalSpacing),
) {
items(
count = 10
) {
SkeletonFolderItem(Modifier.fillMaxSize())
}
}
}
}

@Preview(showBackground = true)
@Composable
private fun LoadingFoldersGridPreview() {
LinkU_AndroidTheme {
LoadingFoldersGrid()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.linku.file.ui.content

internal fun LoadingLinksGrid(){

}
Loading