BubbleShape Text Component

Davidยท2025๋…„ 4์›” 27์ผ
0

[Android] Compose

๋ชฉ๋ก ๋ณด๊ธฐ
7/7
post-thumbnail

๐Ÿฅฒ ์™œ ๊ฐœ๋ฐœํ•˜๊ฒŒ ๋๋Š”๊ฐ€

๊ฐœ๋ฐœํ•˜๊ฒŒ ๋œ ๊ณ„๊ธฐ๋Š” ๋‘ ๊ฐ€์ง€ ์ด์œ ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
๊ฐ€์žฅ ํฐ ์ด์œ ๋Š” ๊ธฐํš์ž&๋””์ž์ด๋„ˆ๊ป˜์„œ
๊ธฐ์กด์˜ ์ฃผ์†Œ๊ฐ€ ๋…ธ์ถœ๋˜๋Š” ๋ถ€๋ถ„์„
Marker ์œ„์— Bubble ํ˜•ํƒœ๋กœ ๋ณ€๊ฒฝ ์š”์ฒญ์ด์˜€์Šต๋‹ˆ๋‹ค.

๋‘ ๋ฒˆ์งธ๋Š” ๊ธฐ์กด์˜ Bubble ํ˜•ํƒœ์˜ ํ…์ŠคํŠธ๊ฐ€ ๊ฐœ๋ฐœ์€ ๋˜์–ด์žˆ์—ˆ๋Š”๋ฐ
๋™์ ์œผ๋กœ ๋ณ€ํ•˜๋Š” ํ…์ŠคํŠธ์— ๋Œ€ํ•œ ๋Œ€์‘์ด ์•ˆ ๋˜์–ด์žˆ์—ˆ๊ณ 
Bubble ํ˜•ํƒœ๋ฅผ ๊ทธ๋ฆฌ๋Š” ๋ฐฉ๋ฒ•์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.
(๐ŸฅฒColumn ๊ณผ Text๋ฅผ ํ˜ผํ•ฉํ•˜๊ณ  ์‚ผ๊ฐํ˜•์„ ๊ทธ๋ ค ๋ถ™์ด๋Š” ํ˜•ํƒœ๋กœ)


๐Ÿค” ๊ฐœ๋ฐœ ๋ฐฉ๋ฒ• ๊ณ ๋ฏผ

์œ„ ๊ธฐํš์— ๋Œ€ํ•œ UI๋ฅผ ๊ฐœ๋ฐœํ•˜๊ธฐ ์œ„ํ•ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋‹จ๊ณ„๋ณ„๋กœ ์ƒ๊ฐํ•ด ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ฒซ ๋ฒˆ์งธ๋Š” ํ…์ŠคํŠธ ๋™์  ๊ณ„์‚ฐ

ํ…์ŠคํŠธ์˜ ์ •์ฑ…์— ๋งž๊ฒŒ ๋„“์ด์™€ ๋†’์ด๊ฐ€ ์„ ํ–‰์ ์œผ๋กœ ๊ณ„์‚ฐ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
๊ณ„์‚ฐ๋œ ๋„“์ด์™€ ๋†’์ด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ
BubbleShape ์˜ ๋„“์ด์™€ ๋†’์ด๋ฅผ ๊ฒฐ์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋‘ ๋ฒˆ์งธ๋Š” Canvas

๊ณ„์‚ฐ๋œ ๋„“์ด์™€ ๋†’์ด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ
๋‘ฅ๊ทผ ๋ชจ์„œ๋ฆฌ(arc) ์™€ path๋ฅผ ์ด์šฉํ•œ BubbleShape ๊ทธ๋ฆฌ๊ธฐ

์„ธ ๋ฒˆ์งธ๋Š” ์ปดํฌ๋„ŒํŠธ๊ฐ„ ์œ„์น˜ ์กฐ์ •

Marker ์œ„์— ๊ทธ๋ฆฌ๊ธฐ ์œ„ํ•ด์„œ Marker ์œ„์น˜๋ฅผ ํŒŒ์•…ํ•˜๊ณ 
ํŒŒ์•…๋œ ์œ„์น˜๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์œ„๋กœ
BubbleShape ๋†’์ด + spacing ๊ฐ„๊ฒฉ ๋ฒŒ๋ฆฌ๊ธฐ


