diff --git a/design/src/main/java/com/linku/design/modifier/Shadow.kt b/design/src/main/java/com/linku/design/modifier/Shadow.kt new file mode 100644 index 000000000..8e7519a9c --- /dev/null +++ b/design/src/main/java/com/linku/design/modifier/Shadow.kt @@ -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 +) \ No newline at end of file diff --git a/design/src/main/java/com/linku/design/modifier/Skeleton.kt b/design/src/main/java/com/linku/design/modifier/Skeleton.kt index da806c800..c3aafb853 100644 --- a/design/src/main/java/com/linku/design/modifier/Skeleton.kt +++ b/design/src/main/java/com/linku/design/modifier/Skeleton.kt @@ -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, @@ -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 - ) -} \ No newline at end of file diff --git a/feature/file/src/main/java/com/linku/file/ui/content/LoadingFoldersGrid.kt b/feature/file/src/main/java/com/linku/file/ui/content/LoadingFoldersGrid.kt new file mode 100644 index 000000000..d16f869f8 --- /dev/null +++ b/feature/file/src/main/java/com/linku/file/ui/content/LoadingFoldersGrid.kt @@ -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() + } +} diff --git a/feature/file/src/main/java/com/linku/file/ui/content/LoadingLinksGrid.kt b/feature/file/src/main/java/com/linku/file/ui/content/LoadingLinksGrid.kt new file mode 100644 index 000000000..1d13f291c --- /dev/null +++ b/feature/file/src/main/java/com/linku/file/ui/content/LoadingLinksGrid.kt @@ -0,0 +1,5 @@ +package com.linku.file.ui.content + +internal fun LoadingLinksGrid(){ + +} \ No newline at end of file diff --git a/feature/file/src/main/java/com/linku/file/ui/item/FolderItemLayout.kt b/feature/file/src/main/java/com/linku/file/ui/item/FolderItemLayout.kt index 53c8e6fda..55d58470d 100644 --- a/feature/file/src/main/java/com/linku/file/ui/item/FolderItemLayout.kt +++ b/feature/file/src/main/java/com/linku/file/ui/item/FolderItemLayout.kt @@ -1,6 +1,5 @@ package com.linku.file.ui.item -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,40 +24,38 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.linku.design.modifier.innerRingShadow import com.linku.design.theme.linkuColors -import com.linku.file.R /** * 폴더 카드의 기본 시각 구조를 그리는 공통 레이아웃입니다. * - * 이 컴포저블은 카드 배경, 겹쳐진 폴더 레이어, 마스크 이미지, 좌우 아이콘 영역, + * 이 컴포저블은 카드 배경, 겹쳐진 폴더 레이어, 도형 기반 하단 [FolderMask], 좌우 아이콘 영역, * 폴더명 배지를 한 번에 구성합니다. 실제 카드 크기는 외부 [modifier]와 내부 기준 * 비율을 함께 사용해 결정되며, 내부 요소는 [BoxWithConstraints]에서 계산한 스케일을 * 기준으로 함께 축소/확대됩니다. * * @param modifier 카드의 외부 크기와 배치를 결정하는 [Modifier]입니다. - * @param backgroundColor 카드 Surface 배경색입니다. + * @param backgroundColor 카드 바탕과 [FolderMask] 내부의 불투명 기본 영역을 채우는 색상입니다. * @param color1 뒤쪽에 배치되는 첫 번째 폴더 레이어 색상입니다. * @param color2 가운데에 배치되는 두 번째 폴더 레이어 색상입니다. * @param color3 앞쪽에 배치되는 세 번째 폴더 레이어 색상입니다. - * @param folderMaskBrush 하단 폴더 마스크 이미지에 입힐 브러시입니다. + * @param folderMaskBrush 하단 [FolderMask]의 불투명 기본 레이어 위에 합성할 전면 브러시입니다. * @param leftIcon 카드 왼쪽 상단에 배치할 아이콘 슬롯입니다. * @param rightIcon 카드 오른쪽 상단에 배치할 아이콘 슬롯입니다. * @param textBackgroundColor 폴더명 첫 글자 배지의 배경색입니다. @@ -79,19 +76,27 @@ fun FolderItemLayout( ) { /** 폴더명 배지와 텍스트에 사용할 LinkU 테마 색상 팔레트입니다. */ val colors = MaterialTheme.linkuColors + val backdropLayer = rememberGraphicsLayer() // 디자인 원본 기준 사이즈입니다. 카드 비율과 내부 요소 스케일 계산의 기준값으로 사용합니다. val baseW = 165.3.dp val baseH = 145.8535.dp val aspect = baseW / baseH + val cardShape = RoundedCornerShape(28.5.dp) - // 외부에서 전달한 modifier가 크기를 정하고, aspectRatio가 폴더 카드의 원본 비율을 유지합니다. - Surface( + // 카드 자체의 모양은 유지하되, 폴더 마스크 그림자가 카드 경계 밖에서도 보이도록 자식은 자르지 않습니다. + Box( modifier = modifier - .aspectRatio(aspect, matchHeightConstraintsFirst = false), - shape = RoundedCornerShape(28.5.dp), - color = backgroundColor, - shadowElevation = 3.8.dp + .aspectRatio(aspect, matchHeightConstraintsFirst = false) + .shadow( + elevation = 3.8.dp, + shape = cardShape, + clip = false, + ) + .background( + color = backgroundColor, + shape = cardShape, + ) ) { /** * 실제 배치된 카드 크기를 기준으로 폴더 레이어, 아이콘 위치, 폰트 크기를 함께 스케일링합니다. @@ -107,6 +112,8 @@ fun FolderItemLayout( // 디자인 기준 dp, sp를 현재 카드 크기에 맞게 변환하는 헬퍼입니다. fun s(dp: Dp) = dp * scale fun ssp(sp: TextUnit) = (sp.value * scale).sp + val maskHeight = s(116.dp) + val maskTopOffset = maxHeight - maskHeight /** * 폴더 일러스트를 구성하는 단일 레이어를 현재 카드 크기에 맞춰 그립니다. @@ -153,33 +160,46 @@ fun FolderItemLayout( ) {} } - /** 폴더 레이어와 하단 마스크를 카드 중앙 기준으로 겹쳐 배치하는 루트 컨테이너입니다. */ + /** 폴더 레이어와 하단 마스크를 하나의 카드 좌표계에 겹쳐 배치하는 루트 컨테이너입니다. */ Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), ) { - // 첫 번째 레이어: 가장 뒤쪽 종이입니다. 원본: 105.45, padding bottom 5.7, rot -7.39 - FolderLayerBox( - color = color1, - size = 105.45.dp, - padding = PaddingValues(bottom = 5.7.dp), - rotation = -7.39f - ) - // 두 번째 레이어: 가운데 종이입니다. 원본: 105.45, padding bottom 3.1825, rot 4.86 - FolderLayerBox( - color = color2, - size = 105.45.dp, - padding = PaddingValues(bottom = 3.1825.dp), - rotation = 4.86f - ) - // 세 번째 레이어: 가장 앞쪽 흰색/컬러 종이입니다. 원본: size 126.407 x 107.9605, padding top 7.6 - FolderLayerBox( - color = color3, - size = 126.407.dp, - height = 107.9605.dp, - padding = PaddingValues(top = 7.6.dp), - rotation = 0f - ) + // 폴더 종이 레이어를 backdropLayer에 기록한 뒤 같은 콘텐츠를 현재 카드에도 그립니다. + Box( + modifier = Modifier + .fillMaxSize() + .drawWithContent { + backdropLayer.renderEffect = null + backdropLayer.record { + this@drawWithContent.drawContent() + } + drawContent() + }, + contentAlignment = Alignment.Center, + ) { + // 첫 번째 레이어: 가장 뒤쪽 종이입니다. 원본: 105.45, padding bottom 5.7, rot -7.39 + FolderLayerBox( + color = color1, + size = 105.45.dp, + padding = PaddingValues(bottom = 5.7.dp), + rotation = -7.39f + ) + // 두 번째 레이어: 가운데 종이입니다. 원본: 105.45, padding bottom 3.1825, rot 4.86 + FolderLayerBox( + color = color2, + size = 105.45.dp, + padding = PaddingValues(bottom = 3.1825.dp), + rotation = 4.86f + ) + // 세 번째 레이어: 가장 앞쪽 흰색/컬러 종이입니다. 원본: size 126.407 x 107.9605, padding top 7.6 + FolderLayerBox( + color = color3, + size = 126.407.dp, + height = 107.9605.dp, + padding = PaddingValues(top = 7.6.dp), + rotation = 0f + ) + } /** 하단 폴더 마스크, 아이콘 슬롯, 폴더명 배지를 배치하는 영역입니다. */ Box( @@ -187,27 +207,27 @@ fun FolderItemLayout( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - // 마스크 이미지는 카드 하단을 채우고 전달받은 브러시로 카테고리별 색감을 입힙니다. - Image( - painter = painterResource(R.drawable.folder_mask), - contentScale = ContentScale.FillWidth, - contentDescription = null, + // Compose FolderMask는 캡처한 뒤쪽 폴더 레이어와 전달받은 전면 브러시를 카드 하단에 합성합니다. + FolderMask( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) - .graphicsLayer(alpha = 0.99f) - .drawWithCache { - // 원본 마스크 이미지를 그린 뒤 브러시를 SrcAtop으로 덮어 폴더 하단 색상을 만듭니다. - onDrawWithContent { - drawContent() - drawRect(folderMaskBrush, blendMode = BlendMode.SrcAtop) - } - } - .shadow( - elevation = 9.5.dp, - ambientColor = Color.Black.copy(alpha = 0.5f), - spotColor = Color.Black.copy(alpha = 0.5f), - ) + .height(maskHeight) + .align(Alignment.BottomCenter), + brush = folderMaskBrush, + baseColor = backgroundColor, + shadow = Shadow( + radius = 10.dp, + spread = 0.dp, + offset = DpOffset( + x = 0.dp, + y = (-4).dp, + ), + color = Color.Black, + alpha = 0.1f, + ), + backdropLayer = backdropLayer, + backdropOffsetY = maskTopOffset, + backdropBlurRadius = 4.dp, ) // 왼쪽 상단 슬롯입니다. 공유/잠금 아이콘처럼 폴더 상태를 나타내는 아이콘을 배치합니다. diff --git a/feature/file/src/main/java/com/linku/file/ui/item/FolderMask.kt b/feature/file/src/main/java/com/linku/file/ui/item/FolderMask.kt new file mode 100644 index 000000000..cc11f5387 --- /dev/null +++ b/feature/file/src/main/java/com/linku/file/ui/item/FolderMask.kt @@ -0,0 +1,135 @@ +package com.linku.file.ui.item + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.linku.design.theme.LinkuPreview +import com.linku.design.theme.linkuColors + +private val FolderMaskShape = GenericShape { size, _ -> + val sx = size.width / 174f + val sy = size.height / 116f + + fun x(value: Float) = value * sx + fun y(value: Float) = value * sy + + moveTo(x(0f), y(25.77f)) + cubicTo(x(0f), y(11.96f), x(11.19f), y(0.77f), x(25f), y(0.77f)) + lineTo(x(46.8f), y(0.77f)) + cubicTo(x(51.82f), y(0.77f), x(56.56f), y(3.05f), x(59.68f), y(6.98f)) + cubicTo(x(62.81f), y(10.91f), x(67.55f), y(13.2f), x(72.57f), y(13.2f)) + lineTo(x(149f), y(13.2f)) + cubicTo(x(162.81f), y(13.2f), x(174f), y(24.39f), x(174f), y(38.2f)) + lineTo(x(174f), y(91f)) + cubicTo(x(174f), y(104.81f), x(162.81f), y(116f), x(149f), y(116f)) + lineTo(x(25f), y(116f)) + cubicTo(x(11.19f), y(116f), x(0f), y(104.81f), x(0f), y(91f)) + + close() +} + +@Composable +internal fun FolderMask( + modifier: Modifier +) { + val colors = MaterialTheme.linkuColors + + FolderMask( + modifier = modifier, + brush = Brush.verticalGradient( + colorStops = arrayOf( + 1.0f to colors.gray[100].copy(alpha = 0.7f), + 0.2f to colors.gray[200].copy(alpha = 1.0f), + ) + ), + baseColor = colors.gray[200], + shadow = null, + ) +} + +@Composable +internal fun FolderMask( + modifier: Modifier, + brush: Brush, + baseColor: Color, + shadow: Shadow? = null, + backdropLayer: GraphicsLayer? = null, + backdropOffsetY: Dp = 0.dp, + backdropBlurRadius: Dp = 0.dp, +) { + Box( + modifier = modifier, + ) { + if (shadow != null) { + Box( + modifier = Modifier + .fillMaxSize() + .dropShadow( + shape = FolderMaskShape, + shadow = shadow, + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .clip(FolderMaskShape) + .drawWithCache { + val backdropOffsetYPx = backdropOffsetY.toPx() + val blurRadiusPx = backdropBlurRadius.toPx() + val blurEffect = if (backdropLayer != null && blurRadiusPx > 0f) { + BlurEffect( + radiusX = blurRadiusPx, + radiusY = blurRadiusPx, + ) + } else { + null + } + + onDrawBehind { + drawRect(color = baseColor.copy(alpha = 1f)) + + backdropLayer?.let { layer -> + layer.renderEffect = blurEffect + translate(top = -backdropOffsetYPx) { + drawLayer(layer) + } + } + + drawRect(brush = brush) + } + } + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun FolderMaskPreview() { + LinkuPreview { + FolderMask( + Modifier + .fillMaxWidth() + .height(116.dp) + ) + } +} diff --git a/feature/file/src/main/java/com/linku/file/ui/item/items/CategoryItemLayout.kt b/feature/file/src/main/java/com/linku/file/ui/item/items/CategoryItemLayout.kt index c064fcac3..a5d1ca4f7 100644 --- a/feature/file/src/main/java/com/linku/file/ui/item/items/CategoryItemLayout.kt +++ b/feature/file/src/main/java/com/linku/file/ui/item/items/CategoryItemLayout.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import com.linku.core.model.FolderSimpleInfo import com.linku.design.modifier.noRippleClickable import com.linku.design.theme.color.CategoryColorStyle @@ -29,12 +28,7 @@ fun CategoryItemLayout( color1 = colorStyle.color3, color2 = colorStyle.color2, color3 = colorStyle.color1, - folderMaskBrush = Brush.verticalGradient( - colorStops = arrayOf( - 1.0f to colors.gray[100].copy(alpha = 0.7f), - 0.2f to colors.gray[200].copy(alpha = 1.0f), - ) - ), + folderMaskBrush = CategoryColorStyle.DEFAULT.verticalGradient(), leftIcon = {}, rightIcon = { if (visibleBookmarked) { diff --git a/feature/file/src/main/java/com/linku/file/ui/item/items/EmptyFolderItemLayout.kt b/feature/file/src/main/java/com/linku/file/ui/item/items/EmptyFolderItemLayout.kt index abd215a26..8bf10e3fd 100644 --- a/feature/file/src/main/java/com/linku/file/ui/item/items/EmptyFolderItemLayout.kt +++ b/feature/file/src/main/java/com/linku/file/ui/item/items/EmptyFolderItemLayout.kt @@ -3,7 +3,7 @@ package com.linku.file.ui.item.items import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import com.linku.design.theme.color.CategoryColorStyle import com.linku.design.theme.linkuColors import com.linku.file.ui.item.FolderItemLayout @@ -12,22 +12,17 @@ fun EmptyFolderItemLayout( modifier: Modifier = Modifier, folderName: String = "" ) { - val colors = MaterialTheme.linkuColors + val color = CategoryColorStyle.DEFAULT FolderItemLayout( - backgroundColor = colors.gray[200], - color1 = colors.gray[300], - color2 = colors.gray[200], - color3 = colors.white, - folderMaskBrush = Brush.verticalGradient( - colorStops = arrayOf( - 1.0f to colors.gray[100].copy(alpha = 0.7f), - 0.2f to colors.gray[200].copy(alpha = 1.0f), - ) - ), + backgroundColor = color.color2, + color1 = color.color3, + color2 = color.color2, + color3 = MaterialTheme.linkuColors.white, + folderMaskBrush = color.verticalGradient(), leftIcon = {}, rightIcon = {}, - textBackgroundColor = colors.gray[500], + textBackgroundColor = color.color4, folderName = folderName, modifier = modifier ) diff --git a/feature/file/src/main/java/com/linku/file/ui/item/items/SkeletonFolderItem.kt b/feature/file/src/main/java/com/linku/file/ui/item/items/SkeletonFolderItem.kt new file mode 100644 index 000000000..9e282515d --- /dev/null +++ b/feature/file/src/main/java/com/linku/file/ui/item/items/SkeletonFolderItem.kt @@ -0,0 +1,145 @@ +package com.linku.file.ui.item.items + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.linku.design.theme.LinkuPreview +import com.linku.design.theme.linkuColors +import com.linku.file.ui.item.FolderMask + +/** + * 폴더 카드 스켈레톤의 기준 너비입니다. + * + * [SkeletonFolderItem] 내부의 하단 마스크 높이, 원형 배지, 텍스트 placeholder, 간격과 padding은 이 + * 너비를 기준으로 정의된 dp 값을 현재 셀 크기에 맞게 비례 확대/축소합니다. + */ +private const val baseW = 174f + +/** + * 폴더 카드 스켈레톤의 기준 높이입니다. + * + * [baseW]와 함께 Figma 스켈레톤 프레임의 카드 비율을 만들며, [aspect] 계산에 사용됩니다. 그리드 셀의 + * 너비가 달라져도 스켈레톤이 찌그러지지 않고 같은 비율로 배치되도록 하는 기준값입니다. + */ +private const val baseH = 154.041f + +/** + * 폴더 카드 스켈레톤이 유지해야 하는 너비 대비 높이 비율입니다. + * + * [SkeletonFolderItem]의 최상위 [Modifier.aspectRatio]에 적용되어, 호출부가 전달한 너비 제약을 기준으로 + * Figma 스켈레톤 프레임과 같은 비율의 영역을 확보합니다. + */ +private const val aspect = baseW / baseH + +/** + * 폴더 목록을 불러오는 동안 실제 폴더 카드 대신 표시하는 스켈레톤 placeholder입니다. + * + * 이 컴포저블은 실제 폴더 카드의 전체 카드 영역, 하단 폴더 마스크, 폴더명 첫 글자 배지용 원형 + * placeholder, 폴더명용 pill placeholder를 단순한 회색 도형으로 구성합니다. 로딩 중에도 최종 카드와 + * 비슷한 크기와 시각적 밀도를 유지해서, 데이터가 도착한 뒤 실제 카드로 교체될 때 레이아웃이 크게 + * 흔들리지 않도록 합니다. + * + * 최상위 영역은 [aspect]를 사용해 원본 디자인 비율을 유지합니다. 내부에서는 [BoxWithConstraints]로 + * 현재 셀의 최대 너비와 높이를 읽은 뒤, [baseW]와 [baseH] 대비 scale 값을 계산합니다. 이 scale은 + * 하단 마스크 높이, 원형 배지, 텍스트 placeholder, 간격과 padding에 적용되어 작은 화면이나 다른 그리드 + * 폭에서도 내부 요소가 카드 비율에 맞춰 함께 줄어들거나 커지도록 합니다. 카드 바탕의 25dp 모서리 반경은 + * 내부 placeholder와 달리 고정값을 유지합니다. + * + * 배경과 내부 placeholder는 [Surface]와 [FolderMask]가 직접 그리는 고정 회색 도형입니다. 이 + * 컴포저블 자체에는 `Modifier.skeleton`을 적용하지 않으므로 shimmer 애니메이션 없이 정적 + * placeholder로 표시됩니다. + * + * @param modifier 그리드 셀이나 부모 레이아웃에서 전달하는 외부 [Modifier]입니다. 보통 셀 전체를 채우도록 + * [Modifier.fillMaxSize]를 전달하고, 이 컴포저블 내부에서 카드 비율을 다시 맞춥니다. + */ +@Composable +internal fun SkeletonFolderItem( + modifier: Modifier +) { + val colors = MaterialTheme.linkuColors + + BoxWithConstraints( + modifier = modifier + .aspectRatio(aspect, matchHeightConstraintsFirst = false) + ) { + val scaleW = maxWidth / baseW.dp + val scaleH = maxHeight / baseH.dp + val scale = minOf(scaleW, scaleH) + + fun s(dp: Dp) = dp * scale + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .fillMaxSize(), + shape = RoundedCornerShape(25.dp), + color = colors.gray[200] + ) {} + + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + FolderMask( + modifier = Modifier + .fillMaxWidth() + .height(s(116.dp)) + .align(Alignment.BottomCenter) + ) + + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = s(18.dp), bottom = s(18.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(s(8.dp)) + ) { + Surface( + modifier = Modifier + .size(s(29.dp)), + shape = CircleShape, + color = colors.gray[400] + ) {} + + Surface( + modifier = Modifier + .width(s(58.dp)) + .height(s(22.dp)), + shape = RoundedCornerShape(s(8.dp)), + color = colors.gray[300] + ) {} + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SkeletonFolderItemPreview() { + LinkuPreview { + SkeletonFolderItem(Modifier.fillMaxSize()) + } +}