From b433e4fee7c1f9820eb9a619b88af7cf7a53bf6b Mon Sep 17 00:00:00 2001 From: gmin Date: Wed, 15 Jul 2026 00:15:30 +0900 Subject: [PATCH 01/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=88=EB=A0=88=ED=86=A4=20=EB=A1=9C=EB=94=A9=20=ED=9A=A8?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=82=B9=20=EB=B0=A9=EC=8B=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `skeleton` Modifier의 렌더링 방식을 `BlendMode.SrcIn` 기반으로 변경하여 실제 콘텐츠가 그려진 영역에만 효과가 적용되도록 개선 - `graphicsLayer`를 통해 `CompositingStrategy.Offscreen`을 설정하여 BlendMode가 다른 컴포넌트에 영향을 주지 않도록 격리 - `drawWithCache` 내부 로직을 수정하여 `drawContent()` 호출 후 그라데이션을 합성하는 구조로 변경 - 불필요한 프리뷰 컴포저블 및 미사용 Import 제거 - 애니메이션 라벨 명칭 변경 및 코드 포맷팅 정리 --- .../com/linku/design/modifier/Skeleton.kt | 126 ++++++++---------- 1 file changed, 57 insertions(+), 69 deletions(-) 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..a008802bb 100644 --- a/design/src/main/java/com/linku/design/modifier/Skeleton.kt +++ b/design/src/main/java/com/linku/design/modifier/Skeleton.kt @@ -5,44 +5,17 @@ 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) 로딩 효과를 적용합니다. - * - * [isLoading]이 true일 때, 선형 그라데이션 애니메이션을 콘텐츠 위에 덮어씌워 - * 데이터가 로딩 중임을 나타내는 플레이스홀더 효과를 생성합니다. - * - * @param isLoading 스켈레톤 효과를 활성화할지 여부입니다. - * @param baseColor 스켈레톤의 기본 배경색입니다. - * @param highlightColor 스켈레톤 위를 지나가는 하이라이트(반짝임)의 색상입니다. - * @param durationMillis 하이라이트 애니메이션이 한 번 반복되는 데 걸리는 시간(밀리초)입니다. - * @return 스켈레톤 효과가 적용된 [Modifier]를 반환합니다. - * - * @sample - * val isLoading = remember { mutableStateOf(false) } - * Box( - * modifier = Modifier - * .fillMaxSize() - * .skeleton(isLoading = isLoading) - * ) - */ fun Modifier.skeleton( isLoading: Boolean, baseColor: Color = Color.LightGray.copy(alpha = 0.3f), @@ -50,56 +23,71 @@ 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 { - - val width = size.width - val height = size.height - - val brush = Brush.linearGradient( - colors = listOf(baseColor, highlightColor, baseColor), - start = Offset(progress.value * width - width, 0f), - end = Offset(progress.value * width, height) - ) - - onDrawWithContent { - // 기존 UI를 덮어버림 (skeleton 상태) - drawRect(brush) + this + .graphicsLayer { + /* + * 컴포넌트를 별도의 버퍼에 그립니다. + * + * BlendMode가 화면의 다른 컴포넌트나 배경에 영향을 주지 않고 + * 현재 컴포넌트 안에서만 동작하도록 하기 위해 필요합니다. + */ + compositingStrategy = CompositingStrategy.Offscreen } - } -} + .drawWithCache { + 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 + ) + ) -@Preview(showBackground = true) -@Composable -private fun SkeletonPreview() { - val isLoading = remember { mutableStateOf(false) } + onDrawWithContent { + /* + * 먼저 원본 컴포넌트를 그립니다. + * 이 원본의 알파값이 마스크 역할을 합니다. + */ + drawContent() - Text( - modifier = Modifier - .fillMaxSize() - .noRippleClickable { - isLoading.value = !isLoading.value + /* + * 원본 컴포넌트가 실제로 그려진 픽셀에만 + * 그라데이션을 남깁니다. + */ + drawRect( + brush = brush, + blendMode = BlendMode.SrcIn + ) } - .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 From 86353949271c097941d67109e7941e074ef76328 Mon Sep 17 00:00:00 2001 From: gmin Date: Wed, 15 Jul 2026 00:15:49 +0900 Subject: [PATCH 02/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로딩 상태를 시각적으로 표현하기 위한 `SkeletonFolderItem` 컴포저블 신규 생성 - `BoxWithConstraints`와 스케일링 헬퍼 함수를 사용하여 컴포넌트 크기에 대응하는 유동적인 레이아웃 구현 - 세 개의 `Surface` 레이어에 회전값과 `skeleton` 모디파이어를 적용하여 겹쳐진 폴더 형태 구현 - 하단 마스크 이미지(`folder_mask`) 적용 및 디자인 확인을 위한 `SkeletonFolderItemPreview` 추가 --- .../file/ui/item/items/SkeletonFolderItem.kt | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 feature/file/src/main/java/com/linku/file/ui/item/items/SkeletonFolderItem.kt 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..0b8544502 --- /dev/null +++ b/feature/file/src/main/java/com/linku/file/ui/item/items/SkeletonFolderItem.kt @@ -0,0 +1,142 @@ +package com.linku.file.ui.item.items + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +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.rotate +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.linku.design.modifier.skeleton +import com.linku.design.theme.LinkuPreview +import com.linku.file.R + +private const val baseW = 165.3f +private const val baseH = 145.8535f +private const val aspect = baseW / baseH + +@Composable +internal fun SkeletonFolderItem( + modifier: Modifier +){ + + BoxWithConstraints( + modifier = modifier + .aspectRatio(aspect, matchHeightConstraintsFirst = false) + ) { + val scaleW = maxWidth / baseW.dp + val scaleH = maxHeight / baseH.dp + val scale = minOf(scaleW, scaleH) + + // 디자인 기준 dp, sp를 현재 카드 크기에 맞게 변환하는 헬퍼입니다. + fun s(dp: Dp) = dp * scale + + /** + * 폴더 일러스트를 구성하는 단일 레이어를 현재 카드 크기에 맞춰 그립니다. + * + * @param size 레이어의 기준 너비입니다. + * @param height 레이어의 기준 높이입니다. 지정하지 않으면 [size]와 같은 값으로 사용합니다. + * @param padding 기준 크기에서 적용할 외부 여백입니다. + * @param rotation 레이어에 적용할 회전 각도입니다. + */ + @Composable + fun FolderLayerBox( + size: Dp, + height: Dp = size, + padding: PaddingValues = PaddingValues(0.dp), + rotation: Float = 0f, + ) { + /** 회전과 그림자를 가진 폴더 뒷장/중간장/앞장 레이어입니다. */ + Surface( + modifier = Modifier + // 레이어별 기준 여백을 현재 카드 스케일에 맞게 변환합니다. + .padding( + PaddingValues( + start = s(padding.calculateStartPadding(LayoutDirection.Ltr)), + top = s(padding.calculateTopPadding()), + end = s(padding.calculateEndPadding(LayoutDirection.Ltr)), + bottom = s(padding.calculateBottomPadding()) + ) + ) + // 레이어마다 다른 회전값을 적용해 폴더 종이가 겹친 느낌을 만듭니다. + .rotate(rotation) + // 레이어의 기준 너비/높이를 현재 카드 크기에 맞춰 스케일링합니다. + .width(s(size)) + .height(s(height)) + .skeleton(isLoading = true), + shape = RoundedCornerShape(s(18.dp)), + ) {} + } + + /** 폴더 레이어와 하단 마스크를 카드 중앙 기준으로 겹쳐 배치하는 루트 컨테이너입니다. */ + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .skeleton(isLoading = true), + shape = RoundedCornerShape(28.5.dp) + ){} + + // 첫 번째 레이어: 가장 뒤쪽 종이입니다. 원본: 105.45, padding bottom 5.7, rot -7.39 + FolderLayerBox( + size = 105.45.dp, + padding = PaddingValues(bottom = 5.7.dp), + rotation = -7.39f + ) + + // 두 번째 레이어: 가운데 종이입니다. 원본: 105.45, padding bottom 3.1825, rot 4.86 + FolderLayerBox( + size = 105.45.dp, + padding = PaddingValues(bottom = 3.1825.dp), + rotation = 4.86f + ) + + // 세 번째 레이어: 가장 앞쪽 흰색/컬러 종이입니다. 원본: size 126.407 x 107.9605, padding top 7.6 + FolderLayerBox( + size = 126.407.dp, + height = 107.9605.dp, + padding = PaddingValues(top = 7.6.dp), + rotation = 0f + ) + + Image( + painter = painterResource(R.drawable.folder_mask), + contentScale = ContentScale.FillWidth, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .skeleton(isLoading = true) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SkeletonFolderItemPreview(){ + LinkuPreview { + SkeletonFolderItem(Modifier.fillMaxSize()) + } +} \ No newline at end of file From 024bd99b88623a6f5c7027e53261a8969dc20c8b Mon Sep 17 00:00:00 2001 From: gmin Date: Wed, 15 Jul 2026 00:21:48 +0900 Subject: [PATCH 03/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=A1=9C=EB=94=A9=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로딩 상태를 표시하기 위한 `LoadingFoldersGrid` 컴포저블 신규 생성 - `SkeletonFolderItem`을 2열 그리드(`LazyVerticalGrid`) 형식으로 배치하여 스켈레톤 UI 구현 - `BoxWithConstraints`를 활용하여 화면 너비에 따른 아이템 간의 동적 가로 간격 계산 로직 적용 - 컴포넌트 확인을 위한 `LoadingFoldersGridPreview` 추가 --- .../file/ui/content/LoadingFoldersGrid.kt | 69 +++++++++++++++++++ .../linku/file/ui/content/LoadingLinksGrid.kt | 5 ++ 2 files changed, 74 insertions(+) create mode 100644 feature/file/src/main/java/com/linku/file/ui/content/LoadingFoldersGrid.kt create mode 100644 feature/file/src/main/java/com/linku/file/ui/content/LoadingLinksGrid.kt 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..3f0806c36 --- /dev/null +++ b/feature/file/src/main/java/com/linku/file/ui/content/LoadingFoldersGrid.kt @@ -0,0 +1,69 @@ +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 + +private const val INTER_LAYER_PADDING = 18.51 +private const val ITEM_RATIO = 10f / 174f + +/** + * 폴더 데이터를 불러오는 동안 표시되는 로딩 스켈레톤 그리드 화면입니다. + * + * 2열 그리드 레이아웃을 사용하여 스켈레톤 아이템을 배치하며, 화면 너비와 + * 사전 정의된 비율([ITEM_RATIO])에 따라 아이템 간의 수평 간격을 동적으로 계산합니다. + * + * @param modifier 그리드 전체 컨테이너에 적용할 [Modifier]입니다. + * @param contentPadding 그리드 내부 콘텐츠에 적용할 여백입니다. + */ +@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 From 998ef12202fd909f5c1a61d0ff77151cc17da02e Mon Sep 17 00:00:00 2001 From: gmin Date: Wed, 15 Jul 2026 00:26:41 +0900 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=93=9D=20docs(ui):=20SkeletonFolder?= =?UTF-8?q?Item=20=EB=82=B4=20KDoc=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SkeletonFolderItem` 컴포저블의 역할과 동작 방식, 매개변수에 대한 KDoc 추가 - 내부 크기 변환 헬퍼 함수 `s(dp: Dp)`에 대한 설명 주석 추가 - 컴포넌트 내부의 배경면(Surface) 및 폴더 마스크(Image) 레이어에 대한 설명 주석 추가 --- .../linku/file/ui/item/items/SkeletonFolderItem.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 index 0b8544502..a55731de6 100644 --- 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 @@ -33,6 +33,15 @@ private const val baseW = 165.3f private const val baseH = 145.8535f private const val aspect = baseW / baseH +/** + * 폴더 아이템의 로딩 상태를 표시하는 스켈레톤 로더 컴포넌트입니다. + * + * 여러 개의 레이어를 회전시키고 겹쳐서 폴더 내부의 종이 뭉치를 표현하며, + * 하단 마스크를 통해 폴더의 외형을 완성합니다. 지정된 종횡비([aspect])를 유지하며 + * 전달된 [modifier]의 크기에 맞춰 내부 요소들이 동적으로 스케일링됩니다. + * + * @param modifier 컨테이너 레이아웃에 적용할 [Modifier] + */ @Composable internal fun SkeletonFolderItem( modifier: Modifier @@ -46,7 +55,7 @@ internal fun SkeletonFolderItem( val scaleH = maxHeight / baseH.dp val scale = minOf(scaleW, scaleH) - // 디자인 기준 dp, sp를 현재 카드 크기에 맞게 변환하는 헬퍼입니다. + /** 디자인 기준 dp, sp를 현재 카드 크기에 맞게 변환하는 헬퍼입니다. */ fun s(dp: Dp) = dp * scale /** @@ -91,6 +100,7 @@ internal fun SkeletonFolderItem( Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { + // 배경면. SkeletonFolderItem 자체가 갖는 크기를 가짐. Surface( modifier = Modifier .fillMaxSize() @@ -120,6 +130,7 @@ internal fun SkeletonFolderItem( rotation = 0f ) + // 하단 폴더 마스크. Image( painter = painterResource(R.drawable.folder_mask), contentScale = ContentScale.FillWidth, From a65324ceec4073dcf585b7ac874e344e61aa8f01 Mon Sep 17 00:00:00 2001 From: gmin Date: Wed, 15 Jul 2026 00:27:21 +0900 Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=93=9D=20docs(ui):=20skeleton=20Mod?= =?UTF-8?q?ifier=20KDoc=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `skeleton` 확장 함수에 동작 원리, 파라미터 설명 및 사용 예시를 포함한 KDoc 추가 - `CompositingStrategy.Offscreen` 및 `BlendMode.SrcIn` 사용 이유에 대한 내부 주석 구체화 - 주석 내 '컴포넌트' 용어를 '컴포저블'로 변경하여 Compose 용어 일관성 유지 --- .../com/linku/design/modifier/Skeleton.kt | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) 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 a008802bb..3945a10ec 100644 --- a/design/src/main/java/com/linku/design/modifier/Skeleton.kt +++ b/design/src/main/java/com/linku/design/modifier/Skeleton.kt @@ -5,7 +5,6 @@ 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.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithCache @@ -16,6 +15,37 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer +/** + * 로딩 상태의 컴포저블에 스켈레톤 shimmer 효과를 적용하는 [Modifier]입니다. + * + * 이 modifier는 대상 컴포저블의 실제 콘텐츠를 먼저 그린 뒤, 그 콘텐츠가 차지하는 픽셀 영역만 + * 그라데이션으로 덮어 shimmer처럼 보이게 만듭니다. 따라서 이 modifier를 적용한 컴포저블의 + * 크기, 모양, clip, alpha는 그대로 스켈레톤 마스크의 기준이 됩니다. + * + * [isLoading]이 `false`이면 추가 draw 단계와 무한 애니메이션을 만들지 않고 기존 [Modifier] + * 체인을 그대로 반환합니다. 로딩이 끝난 뒤 실제 UI를 보여줄 때도 같은 modifier 체인을 유지할 수 + * 있도록 하기 위한 동작입니다. + * + * 내부에서는 [CompositingStrategy.Offscreen]으로 별도 버퍼에 대상 컴포저블을 먼저 렌더링한 뒤, + * [BlendMode.SrcIn]을 사용해 실제로 그려진 픽셀 영역에만 [Brush.linearGradient]를 합성합니다. + * 이 처리가 없으면 shimmer 그라데이션이 현재 컴포저블 바깥이나 다른 컴포저블의 배경까지 영향을 + * 줄 수 있습니다. + * + * 사용 예: + * ``` + * Box( + * modifier = Modifier + * .size(width = 120.dp, height = 16.dp) + * .clip(RoundedCornerShape(4.dp)) + * .skeleton(isLoading = uiState.isLoading) + * ) + * ``` + * + * @param isLoading `true`이면 shimmer 스켈레톤을 표시하고, `false`이면 원본 modifier를 그대로 사용합니다. + * @param baseColor 스켈레톤의 기본 배경색입니다. 그라데이션의 양 끝 색상으로 사용됩니다. + * @param highlightColor shimmer가 지나가는 중앙 하이라이트 색상입니다. + * @param durationMillis shimmer 그라데이션이 한 번 이동하는 데 걸리는 시간입니다. + */ fun Modifier.skeleton( isLoading: Boolean, baseColor: Color = Color.LightGray.copy(alpha = 0.3f), @@ -46,10 +76,10 @@ fun Modifier.skeleton( this .graphicsLayer { /* - * 컴포넌트를 별도의 버퍼에 그립니다. + * 현재 컴포저블을 별도 버퍼에 먼저 그립니다. * - * BlendMode가 화면의 다른 컴포넌트나 배경에 영향을 주지 않고 - * 현재 컴포넌트 안에서만 동작하도록 하기 위해 필요합니다. + * BlendMode가 화면 전체나 다른 컴포저블의 배경에 영향을 주지 않고, + * 현재 컴포저블 내부에서만 동작하도록 하기 위해 필요합니다. */ compositingStrategy = CompositingStrategy.Offscreen } @@ -75,14 +105,13 @@ fun Modifier.skeleton( onDrawWithContent { /* - * 먼저 원본 컴포넌트를 그립니다. - * 이 원본의 알파값이 마스크 역할을 합니다. + * 먼저 원본 컴포저블을 그립니다. + * 이 원본의 알파 값이 그라데이션을 입힐 마스크가 됩니다. */ drawContent() /* - * 원본 컴포넌트가 실제로 그려진 픽셀에만 - * 그라데이션을 남깁니다. + * 원본 컴포저블이 실제로 그려진 픽셀 영역에만 그라데이션을 합성합니다. */ drawRect( brush = brush, @@ -90,4 +119,4 @@ fun Modifier.skeleton( ) } } -} \ No newline at end of file +} From a14aa3bc3484225a4f3fc6f15c1b4810a37b24ff Mon Sep 17 00:00:00 2001 From: gmin Date: Thu, 16 Jul 2026 18:41:47 +0900 Subject: [PATCH 06/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20ShadowStyle=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EB=B0=8F=20Modifier.shadow=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `elevation`, `shape`, `clip`, `ambientColor`, `spotColor` 속성을 포함하는 `ShadowStyle` 데이터 클래스 정의 - `ShadowStyle` 객체를 매개변수로 받아 그림자 효과를 적용하는 `Modifier.shadow` 확장 함수 구현 --- .../java/com/linku/design/modifier/Shadow.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 design/src/main/java/com/linku/design/modifier/Shadow.kt 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 From 9a57c4747ba5f1e364d54d590f618e628e99e87b Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 16:45:04 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=93=9D=20docs(ui):=20skeleton=20Mod?= =?UTF-8?q?ifier=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/linku/design/modifier/Skeleton.kt | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) 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 3945a10ec..c3aafb853 100644 --- a/design/src/main/java/com/linku/design/modifier/Skeleton.kt +++ b/design/src/main/java/com/linku/design/modifier/Skeleton.kt @@ -18,33 +18,44 @@ import androidx.compose.ui.graphics.graphicsLayer /** * 로딩 상태의 컴포저블에 스켈레톤 shimmer 효과를 적용하는 [Modifier]입니다. * - * 이 modifier는 대상 컴포저블의 실제 콘텐츠를 먼저 그린 뒤, 그 콘텐츠가 차지하는 픽셀 영역만 - * 그라데이션으로 덮어 shimmer처럼 보이게 만듭니다. 따라서 이 modifier를 적용한 컴포저블의 - * 크기, 모양, clip, alpha는 그대로 스켈레톤 마스크의 기준이 됩니다. + * 이 modifier는 대상 컴포저블의 실제 콘텐츠를 먼저 그린 뒤, 그 콘텐츠가 차지하는 픽셀 영역에만 + * shimmer 그라데이션을 합성합니다. 즉, 단순히 크기만 가진 빈 [androidx.compose.foundation.layout.Box]에 + * 이 modifier를 붙이면 마스크로 사용할 픽셀이 없기 때문에 스켈레톤이 보이지 않을 수 있습니다. + * placeholder로 사용할 영역은 [androidx.compose.material3.Surface], `background`, `Image`처럼 + * 실제로 그려지는 콘텐츠를 포함해야 합니다. + * + * 스켈레톤의 모양은 이 modifier가 적용된 컴포저블이 최종적으로 그리는 픽셀을 기준으로 결정됩니다. + * 따라서 size, shape, clip, alpha, child content의 배치가 그대로 스켈레톤 마스크가 됩니다. 예를 들어 + * [androidx.compose.foundation.shape.RoundedCornerShape]로 clip된 Surface에 적용하면 둥근 사각형 + * 스켈레톤이 되고, 원형 Surface에 적용하면 원형 스켈레톤이 됩니다. * * [isLoading]이 `false`이면 추가 draw 단계와 무한 애니메이션을 만들지 않고 기존 [Modifier] * 체인을 그대로 반환합니다. 로딩이 끝난 뒤 실제 UI를 보여줄 때도 같은 modifier 체인을 유지할 수 - * 있도록 하기 위한 동작입니다. + * 있도록 하기 위한 동작이며, 로딩 여부에 따라 호출부의 modifier 구성을 분기하지 않아도 됩니다. * * 내부에서는 [CompositingStrategy.Offscreen]으로 별도 버퍼에 대상 컴포저블을 먼저 렌더링한 뒤, * [BlendMode.SrcIn]을 사용해 실제로 그려진 픽셀 영역에만 [Brush.linearGradient]를 합성합니다. * 이 처리가 없으면 shimmer 그라데이션이 현재 컴포저블 바깥이나 다른 컴포저블의 배경까지 영향을 - * 줄 수 있습니다. + * 줄 수 있습니다. [BlendMode.SrcIn]은 이미 그려진 원본 콘텐츠의 alpha를 마스크로 삼기 때문에, + * 원본 콘텐츠가 투명한 부분에는 shimmer가 합성되지 않습니다. * * 사용 예: * ``` - * Box( + * Surface( * modifier = Modifier * .size(width = 120.dp, height = 16.dp) - * .clip(RoundedCornerShape(4.dp)) - * .skeleton(isLoading = uiState.isLoading) - * ) + * .skeleton(isLoading = uiState.isLoading), + * shape = RoundedCornerShape(4.dp), + * color = Color.LightGray + * ) {} * ``` * * @param isLoading `true`이면 shimmer 스켈레톤을 표시하고, `false`이면 원본 modifier를 그대로 사용합니다. - * @param baseColor 스켈레톤의 기본 배경색입니다. 그라데이션의 양 끝 색상으로 사용됩니다. - * @param highlightColor shimmer가 지나가는 중앙 하이라이트 색상입니다. - * @param durationMillis shimmer 그라데이션이 한 번 이동하는 데 걸리는 시간입니다. + * @param baseColor 스켈레톤의 기본 배경색입니다. shimmer 그라데이션의 시작과 끝 색상으로 사용됩니다. + * @param highlightColor shimmer가 지나가는 중앙 하이라이트 색상입니다. [baseColor]보다 밝게 지정하면 + * 이동하는 빛 효과가 더 분명해집니다. + * @param durationMillis 대각선으로 기울어진 shimmer 그라데이션이 왼쪽에서 오른쪽으로 한 번 이동하는 데 + * 걸리는 시간입니다. 값이 작을수록 shimmer가 빠르게 움직입니다. */ fun Modifier.skeleton( isLoading: Boolean, From 23ad15570e96972e0037aa9660099ffaeb6f5582 Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 17:00:05 +0900 Subject: [PATCH 08/16] =?UTF-8?q?=F0=9F=93=9D=20docs(ui):=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=ED=8F=B4=EB=8D=94=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EA=B0=84=EA=B2=A9=20=EA=B3=84=EC=82=B0=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/ui/content/LoadingFoldersGrid.kt | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) 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 index 3f0806c36..d16f869f8 100644 --- 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 @@ -16,17 +16,45 @@ 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 /** * 폴더 데이터를 불러오는 동안 표시되는 로딩 스켈레톤 그리드 화면입니다. * - * 2열 그리드 레이아웃을 사용하여 스켈레톤 아이템을 배치하며, 화면 너비와 - * 사전 정의된 비율([ITEM_RATIO])에 따라 아이템 간의 수평 간격을 동적으로 계산합니다. + * 실제 폴더 목록이 도착하기 전까지 [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 그리드 내부 콘텐츠에 적용할 여백입니다. + * @param contentPadding 그리드 내부 콘텐츠에 적용할 여백입니다. start/end padding의 합을 `maxWidth`에서 + * 뺀 뒤 실제 아이템이 배치되는 콘텐츠 영역을 기준으로 수평 간격을 계산합니다. */ @Composable internal fun LoadingFoldersGrid( From 3e98113a29ebc1bea97dccdd8afb7f80af7bb0ec Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 17:04:27 +0900 Subject: [PATCH 09/16] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ui):=20?= =?UTF-8?q?=EB=B9=88=20=ED=8F=B4=EB=8D=94=20=EC=B9=B4=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=ED=8C=94=EB=A0=88=ED=8A=B8=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linku/file/ui/item/items/EmptyFolderItemLayout.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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..d25d4ff6d 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 @@ -4,6 +4,7 @@ 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 @@ -13,11 +14,12 @@ fun EmptyFolderItemLayout( folderName: String = "" ) { val colors = MaterialTheme.linkuColors + val color = CategoryColorStyle.DEFAULT FolderItemLayout( - backgroundColor = colors.gray[200], - color1 = colors.gray[300], - color2 = colors.gray[200], + backgroundColor = color.color2, + color1 = color.color3, + color2 = color.color2, color3 = colors.white, folderMaskBrush = Brush.verticalGradient( colorStops = arrayOf( @@ -27,7 +29,7 @@ fun EmptyFolderItemLayout( ), leftIcon = {}, rightIcon = {}, - textBackgroundColor = colors.gray[500], + textBackgroundColor = color.color4, folderName = folderName, modifier = modifier ) From 5bf6101ace6acb22da83a051829b1f59c2ad32d6 Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 17:07:13 +0900 Subject: [PATCH 10/16] =?UTF-8?q?=F0=9F=8E=A8=20style(ui):=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=ED=8F=B4=EB=8D=94=20=EB=A7=88=EC=8A=A4=ED=81=AC=20?= =?UTF-8?q?=EA=B7=B8=EB=9D=BC=EB=8D=B0=EC=9D=B4=EC=85=98=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linku/file/ui/item/items/CategoryItemLayout.kt | 8 +------- .../linku/file/ui/item/items/EmptyFolderItemLayout.kt | 11 ++--------- 2 files changed, 3 insertions(+), 16 deletions(-) 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 d25d4ff6d..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,6 @@ 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 @@ -13,20 +12,14 @@ fun EmptyFolderItemLayout( modifier: Modifier = Modifier, folderName: String = "" ) { - val colors = MaterialTheme.linkuColors val color = CategoryColorStyle.DEFAULT FolderItemLayout( backgroundColor = color.color2, color1 = color.color3, color2 = color.color2, - 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), - ) - ), + color3 = MaterialTheme.linkuColors.white, + folderMaskBrush = color.verticalGradient(), leftIcon = {}, rightIcon = {}, textBackgroundColor = color.color4, From 8e7a6a2c6e9d11520030e99be15fea0c27aae785 Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 17:11:27 +0900 Subject: [PATCH 11/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20Compose=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EB=A7=88=EC=8A=A4=ED=81=AC=20=EB=8F=84?= =?UTF-8?q?=ED=98=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/linku/file/ui/item/FolderMask.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 feature/file/src/main/java/com/linku/file/ui/item/FolderMask.kt 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..58d14ce4a --- /dev/null +++ b/feature/file/src/main/java/com/linku/file/ui/item/FolderMask.kt @@ -0,0 +1,53 @@ +package com.linku.file.ui.item + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.GenericShape +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.graphics.Brush + +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, + brush: Brush, +) { + Box( + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(FolderMaskShape) + .drawWithCache { + onDrawBehind { + drawRect(brush = brush) + } + } + ) + } +} From ed2ffa29e303e2040084e1731bfaa88bccb95443 Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 18:01:03 +0900 Subject: [PATCH 12/16] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ui):=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=B9=B4=EB=93=9C=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A5=BC=20Compose=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linku/file/ui/item/FolderItemLayout.kt | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) 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..0dfa03f7f 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,15 +24,10 @@ 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.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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -43,12 +37,11 @@ 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]에서 계산한 스케일을 * 기준으로 함께 축소/확대됩니다. @@ -58,7 +51,7 @@ import com.linku.file.R * @param color1 뒤쪽에 배치되는 첫 번째 폴더 레이어 색상입니다. * @param color2 가운데에 배치되는 두 번째 폴더 레이어 색상입니다. * @param color3 앞쪽에 배치되는 세 번째 폴더 레이어 색상입니다. - * @param folderMaskBrush 하단 폴더 마스크 이미지에 입힐 브러시입니다. + * @param folderMaskBrush 하단 [FolderMask]를 채울 브러시입니다. * @param leftIcon 카드 왼쪽 상단에 배치할 아이콘 슬롯입니다. * @param rightIcon 카드 오른쪽 상단에 배치할 아이콘 슬롯입니다. * @param textBackgroundColor 폴더명 첫 글자 배지의 배경색입니다. @@ -107,6 +100,7 @@ fun FolderItemLayout( // 디자인 기준 dp, sp를 현재 카드 크기에 맞게 변환하는 헬퍼입니다. fun s(dp: Dp) = dp * scale fun ssp(sp: TextUnit) = (sp.value * scale).sp + val maskHeight = s(116.dp) /** * 폴더 일러스트를 구성하는 단일 레이어를 현재 카드 크기에 맞춰 그립니다. @@ -153,7 +147,7 @@ fun FolderItemLayout( ) {} } - /** 폴더 레이어와 하단 마스크를 카드 중앙 기준으로 겹쳐 배치하는 루트 컨테이너입니다. */ + /** 폴더 레이어와 하단 마스크를 하나의 카드 좌표계에 겹쳐 배치하는 루트 컨테이너입니다. */ Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -187,27 +181,13 @@ 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, ) // 왼쪽 상단 슬롯입니다. 공유/잠금 아이콘처럼 폴더 상태를 나타내는 아이콘을 배치합니다. From d092ee55df8cc1f83023421d830ff4d17920ee59 Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 19:01:04 +0900 Subject: [PATCH 13/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=A7=88=EC=8A=A4=ED=81=AC=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=EA=B3=BC=20Preview=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/linku/file/ui/item/FolderMask.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 index 58d14ce4a..18aa66932 100644 --- 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 @@ -2,12 +2,19 @@ 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.graphics.Brush +import androidx.compose.ui.tooling.preview.Preview +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 @@ -31,6 +38,23 @@ private val FolderMaskShape = GenericShape { size, _ -> 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), + ) + ), + ) +} + @Composable internal fun FolderMask( modifier: Modifier, @@ -51,3 +75,15 @@ internal fun FolderMask( ) } } + +@Preview(showBackground = true) +@Composable +private fun FolderMaskPreview() { + LinkuPreview { + FolderMask( + Modifier + .fillMaxWidth() + .height(116.dp) + ) + } +} From 06a2e4145cfc02884feb4f2b7ede618811e51ab4 Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 19:24:47 +0900 Subject: [PATCH 14/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=A1=9C=EB=94=A9=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20placeholder=EB=A1=9C=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/ui/item/items/SkeletonFolderItem.kt | 182 +++++++++--------- 1 file changed, 87 insertions(+), 95 deletions(-) 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 index a55731de6..9e282515d 100644 --- 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 @@ -1,51 +1,80 @@ package com.linku.file.ui.item.items -import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding 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.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import com.linku.design.modifier.skeleton import com.linku.design.theme.LinkuPreview -import com.linku.file.R +import com.linku.design.theme.linkuColors +import com.linku.file.ui.item.FolderMask -private const val baseW = 165.3f -private const val baseH = 145.8535f +/** + * 폴더 카드 스켈레톤의 기준 너비입니다. + * + * [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와 달리 고정값을 유지합니다. * - * 여러 개의 레이어를 회전시키고 겹쳐서 폴더 내부의 종이 뭉치를 표현하며, - * 하단 마스크를 통해 폴더의 외형을 완성합니다. 지정된 종횡비([aspect])를 유지하며 - * 전달된 [modifier]의 크기에 맞춰 내부 요소들이 동적으로 스케일링됩니다. + * 배경과 내부 placeholder는 [Surface]와 [FolderMask]가 직접 그리는 고정 회색 도형입니다. 이 + * 컴포저블 자체에는 `Modifier.skeleton`을 적용하지 않으므로 shimmer 애니메이션 없이 정적 + * placeholder로 표시됩니다. * - * @param modifier 컨테이너 레이아웃에 적용할 [Modifier] + * @param modifier 그리드 셀이나 부모 레이아웃에서 전달하는 외부 [Modifier]입니다. 보통 셀 전체를 채우도록 + * [Modifier.fillMaxSize]를 전달하고, 이 컴포저블 내부에서 카드 비율을 다시 맞춥니다. */ @Composable internal fun SkeletonFolderItem( modifier: Modifier -){ +) { + val colors = MaterialTheme.linkuColors BoxWithConstraints( modifier = modifier @@ -55,99 +84,62 @@ internal fun SkeletonFolderItem( val scaleH = maxHeight / baseH.dp val scale = minOf(scaleW, scaleH) - /** 디자인 기준 dp, sp를 현재 카드 크기에 맞게 변환하는 헬퍼입니다. */ fun s(dp: Dp) = dp * scale - /** - * 폴더 일러스트를 구성하는 단일 레이어를 현재 카드 크기에 맞춰 그립니다. - * - * @param size 레이어의 기준 너비입니다. - * @param height 레이어의 기준 높이입니다. 지정하지 않으면 [size]와 같은 값으로 사용합니다. - * @param padding 기준 크기에서 적용할 외부 여백입니다. - * @param rotation 레이어에 적용할 회전 각도입니다. - */ - @Composable - fun FolderLayerBox( - size: Dp, - height: Dp = size, - padding: PaddingValues = PaddingValues(0.dp), - rotation: Float = 0f, - ) { - /** 회전과 그림자를 가진 폴더 뒷장/중간장/앞장 레이어입니다. */ - Surface( - modifier = Modifier - // 레이어별 기준 여백을 현재 카드 스케일에 맞게 변환합니다. - .padding( - PaddingValues( - start = s(padding.calculateStartPadding(LayoutDirection.Ltr)), - top = s(padding.calculateTopPadding()), - end = s(padding.calculateEndPadding(LayoutDirection.Ltr)), - bottom = s(padding.calculateBottomPadding()) - ) - ) - // 레이어마다 다른 회전값을 적용해 폴더 종이가 겹친 느낌을 만듭니다. - .rotate(rotation) - // 레이어의 기준 너비/높이를 현재 카드 크기에 맞춰 스케일링합니다. - .width(s(size)) - .height(s(height)) - .skeleton(isLoading = true), - shape = RoundedCornerShape(s(18.dp)), - ) {} - } - - /** 폴더 레이어와 하단 마스크를 카드 중앙 기준으로 겹쳐 배치하는 루트 컨테이너입니다. */ Box( - Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - // 배경면. SkeletonFolderItem 자체가 갖는 크기를 가짐. Surface( modifier = Modifier - .fillMaxSize() - .skeleton(isLoading = true), - shape = RoundedCornerShape(28.5.dp) - ){} - - // 첫 번째 레이어: 가장 뒤쪽 종이입니다. 원본: 105.45, padding bottom 5.7, rot -7.39 - FolderLayerBox( - size = 105.45.dp, - padding = PaddingValues(bottom = 5.7.dp), - rotation = -7.39f - ) - - // 두 번째 레이어: 가운데 종이입니다. 원본: 105.45, padding bottom 3.1825, rot 4.86 - FolderLayerBox( - size = 105.45.dp, - padding = PaddingValues(bottom = 3.1825.dp), - rotation = 4.86f - ) - - // 세 번째 레이어: 가장 앞쪽 흰색/컬러 종이입니다. 원본: size 126.407 x 107.9605, padding top 7.6 - FolderLayerBox( - size = 126.407.dp, - height = 107.9605.dp, - padding = PaddingValues(top = 7.6.dp), - rotation = 0f - ) + .fillMaxSize(), + shape = RoundedCornerShape(25.dp), + color = colors.gray[200] + ) {} - // 하단 폴더 마스크. - Image( - painter = painterResource(R.drawable.folder_mask), - contentScale = ContentScale.FillWidth, - contentDescription = null, + Box( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) - .skeleton(isLoading = true) - ) + ) { + 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(){ +private fun SkeletonFolderItemPreview() { LinkuPreview { SkeletonFolderItem(Modifier.fillMaxSize()) } -} \ No newline at end of file +} From d9588aa81c216e8f05d61710e879c5a7a23a9fec Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 19:31:42 +0900 Subject: [PATCH 15/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=A7=88=EC=8A=A4=ED=81=AC=EC=97=90=20Figma=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=EC=9E=90=EC=99=80=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linku/file/ui/item/FolderItemLayout.kt | 36 ++++++++++++++----- .../java/com/linku/file/ui/item/FolderMask.kt | 19 ++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) 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 0dfa03f7f..d02ce74b8 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 @@ -28,9 +28,11 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +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 @@ -47,11 +49,11 @@ import com.linku.design.theme.linkuColors * 기준으로 함께 축소/확대됩니다. * * @param modifier 카드의 외부 크기와 배치를 결정하는 [Modifier]입니다. - * @param backgroundColor 카드 Surface 배경색입니다. + * @param backgroundColor 카드 바탕과 [FolderMask] 내부의 불투명 기본 영역을 채우는 색상입니다. * @param color1 뒤쪽에 배치되는 첫 번째 폴더 레이어 색상입니다. * @param color2 가운데에 배치되는 두 번째 폴더 레이어 색상입니다. * @param color3 앞쪽에 배치되는 세 번째 폴더 레이어 색상입니다. - * @param folderMaskBrush 하단 [FolderMask]를 채울 브러시입니다. + * @param folderMaskBrush 하단 [FolderMask]의 불투명 기본 레이어 위에 합성할 전면 브러시입니다. * @param leftIcon 카드 왼쪽 상단에 배치할 아이콘 슬롯입니다. * @param rightIcon 카드 오른쪽 상단에 배치할 아이콘 슬롯입니다. * @param textBackgroundColor 폴더명 첫 글자 배지의 배경색입니다. @@ -77,14 +79,21 @@ fun FolderItemLayout( 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, + ) ) { /** * 실제 배치된 카드 크기를 기준으로 폴더 레이어, 아이콘 위치, 폰트 크기를 함께 스케일링합니다. @@ -188,6 +197,17 @@ fun FolderItemLayout( .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, + ), ) // 왼쪽 상단 슬롯입니다. 공유/잠금 아이콘처럼 폴더 상태를 나타내는 아이콘을 배치합니다. 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 index 18aa66932..6a19a94dc 100644 --- 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 @@ -10,7 +10,10 @@ 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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.linku.design.theme.LinkuPreview @@ -52,6 +55,8 @@ internal fun FolderMask( 0.2f to colors.gray[200].copy(alpha = 1.0f), ) ), + baseColor = colors.gray[200], + shadow = null, ) } @@ -59,16 +64,30 @@ internal fun FolderMask( internal fun FolderMask( modifier: Modifier, brush: Brush, + baseColor: Color, + shadow: Shadow? = null, ) { Box( modifier = modifier, ) { + if (shadow != null) { + Box( + modifier = Modifier + .fillMaxSize() + .dropShadow( + shape = FolderMaskShape, + shadow = shadow, + ) + ) + } + Box( modifier = Modifier .fillMaxSize() .clip(FolderMaskShape) .drawWithCache { onDrawBehind { + drawRect(color = baseColor.copy(alpha = 1f)) drawRect(brush = brush) } } From 72bbd3e554fddbeca7ed8d6ac1cebcce2f52aec0 Mon Sep 17 00:00:00 2001 From: gmin Date: Sat, 25 Jul 2026 19:37:00 +0900 Subject: [PATCH 16/16] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EB=A7=88=EC=8A=A4=ED=81=AC=20backdrop=20blur=20?= =?UTF-8?q?=ED=95=A9=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linku/file/ui/item/FolderItemLayout.kt | 70 ++++++++++++------- .../java/com/linku/file/ui/item/FolderMask.kt | 27 +++++++ 2 files changed, 72 insertions(+), 25 deletions(-) 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 d02ce74b8..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 @@ -24,10 +24,12 @@ 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.drawWithContent import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +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 @@ -74,6 +76,7 @@ fun FolderItemLayout( ) { /** 폴더명 배지와 텍스트에 사용할 LinkU 테마 색상 팔레트입니다. */ val colors = MaterialTheme.linkuColors + val backdropLayer = rememberGraphicsLayer() // 디자인 원본 기준 사이즈입니다. 카드 비율과 내부 요소 스케일 계산의 기준값으로 사용합니다. val baseW = 165.3.dp @@ -110,6 +113,7 @@ fun FolderItemLayout( fun s(dp: Dp) = dp * scale fun ssp(sp: TextUnit) = (sp.value * scale).sp val maskHeight = s(116.dp) + val maskTopOffset = maxHeight - maskHeight /** * 폴더 일러스트를 구성하는 단일 레이어를 현재 카드 크기에 맞춰 그립니다. @@ -158,31 +162,44 @@ 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( @@ -190,7 +207,7 @@ fun FolderItemLayout( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - // Compose FolderMask는 카드 하단을 채우고 전달받은 브러시로 전면 색상을 그립니다. + // Compose FolderMask는 캡처한 뒤쪽 폴더 레이어와 전달받은 전면 브러시를 카드 하단에 합성합니다. FolderMask( modifier = Modifier .fillMaxWidth() @@ -208,6 +225,9 @@ fun FolderItemLayout( 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 index 6a19a94dc..cc11f5387 100644 --- 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 @@ -11,10 +11,15 @@ 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 @@ -66,6 +71,9 @@ internal fun FolderMask( brush: Brush, baseColor: Color, shadow: Shadow? = null, + backdropLayer: GraphicsLayer? = null, + backdropOffsetY: Dp = 0.dp, + backdropBlurRadius: Dp = 0.dp, ) { Box( modifier = modifier, @@ -86,8 +94,27 @@ internal fun FolderMask( .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) } }