์„ค๊ณ„๋Š” ๋๋‚ฌ์œผ๋‹ˆ ์ด์ œ ๊ฐœ๋ฐœ์„ ํ•ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.


๐Ÿ‘จ๐Ÿปโ€๐Ÿ’ป ๊ฐœ๋ฐœ ๋‚ด์šฉ

์ปดํฌ๋„ŒํŠธ ์†์„ฑ ๊ด€๋ฆฌ ํด๋ž˜์Šค ์ƒ์„ฑ

์šฐ์„  ์ปดํฌ๋„ŒํŠธ์˜ ์†์„ฑ๋ณ„๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋Š” ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ“Œ ํ…์ŠคํŠธ ๊ด€๋ฆฌ

  • text: ๋ณด์—ฌ์งˆ ํ…์ŠคํŠธ
  • textStyle: ํ…์ŠคํŠธ ์Šคํƒ€์ผ
  • textMaxRatio: ํ…์ŠคํŠธ๊ฐ€ ์ฐจ์ง€ํ•  ์ตœ๋Œ€ ๋น„์œจ
@Immutable
data class TextAppearance private constructor(
    val text: String,
    val textStyle: TextStyle,
    @FloatRange(from = 0.0, to = 1.0)
    val textMaxRatio: Float
) {
    companion object {
        @Composable
        fun setAppearance(
            text: String,
            textStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(color = PetnowColor.Grey.Tertiary),
            textMaxRatio: Float = 0.7f
        ): TextAppearance {
            return TextAppearance(
                text = text,
                textStyle = textStyle,
                textMaxRatio = textMaxRatio
            )
        }
    }
}

๐Ÿ“Œ ๋ฒ„๋ธ” ๋ชจ์–‘ ๊ด€๋ฆฌ
๊ฐ’์„ ์ •์ˆ˜๋กœ ๋ฐ›๊ณ  ์ด๋ฅผ ํ™”๋ฉด ๋ฐ€๋„(density)๋ฅผ ์ด์šฉํ•˜์—ฌ float(px)๋กœ ์น˜ํ™˜ ํ•ฉ๋‹ˆ๋‹ค.

  • padding: ๋ฒ„๋ธ” ๋ชจ์–‘์˜ padding
  • backgroundColor: ๋ฒ„๋ธ” ๋ชจ์–‘์˜ ์ „์ฒด ์ปฌ๋Ÿฌ
  • triangleHeight: ๋ฒ„๋ธ” ๋ชจ์–‘์˜ ๊ผฌ๋ฆฌ ์‚ผ๊ฐํ˜• ๋†’์ด
  • triangleWidth: ๋ฒ„๋ธ” ๋ชจ์–‘์˜ ๊ผฌ๋ฆฌ ์‚ผ๊ฐํ˜• ๋„“์ด
  • cornerRadius: ๋ฒ„๋ธ” ๋ชจ์–‘์˜ ๋ผ์šด๋“œ ๊ฐ’
  • shadowRadius: ๋ฒ„๋ธ” ๋ชจ์–‘์˜ ๊ทธ๋ฆผ์ž ๊ฐ’
  • shadowColor: ๋ฒ„๋ธ” ๋ชจ์–‘์˜ ๊ทธ๋ฆผ์ž ์ƒ‰์ƒ
  • spacingBetweenAnchorUI: ๋ฒ„๋ธ” ๋ชจ์–‘์„ ์ง€์ •ํ•˜๋Š” ์œ„์น˜ ui์™€์˜ ๊ฐ„๊ฒฉ
@Immutable
data class BubbleShape private constructor(
    val padding: Float,
    val backGroundColor: Color,
    val triangleHeight: Float,
    val triangleWidth: Float,
    val cornerRadius: Float,
    val shadowRadius: Float,
    val shadowColor: Color,
    val spacingBetweenAnchorUI: Float,
) {
    companion object {
        @Composable
        fun setShape(
            padding: Int = 12,
            backGroundColor: Color = Color.White,
            triangleHeight: Int = 10,
            triangleWidth: Int = 16,
            cornerRadius: Int = 8,
            shadowRadius: Int = 8,
            spacingBetweenAnchorUI: Int = 4
        ): BubbleShape {
            val density = LocalDensity.current
            val bubblePaddingPx = with(density) { padding.dp.toPx() }
            val triangleHeightPx = with(density) { triangleHeight.dp.toPx() }
            val triangleWidthPx = with(density) { triangleWidth.dp.toPx() }
            val cornerRadiusPx = with(density) { cornerRadius.dp.toPx() }
            val shadowRadiusPx = with(density) { shadowRadius.dp.toPx() }
            val shadowColor = Color.Black.copy(alpha = 0.15f)
            val spacingPx = with (density) { spacingBetweenAnchorUI.dp.toPx() }
            return BubbleShape(
                padding = bubblePaddingPx,
                backGroundColor = backGroundColor,
                triangleHeight = triangleHeightPx,
                triangleWidth = triangleWidthPx,
                cornerRadius = cornerRadiusPx,
                shadowRadius = shadowRadiusPx,
                shadowColor = shadowColor,
                spacingBetweenAnchorUI = spacingPx
            )
        }
    }
}

ํ…์ŠคํŠธ ๋™์  ๊ณ„์‚ฐ

์–ด๋–ป๊ฒŒ ํ…์ŠคํŠธ์˜ ๊ธ€์ž์— ๋”ฐ๋ผ ๋™์ ์œผ๋กœ ๋„“์ด์™€ ๋†’์ด๋ฅผ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ์„๊นŒ์š”
TextMeasure๋ฅผ ์ด์šฉํ•˜๋ฉด ์ด๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

private val DefaultCacheSize: Int = 8

/**
 * Creates and remembers a [TextMeasurer]. All parameters that are required for [TextMeasurer]
 * except [cacheSize] are read from CompositionLocals. Created [TextMeasurer] carries an internal
 * [TextLayoutCache] with [cacheSize] capacity. Provide 0 for [cacheSize] to opt-out from internal
 * caching behavior.
 *
 * @param cacheSize Capacity of internal cache inside [TextMeasurer]. Size unit is the number of
 * unique text layout inputs that are measured. Value of this parameter highly depends on the
 * consumer use case. Provide a cache size that is in line with how many distinct text layouts are
 * going to be calculated by this measurer repeatedly. If you are animating font attributes, or any
 * other layout affecting input, cache can be skipped because most repeated measure calls would miss
 * the cache.
 */
@Composable
fun rememberTextMeasurer(
    cacheSize: Int = DefaultCacheSize
): TextMeasurer {
    val fontFamilyResolver = LocalFontFamilyResolver.current
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current

    return remember(fontFamilyResolver, density, layoutDirection, cacheSize) {
        TextMeasurer(fontFamilyResolver, density, layoutDirection, cacheSize)
    }
}

๐Ÿ“Œ textMeasurer๋ฅผ ์ด์šฉํ•ด์„œ BubbleShape width, height ๊ณ„์‚ฐํ•˜๊ธฐ

  • textMeasurer๋ฅผ ํ†ตํ•ด measure๋œ ๊ฒฐ๊ณผ๋ฅผ ํ†ตํ•ด
    width, height์™€ padding๊ฐ’์„ ํ•ฉ์ณ BubbleShape width, height ๊ฒฐ๊ณผ ๋„์ถœ

BubbleShape ๋งŒ๋“ค๊ธฐ (Canvas)

Canvas์˜ ๋„“์ด์™€ ๋†’์ด ์ง€์ •

๋„“์ด๋Š” ์œ„์—์„œ ๊ณ„์‚ฐํ•œ bubbleWidth๋กœ ์ง€์ •ํ•˜๊ณ 
๋†’์ด๋Š” ์œ„์—์„œ ๊ณ„์‚ฐํ•œ bubbleHeight์™€ ์‚ผ๊ฐํ˜•์˜ ๋†’์ด๋ฅผ ๋”ํ•˜์—ฌ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.

Canvas(
    modifier = modifier
        .width(with(density) { bubbleWidth.toDp() })
        .height(with(density) { (bubbleHeight + bubbleShape.triangleHeight).toDp() })
        .padding(end = with(density) { bubbleShape.padding.toDp() })
)

Paint ์„ค์ •

compose์—์„œ Paint๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ƒ‰์ƒ์„ ์ ์šฉํ•  ๋•Œ
โœŒ๐Ÿป๊ทธ๋ฆผ์žโœŒ๐Ÿป๋Š” ์•„์ง ๋ฏธ์ง€์›์ด๋ฏ€๋กœ ๊ธฐ์กด ์•ˆ๋“œ๋กœ์ด๋“œ ์‹œ์Šคํ…œ์—์„œ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

val paint = Paint().apply {
	this.color = bubbleShape.backGroundColor
	asFrameworkPaint().setShadowLayer(
		bubbleShape.shadowRadius,
		0f,
        2f,
        bubbleShape.shadowColor.toArgb()
    )
}

Path ๊ทธ๋ฆฌ๊ธฐ

1๋ฒˆ ์ฒซ ์‹œ์ž‘์€ radius๋กœ ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜๊ฒŒ ์„ค์ • (moveTo)
2๋ฒˆ lineTo๋กœ ๋ชจ์„œ๋ฆฌ ์ฒ˜๋ฆฌ ์ง์ „๊นŒ์ง€ ์ด๋™ (width-radius)
3๋ฒˆ arcTo ๋Š” ์‚ฌ๊ฐํ˜•์„ ๊ทธ๋ฆฌ๊ณ  ๊ฑฐ๊ธฐ์— ์›์„ ๊ทธ๋ฆฌ๋Š” ๋กœ์ง

  • ์‚ฌ๊ฐํ˜•์„ ๊ทธ๋ฆฌ๊ธฐ ์œ„ํ•ด ์™ผ์ชฝ ์‹œ์ž‘์ (width-radius*2) ์œผ๋กœ ์ง€์ •ํ•˜๊ณ 
  • ์‚ฌ๊ฐํ˜•์˜ ์‚ฌ์ด์ฆˆ๋ฅผ ๋ช…์‹œํ•จ (radius2, radius2)
  • ์ค‘์‹ฌ์œผ๋กœ๋ถ€ํ„ฐ ์‹œ์ž‘์ ์€ -90๋„
  • ๊ทธ๋ฆฌ๋Š” ๋ฐฉํ–ฅ์€ ์‹œ๊ณ„๋ฐฉํ–ฅ 90๋„ (์›์˜ 1/4)

...

6๋ฒˆ ์‚ผ๊ฐํ˜•์€ ๊ธฐ์ค€์ ์€ bubbleWidth์˜ ์ค‘๊ฐ„์ง€์  (bubbleWidth/2)

  • ๊ธฐ์ค€์ ์„ ์‚ผ์•„ ์˜ค๋ฅธ์ชฝ ์ ์€ ์‚ผ๊ฐํ˜•์˜ ๋„“์ด์˜ ๋ฐ˜์„ ๋”ํ•œ ์ง€์ 
    -> (์ค‘๊ฐ„์ง€์ +triangleWidth/2)
  • ์™ผ์ชฝ ์ ์€ ์‚ผ๊ฐํ˜•์˜ ๋„“์ด์˜ ๋ฐ˜์„ ๋บ€ ์ง€์ 
    -> (์ค‘๊ฐ„์ง€์ -traingleWidth/2)

...
10๋ฒˆ๊นŒ์ง€ ๊ฐ€๋ฉด bubleShape ์˜ Path๋ฅผ ๊ทธ๋ฆด ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

 val bubblePath = Path().apply {
        moveTo(bubbleShape.cornerRadius, 0f) // 1.
        lineTo(
            bubbleWidth - bubbleShape.cornerRadius,
            0f
        ) // 2.

        arcTo(
            rect = Rect(
                offset = Offset(bubbleWidth - bubbleShape.cornerRadius * 2, 0f),
                size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
            ),
            startAngleDegrees = -90f,
            sweepAngleDegrees = 90f,
            forceMoveTo = false
        ) // 3.

        lineTo(
            bubbleWidth,
            bubbleHeight - bubbleShape.cornerRadius
        ) // 4.

        arcTo(
            rect = Rect(
                offset = Offset(bubbleWidth - bubbleShape.cornerRadius * 2, bubbleHeight - bubbleShape.cornerRadius * 2),
                size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
            ),
            startAngleDegrees = 0f,
            sweepAngleDegrees = 90f,
            forceMoveTo = false
        ) // 5

        drawTriangle(
            bubbleShape = bubbleShape,
            bubbleWidth = bubbleWidth,
            bubbleHeight = bubbleHeight
        ) // 6

        lineTo(
            bubbleShape.cornerRadius,
            bubbleHeight
        ) // 7

        arcTo(
            rect = Rect(
                offset = Offset(0f, bubbleHeight - bubbleShape.cornerRadius * 2),
                size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
            ),
            startAngleDegrees = 90f,
            sweepAngleDegrees = 90f,
            forceMoveTo = false
        ) // 8

        lineTo(
            0f,
            bubbleShape.cornerRadius
        ) // 9

        arcTo(
            rect = Rect(
                offset = Offset(0f, 0f),
                size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
            ),
            startAngleDegrees = 180f,
            sweepAngleDegrees = 90f,
            forceMoveTo = false
        ) // 10
        close()
 }
 
 private fun Path.drawTriangle(
    bubbleShape: BubbleShape,
    bubbleWidth: Float,
    bubbleHeight: Float
) = apply {
    lineTo(
        (bubbleWidth + bubbleShape.triangleWidth) / 2,
        bubbleHeight
    )
    lineTo(
        bubbleWidth / 2,
        bubbleHeight + bubbleShape.triangleHeight
    )
    lineTo(
        (bubbleWidth - bubbleShape.triangleWidth) / 2,
        bubbleHeight
    )
}

Canvas ๊ทธ๋ฆฌ๊ธฐ

์ค€๋น„ํ•œ paint, path๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ drawIntoCanvas ํ•จ์ˆ˜๋กœ canvas์— path๋ฅผ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.
์ถ”๊ฐ€๋กœ ํ…์ŠคํŠธ ์—ญ์‹œ ์œ„์˜ textMeasurer์™€ textAppearance๋กœ ๊ฐ™์ด ๊ทธ๋ ค์ค๋‹ˆ๋‹ค.

Canvas(
    modifier = modifier
        .width(with(density) { bubbleWidth.toDp() })
        .height(with(density) { (bubbleHeight + bubbleShape.triangleHeight).toDp() })
        .padding(end = with(density) { bubbleShape.padding.toDp() })
) {

    val paint = Paint().apply {
        this.color = bubbleShape.backGroundColor
        asFrameworkPaint().setShadowLayer(
            bubbleShape.shadowRadius,
            0f,
            2f,
            bubbleShape.shadowColor.toArgb()
        )
    }

    val bubblePath = Path().apply {
        // ์œ„ ์ฝ”๋“œ ์ฐธ์กฐ
        ...
    }

    drawIntoCanvas { canvas ->
        canvas.drawPath(bubblePath, paint)
    }

    drawText(
        textMeasurer = textMeasurer,
        text = textAppearance.text,
        style = textStyle.copy(color = PetnowColor.Grey.Tertiary),
        topLeft = Offset(bubbleShape.padding, bubbleShape.padding)
    )
}

์ปดํฌ๋„ŒํŠธ ์œ„์น˜ ์กฐ์ •

markerUI์œ„์— BubbleShape๊ฐ€ ์œ„์น˜ํ•ด์•ผ ํ•˜๋ฏ€๋กœ
markUI โœŒ๐Ÿป์œ„์น˜๋ฅผ ๊ฐ€์ ธ์™€์•ผโœŒ๐Ÿป ํ•ฉ๋‹ˆ๋‹ค.
์ €๋Š” .onGloballyPositioned { } ๋ฅผ ํ™œ์šฉํ•˜์—ฌ anchorPosition์— ์œ„์น˜๋ฅผ ํ• ๋‹น ํ–ˆ์Šต๋‹ˆ๋‹ค.

์ดํ›„ ๊ฐ€์ ธ์˜จ anchorPosition์œผ๋กœ y ์ขŒํ‘œ ์œ„์—
bubbleShape ๋†’์ด์™€ ๊ธฐํš๋œ ๋นˆ ๊ณต๊ฐ„๋งŒํผ์„
verticalSpacing์— ํ• ๋‹นํ•˜๊ณ 

.graphicsLayer { this.translationY = verticalSpacing } ๋กœ ์œ„์น˜๋ฅผ ์„ค์ • ํ–ˆ์Šต๋‹ˆ๋‹ค.

@Composable
fun AddressBubbleBox(
    modifier: Modifier = Modifier,
    bubbleShape: BubbleShape = BubbleShape.setShape(),
    textAppearance: TextAppearance,
    markerUI: @Composable () -> Unit
) {
    var anchorPosition by remember { mutableStateOf(Offset.Zero) }

    var bubbleHeight by remember {
        mutableFloatStateOf(0f)
    }

    val verticalSpacing = anchorPosition.y - bubbleHeight - bubbleShape.triangleHeight - bubbleShape.spacingBetweenAnchorUI

    Box(modifier) {
        AnimatedVisibility(textAppearance.text.isNotEmpty()) {
            BubbleBox(
                modifier = Modifier.graphicsLayer {
                    this.translationY = verticalSpacing
                },
                textAppearance = textAppearance,
                bubbleShape = bubbleShape,
                onChangeBubbleHeight = {
                    bubbleHeight = it
                }
            )
        }

        Box(
            modifier = Modifier
                .align(Alignment.Center)
                .onGloballyPositioned { coordinate ->
                    anchorPosition = coordinate.positionInParent()
                }
        ) {
            markerUI()
        }
    }
}

์—ฌ๊ธฐ๊นŒ์ง€ ๋ชจ๋“  ์„ค์ •์„ ํ–ˆ๊ณ  ์™„๋ฃŒํ•œ ํ™”๋ฉด์„ ์•„๋ž˜์™€ ๊ฐ™์ด ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


๐Ÿ‘๐Ÿป ํฌ์ŠคํŒ…์„ ๋งˆ์น˜๋ฉฐ

ํ•ด๋‹น ๊ธฐํš์— ๋Œ€ํ•ด ๊ฐœ๋ฐœ ํ•˜๋ฉด์„œ
canvas์— ์ขŒํ‘œ๊ณ„์— ๋Œ€ํ•ด์„œ ์•Œ๊ฒŒ ๋˜๊ณ 
shadow ์„ค์ •์€ ๊ธฐ์กด ์ปดํฌ์ฆˆ์—์„œ ์•ˆ ๋˜์„œ ๋ ˆ๊ฑฐ์‹œ ์‹œ์Šคํ…œ์„
์ด์šฉํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ์„ ๋ฐฐ์› ์Šต๋‹ˆ๋‹ค.

์œ„์™€ ๊ฐ™์€ UI๋Š” ๊ฝค ๋งŽ์ด ์ด์šฉ๋  ๊ฒƒ ๊ฐ™์•„์„œ
์ •๋ฆฌ ๋ฐ ๊ณต์œ ํ•˜๋Š” ์‹œ๊ฐ„์„ ๊ฐ€์กŒ์Šต๋‹ˆ๋‹ค.
(์ด์ „์— ๊ฐœ๋ฐœํ–ˆ๋˜ ๋ถ€๋ถ„์ธ๋ฐ ์ •๋ฆฌ๋ฅผ ์ด์ œ์„œ์•ผ ํ•˜๋Š” ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค.๐Ÿฅฒ)

์ด๋งŒ ํฌ์ŠคํŒ…์„ ๋งˆ์น˜๊ฒ ์Šต๋‹ˆ๋‹ค๐Ÿ™‡๐Ÿปโ€โ™‚๏ธ

profile
๊ณต๋ถ€ํ•˜๋Š” ๊ฐœ๋ฐœ์ž

0๊ฐœ์˜ ๋Œ“๊ธ€