๊ฐœ๋ฐœ ์ผ์ง€ ๐Ÿ’ป/Nutri Capture

Nutri Capture ํ”„๋ก ํŠธ์—”๋“œ - 'ํ”ผ์ž' ์•„์ด์ฝ˜ ๊ตฌํ˜„

interfacer_han 2025. 3. 20. 17:24

#1 ๊ฐœ์š”

#1-1 ์ง€๊ธˆ๊นŒ์ง€์˜ ์—ฌ์ •

 

Nutri Capture ํ”„๋ก ํŠธ์—”๋“œ - ์ปค์Šคํ…€ BottomSheetScaffold ๊ฐœ๋ฐœ ์œ ์˜ˆ

#1 ๊ฐœ์š”#1-1 ๊ฐœ๋ฐœ ์ด์œ ๋ง๋กœ ์„ค๋ช…ํ•˜๊ธฐ ํž˜๋“ค์ง€๋งŒ, BottomSheetScaffold์˜ ๋‚ด๋ถ€ ์ฝ”๋“œ๋ฅผ ์‚ด์ง๋งŒ ๋ฐ”๊พธ๋ฉด ๋‚ด๊ฐ€ ์›ํ•˜๋Š” ๋™์ž‘์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. ๊ทธ๋ ‡๋‹ค๊ณ  internal์ด๋‚˜ private ์ ‘๊ทผ ์ง€์ •์ž๊ฐ€ ๋ถ™์€ ๋‚ด๋ถ€ ์ฝ”๋“œ๋ฅผ ๋‚ด

kenel.tistory.com

์œ„ ๊ฒŒ์‹œ๊ธ€์—์„œ ๋ณด๋“ฏ, ์›๋ž˜๋Š” ์˜์–‘ ์ •๋ณด ์ž…๋ ฅ์„ ์œ„ํ•œ ์ปค์Šคํ…€ ํ‚ค๋ณด๋“œ๋ฅผ ๋งŒ๋“ค๊ธฐ๋กœ ํ–ˆ์—ˆ๋‹ค. ์ด ์ปค์Šคํ…€ ํ‚ค๋ณด๋“œ๋Š” BottomSheetScaffold์˜ BottomSheet์— ๋“ค์–ด๊ฐˆ ์˜ˆ์ •์ด์—ˆ๋‹ค. ์ˆœ์ • BottomSheetScaffold๋กœ๋Š” ๋‚ด๊ฐ€ ์›ํ•˜๋Š” ๋™์ž‘์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์—†๋‹ค. ๊ทธ๋ž˜์„œ ์ปค์Šคํ…€ BottomSheetScaffold๋ฅผ ๋งŒ๋“ค๋ ค ํ–ˆ๋‹ค. ์ด ๊ฒŒ์‹œ๊ธ€์—์„œ ๋ณด๋“ฏ, ์ปค์Šคํ…€์€ ์œ ์˜ˆํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค. ์ปค์Šคํ…€ BottomSheetScaffold๊ฐ€ ์™„์„ฑ๋  ๋•Œ๊นŒ์ง€๋Š” ์ผ๋ถ€ ์–ด์ƒ‰ํ•จ์ด ์กด์žฌํ•˜๋”๋ผ๋„, ์ˆœ์ • BottomSheetScaffold์„ ์‚ฌ์šฉํ•˜๊ฒ ๋‹ค.

 

#1-2 ์™„์„ฑ๋œ ์ปค์Šคํ…€ ํ‚ค๋ณด๋“œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ

[์™„์„ฑ๋œ ์•ฑ์˜ ์‹ค์ œ ์ž‘๋™ ์Šคํฌ๋ฆฐ์ƒท ๋„ฃ์„ ์˜ˆ์ •]

์ปค์Šคํ…€ ํ‚ค๋ณด๋“œ์˜ ๋„์‹๋„๋‹ค. ์•„์ด์ฝ˜์„ ํด๋ฆญํ• ๋•Œ๋งˆ๋‹ค ์•„์ด์ฝ˜์˜ ๋ฐฐ๊ฒฝ์ƒ‰์ด ๋ณ€ํ•œ๋‹ค. ๋˜ ์•„์ด์ฝ˜์„ ๋‘˜๋Ÿฌ์‹ผ ํ˜ธ์˜ ๊ธธ์ด๊ฐ€ ๋Š˜์–ด๋‚œ๋‹ค. ํ˜ธ์˜ ๊ธธ์ด๋Š” ์•„์ด์ฝ˜์˜ ์˜๋ฏธํ•˜๋Š” ์˜์–‘์†Œ์˜ ์„ญ์ทจ๋Ÿ‰์ด๋‹ค. ์ˆซ์ž๋ณด๋‹ค ์ง๊ด€์ ์ผ ๊ฒƒ์ด๋‹ค.

 

#1-3 ๊ตฌํ˜„๋œ ์•„์ด์ฝ˜ ๋ฏธ๋ฆฌ๋ณด๊ธฐ

[์™„์„ฑ๋œ ์•ฑ์˜ ์‹ค์ œ ์ž‘๋™ ์Šคํฌ๋ฆฐ์ƒท ๋„ฃ์„ ์˜ˆ์ •]

๊ธธ์ด๊ฐ€ ๊ฐ€๋ณ€์ธ ํ˜ธ๋ฅผ ํฌํ•จํ•˜๋ฉฐ, ํด๋ฆญํ•  ๋•Œ๋งˆ๋‹ค ๊ทธ ํ˜ธ์˜ ๊ธธ์ด๊ฐ€ ๋ณ€ํ•˜๋Š” ์•„์ด์ฝ˜์„ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค. ๋จผ์ € ๋ ˆ์ด์–ด1์— ์›์„ ๋‘”๋‹ค. ๋ ˆ์ด์–ด1 ์œ„์— ๋ ˆ์ด์–ด2๋ฅผ ๋‘”๋‹ค. ๋ ˆ์ด์–ด2์— ๋ ˆ์ด์–ด1์˜ ์›๋ณด๋‹ค ๋ฐ˜์ง€๋ฆ„์ด ์‚ด์ง ์ž‘์€ ์› ์•„์ด์ฝ˜์„ ๋‘”๋‹ค. ๋ ˆ์ด์–ด2์˜ ์•„์ด์ฝ˜์„ ํด๋ฆญํ•˜๋ฉด ๋ ˆ์ด์–ด1์˜ ์›์˜ ๋ชจ์–‘์ด ๋ณ€ํ•œ๋‹ค. ๋ ˆ์ด์–ด1์˜ ์›์˜ ๋ชจ์–‘์„ ๋ฐ”๊พธ๋Š” ๊ฒŒ ๊ตฌํ˜„์˜ ํ•ต์‹ฌ์ด ๋˜๊ฒ ๋‹ค. ๋งˆ์น˜ ํ”ผ์ž์—์„œ ํ”ผ์ž ์กฐ๊ฐ์„ ํ•˜๋‚˜์”ฉ ๋นผ๋“ฏ ๋ณ€ํ•˜๋ฏ€๋กœ ์ด ์•„์ด์ฝ˜์„ ์ด์ œ๋ถ€ํ„ฐ 'ํ”ผ์ž' ์•„์ด์ฝ˜์œผ๋กœ ๋ถ€๋ฅด๊ธฐ๋กœ ํ•œ๋‹ค.

 

#2 ์ฝ”๋“œ ์Šค๋‹ˆํŽซ - ํ•ต์‹ฌ

#2-1 drawArc()

๋งˆ์นจ ํ”ผ์ž ์•„์ด์ฝ˜์„ ๊ตฌํ˜„ํ•˜๊ธฐ์— ์•ˆ์„ฑ๋งž์ถค์ธ ๊ณต์‹ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ API๊ฐ€ ์žˆ์—ˆ๋‹ค.

 

DrawScope  |  API reference  |  Android Developers

 

developer.android.com

API์— ๋Œ€ํ•œ ๊ณต์‹ ๋ฌธ์„œ ๋งํฌ๋‹ค.

 

// https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#drawArc(androidx.compose.ui.graphics.Brush,kotlin.Float,kotlin.Float,kotlin.Boolean,androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Size,kotlin.Float,androidx.compose.ui.graphics.drawscope.DrawStyle,androidx.compose.ui.graphics.ColorFilter,androidx.compose.ui.graphics.BlendMode)

fun drawArc(
    brush: Brush, // ๊ทธ๋ฆฌ๊ธฐ์— ์‚ฌ์šฉํ•  ๋ธŒ๋Ÿฌ์‰ฌ(๋ถ“) ์ข…๋ฅ˜ ์„ค์ • (๋งจ ์•„๋ž˜ ์ฃผ์„ ์ฐธ์กฐ)
    startAngle: Float, // ํ˜ธ๋ฅผ ๊ทธ๋ฆฌ๊ธฐ ์‹œ์ž‘ํ•˜๋Š” ๊ฐ๋„ (0๋„๋Š” ์‹œ์นจ์˜ 3์‹œ ๋ฐฉํ–ฅ์ด๊ณ , ์‹œ๊ณ„ ๋ฐฉํ–ฅ์œผ๋กœ ์ฆ๊ฐ€)
    sweepAngle: Float, // startAngle๋กœ๋ถ€ํ„ฐ ์–ผ๋งˆ๋‚˜ ํ˜ธ๋ฅผ ๊ทธ๋ฆด์ง€์˜ ์ •๋„๊ฐ’
    useCenter: Boolean, // true๋กœ ํ•˜๋ฉด ํ˜ธ๊ฐ€ ์•„๋‹Œ ๋ถ€์ฑ„๊ผด ๋ชจ์–‘์ด ๋จ
    topLeft: Offset = Offset.Zero, // ํ˜ธ๋ฅผ ๋‹ด์„ ๊ฐ€์ƒ ์‚ฌ๊ฐํ˜•์˜ ์™ผ์ชฝ ์ƒ๋‹จ ์ขŒํ‘œ
    size: Size = this.size.offsetSize(topLeft), // ํ˜ธ๋ฅผ ๋‹ด์„ ๊ฐ€์ƒ ์‚ฌ๊ฐํ˜•์˜ ํฌ๊ธฐ
    alpha: @FloatRange(from = 0.0, to = 1.0) Float = 1.0f, // ํ˜ธ์˜ ํˆฌ๋ช…๋„
    style: DrawStyle = Fill, // ๊ทธ๋ฆฌ๊ธฐ ์Šคํƒ€์ผ (Stroke์€ ์™ธ๊ณฝ์„ ๋งŒ, Fill์€ ํ˜ธ๋ฅผ ์ฑ„์›€)
    colorFilter: ColorFilter? = null, // ์ƒ‰์ƒ ํ•„ํ„ฐ ์ ์šฉ
    blendMode: BlendMode = DefaultBlendMode // ๊ทธ๋ฆฌ๊ธฐ ์ž‘์—…์˜ ํ˜ผํ•ฉ ๋ชจ๋“œ ์„ค์ • (๋งจ ์•„๋ž˜ ์ฃผ์„ ์ฐธ์กฐ)
): Unit

/*
brush: ๊ทธ๋ฆผํŒ์—์„œ ์„ค์ •ํ•˜๋Š” ๋ธŒ๋Ÿฌ์‰ฌ์™€ ๊ฐ™๋‹ค๊ณ  ์ƒ๊ฐํ•˜๋ฉด ๋œ๋‹ค.
    ์ฐธ์กฐ: https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/Brush
blendMode: ์ƒ‰์ƒ์„ ํ˜ผํ•ฉ(Blend)ํ•˜๋Š” ๋ฐฉ์‹์ด๋ผ๊ณ  ํ•œ๋‹ค.
    ์†”์งํžˆ ์ž˜ ๋ชจ๋ฅด๊ฒ ๋‹ค.
    ์ƒ‰์ƒ ๊ด€๋ จํ•ด์„œ ๋” ๊นŠ์ด ๊ตฌํ˜„ํ•  ๋•Œ๊ฐ€ ์˜ฌ๊นŒ?
    ๊ทธ๋ ‡๊ฒŒ ๋œ๋‹ค๋ฉด, ๊ณต๋ถ€ํ•ด์•ผํ•  ์ง€๋„ ๋ชจ๋ฅธ๋‹ค.
    ์ฐธ์กฐ: https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/BlendMode
*/

๊ฐ ๋งค๊ฐœ๋ณ€์ˆ˜์— ๋Œ€ํ•œ ์„ค๋ช…์ด๋‹ค.

 

#2-2 animatedFloatAsState()

 

๊ฐ€์น˜ ๊ธฐ๋ฐ˜ ์• ๋‹ˆ๋ฉ”์ด์…˜  |  Jetpack Compose  |  Android Developers

์ด ํŽ˜์ด์ง€๋Š” Cloud Translation API๋ฅผ ํ†ตํ•ด ๋ฒˆ์—ญ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๊ฐ€์น˜ ๊ธฐ๋ฐ˜ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปฌ๋ ‰์…˜์„ ์‚ฌ์šฉํ•ด ์ •๋ฆฌํ•˜๊ธฐ ๋‚ด ํ™˜๊ฒฝ์„ค์ •์„ ๊ธฐ์ค€์œผ๋กœ ์ฝ˜ํ…์ธ ๋ฅผ ์ €์žฅํ•˜๊ณ  ๋ถ„๋ฅ˜ํ•˜์„ธ์š”. animate*AsState๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‹จ์ผ ๊ฐ’

developer.android.com

animatedFloatAsState()์— ๋Œ€ํ•œ ๊ณต์‹ ๋ฌธ์„œ๋‹ค.

 

// https://developer.android.com/reference/kotlin/androidx/compose/animation/core/package-summary#animateFloatAsState(kotlin.Float,androidx.compose.animation.core.AnimationSpec,kotlin.Float,kotlin.String,kotlin.Function1)

@Composable
fun animateFloatAsState(
    targetValue: Float,
    animationSpec: AnimationSpec<Float> = defaultAnimation,
    visibilityThreshold: Float = 0.01f,
    label: String = "FloatAnimation",
    finishedListener: ((Float) -> Unit)? = null
): State<Float>

animtedFloatAsState()๋Š” targetValue ํ”„๋กœํผํ‹ฐ๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ๋ฐ˜์‘ํ•œ๋‹ค. ๊ฐ€๋ น, ์›๋ž˜์˜ targetValue๊ฐ€ 8์ด์—ˆ๋‹ค๊ฐ€ ๊ฐ‘์ž๊ธฐ 9๋กœ ๋ณ€๊ฒฝ๋œ ๊ฒฝ์šฐ๋ฅผ ๊ฐ€์ •ํ•ด๋ณธ๋‹ค. animatedFloatAsState๋Š” 8์—์„œ 9๋กœ ์„œ์„œํžˆ ์ฆ๊ฐ€ํ•œ๋‹ค. ๊ทธ ์ฆ๊ฐ€์˜ ์–‘์ƒ์€ animationSpec ํ”„๋กœํผํ‹ฐ์— ๊ธฐ์ˆ ๋œ๋‹ค. ๋˜, animtedFloatAsState() ๋˜ํ•œ State์ด๋ฏ€๋กœ ๊ทธ ๊ฐ’์ด ๋ณ€ํ•  ๋•Œ Recomposition์ด ์œ ๋ฐœ๋œ๋‹ค.

 

#3 ๋ ˆ์ด์–ด 1 ๊ตฌํ˜„

#1-3์—์„œ ๋งํ•œ ๋ ˆ์ด์–ด1์ด๋‹ค.

 

#3-1 ์›์˜ ๋ชจ์–‘ ๋ฐ”๊พธ๊ธฐ - ๊ธฐ๋ณธ

๊ตฌํ˜„์„ ์œ„ํ•œ ์ƒ˜ํ”Œ ํ”„๋กœ์ ํŠธ๋ฅผ ๋งŒ๋“ค์—ˆ๋‹ค. ํ”„๋กœ์ ํŠธ ์ด๋ฆ„์€ ResponsivePizzaShape๋‹ค. drawArc()์„ ์ด์šฉํ•œ ์ฝ”๋“œ๋Š” ์•„๋ž˜์™€ ๊ฐ™๋‹ค.

 

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ResponsivePizzaShapeTheme {
                Scaffold(
                    modifier = Modifier.fillMaxSize()
                ) { innerPadding ->
                    Box(
                        modifier = Modifier.padding(innerPadding)
                    ) {
                        ResponsivePizzaShape(
                            modifier = Modifier.padding(innerPadding),
                            totalSlices = 12,
                            color = Color.Yellow
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun ResponsivePizzaShape(
    modifier: Modifier = Modifier,
    totalSlices: Int = 8,
    color: Color = Color.Green
) {
    var remainingSlices by remember { mutableIntStateOf(0) }

    Box(
        modifier = modifier
            .fillMaxSize()
            .clickable {
                remainingSlices = (remainingSlices + 1) % (totalSlices + 1)
            }
    ) {
        // State Hoisting
        val animatedRemainingSlices by animateFloatAsState(
            targetValue = remainingSlices.toFloat(),
            animationSpec = tween(durationMillis = 500, easing = LinearOutSlowInEasing)
        )

        Canvas(modifier = Modifier.fillMaxSize()) {
            animatedDrawPizza(
                animatedRemainingSlices = animatedRemainingSlices,
                totalSlices = totalSlices,
                color = color
            )
        }
    }
}

fun DrawScope.drawPizza(
    remainingSlices: Int,
    totalSlices: Int,
    color: Color
) {
    val radius = size.minDimension / 2 // minDimension์€ size์˜ ๋„ˆ๋น„์™€ ๋†’์ด ์ค‘ ๋” ์ž‘์€ ๊ฐ’์„ ๋ฐ˜ํ™˜
    val center = Offset(size.width / 2, size.height / 2) // ๋งจ โ†–์—์„œ โ†’์ชฝ์œผ๋กœ (๋„ˆ๋น„/2)๋งŒํผ โ†“์ชฝ์œผ๋กœ (๋†’์ด/2)๋งŒํผ ์›€์ง์ธ ๊ณณ
    val sliceAngle = 360f / totalSlices

    drawArc(
        color = color,
        startAngle = 180f, // ์‹œ์นจ์˜ 3์‹œ ๋ฐฉํ–ฅ๋ถ€ํ„ฐ ์‹œ์ž‘
        sweepAngle = sliceAngle * remainingSlices,
        useCenter = true,
        topLeft = Offset(center.x - radius, center.y - radius),
        size = Size(radius * 2, radius * 2)
    )
}

์ฐธ๊ณ ๋กœ Jetpack Compose์—์„œ ๊ฐ ์ปดํฌ๋„ŒํŠธ๋“ค์˜ Offset(0, 0)์€ ๊ณ ์œ ํ•˜๋‹ค. ๊ฐ ์ปดํฌ๋„ŒํŠธ ๋ณ„๋กœ ๋งจ โ†–์ชฝ์ด Offset(0, 0)๋‹ค.

 

#3-2 ์‹คํ–‰ ํ™•์ธ

์‹คํ–‰์‹œ์ผฐ์„ ๋•Œ์˜ ๋ชจ์Šต์ด๋‹ค. ํด๋ฆญํ•  ๋•Œ๋งˆ๋‹ค ํ”ผ์ž์˜ ๋ชจ์–‘์ด ์ž˜ ๋ณ€ํ•œ๋‹ค. ํ•˜์ง€๋งŒ, ๋‚ด๊ฐ€ ์›ํ•˜๋Š” ๋ชจ์Šต์€ ์•„๋‹ˆ๋‹ค. ์™œ๋ƒํ•˜๋ฉด ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์—†๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. ์• ๋‹ˆ๋ฉ”์ด์…˜์˜ ๋ถ€์žฌ๋กœ, ๋ณ€ํ™”๊ฐ€ ๋š๋š ๋Š๊ฒจ ๋ณด์ธ๋‹ค. ์•„๋ž˜์—์„œ ํ”ผ์ž์— ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค.

 

#3-3 ์›์˜ ๋ชจ์–‘ ๋ฐ”๊พธ๊ธฐ - ์• ๋‹ˆ๋ฉ”์ด์…˜

์ž์—ฐ์Šค๋Ÿฌ์šด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์œ„ํ•ด์„  'ํ”ผ์ž' ํด๋ฆญ ์‹œ ์„œ์„œํžˆ ํ”ผ์ž๊ฐ€ '์ฐจ์˜ค๋ฅด๋Š”' ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ๋‚˜์™€์•ผ ํ•œ๋‹ค. ๊ทธ๋Ÿด๋ ค๋ฉด drawArc()๊ฐ€ ์—ฐ์†์ ์œผ๋กœ trigger(์œ ๋ฐœ)๋˜์–ด์•ผํ•  ๊ฒƒ์ด๋‹ค. ์šฐ์„  #2-2์— ์žˆ๋Š” animtedFloatAsState()์— ๋Œ€ํ•ด ์ดํ•ดํ•˜๊ธฐ ๋ฐ”๋ž€๋‹ค.

 

"drawArc()๊ฐ€ ์—ฐ์†์ ์œผ๋กœ trigger๋˜์–ด์•ผํ•œ๋‹ค."๋Š” ๋ช…์ œ์—์„œ Recomposition ์ด์šฉํ•  ์ˆ˜ ์žˆ๊ฒ ๋‹ค๋Š” ์ƒ๊ฐ์„ ํ–ˆ๋‹ค. Recomposition ๋˜ํ•œ ์—ฐ์†์ ์œผ๋กœ trigger๋˜๋‹ˆ๊นŒ. animtedFloatAsState()์„ ์™ธ๋ถ€์— ๋‘๊ณ  drawArc()์— ์ฃผ์ž…ํ•˜๋ฉด (= State Hoisting) ์–ด๋–จ๊นŒ? ์‚ฌ์šฉ์ž๊ฐ€ ํ”ผ์ž๋ฅผ ํด๋ฆญํ•  ๋•Œ๋งˆ๋‹ค targetValue๊ฐ€ ๋ณ€ํ•  ๊ฒƒ์ด๊ณ , animationSpec ํ”„๋กœํผํ‹ฐ์— ๋ช…์‹œํ•œ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹œ๊ฐ„ ๋™์•ˆ animtedFloatAsState()๊ฐ€ ์—ฐ์†์ ์œผ๋กœ ๋ณ€ํ•  ๊ฒƒ์ด๊ณ , ๊ทธ ๋™์•ˆ drawArc() ๋˜ํ•œ ์—ฐ์†์ ์œผ๋กœ (Recompostion์— ์˜ํ•ด) trigger๋  ๊ฒƒ์ด๋‹ค. ๋ง์ด ์–ด๋ ต๋‹ค. ๋‹จ๋„์ง์ž…์ ์œผ๋กœ ์•„๋ž˜์˜ ์ฝ”๋“œ๋ฅผ ๋ณด์ž. ๊ทธ ํŽธ์ด ์ดํ•ด๊ฐ€ ๋” ๋น ๋ฅผ ์ˆ˜ ์žˆ๋‹ค.

 

...

class MainActivity : ComponentActivity() {
    ... // #3-1๊ณผ ๊ฐ™์Œ
}

@Composable
fun ResponsivePizzaShape(
    ... // #3-1๊ณผ ๊ฐ™์Œ
) {
    ... // #3-1๊ณผ ๊ฐ™์Œ

    Box(
        ... // #3-1๊ณผ ๊ฐ™์Œ
    ) {
        // State Hoisting
        val animatedRemainingSlices by animateFloatAsState(
            targetValue = remainingSlices.toFloat(),
            animationSpec = tween(durationMillis = 500, easing = LinearOutSlowInEasing)
        )

        Canvas(modifier = Modifier.fillMaxSize()) {
            animatedDrawPizza(
                animatedRemainingSlices = animatedRemainingSlices,
                totalSlices = totalSlices,
                color = color
            )
        }
    }
}

fun DrawScope.drawPizza(
    ... // #3-1๊ณผ ๊ฐ™์Œ
) {
    ... // #3-1๊ณผ ๊ฐ™์Œ
}

fun DrawScope.animatedDrawPizza(
    animatedRemainingSlices: Float,
    totalSlices: Int,
    color: Color
) {
    val radius = size.minDimension / 2 // minDimension์€ size์˜ ๋„ˆ๋น„์™€ ๋†’์ด ์ค‘ ๋” ์ž‘์€ ๊ฐ’์„ ๋ฐ˜ํ™˜
    val center = Offset(size.width / 2, size.height / 2) // ๋งจ โ†–์—์„œ โ†’์ชฝ์œผ๋กœ (๋„ˆ๋น„/2)๋งŒํผ โ†“์ชฝ์œผ๋กœ (๋†’์ด/2)๋งŒํผ ์›€์ง์ธ ๊ณณ
    val sliceAngle = 360f / totalSlices

    drawArc(
        color = color,
        startAngle = 180f, // ์‹œ์นจ์˜ 3์‹œ ๋ฐฉํ–ฅ๋ถ€ํ„ฐ ์‹œ์ž‘
        sweepAngle = sliceAngle * animatedRemainingSlices,
        useCenter = true,
        topLeft = Offset(center.x - radius, center.y - radius),
        size = Size(radius * 2, radius * 2)
    )
}

์‚ฌ์šฉ์ž๊ฐ€ 'ํ”ผ์ž'๋ฅผ ํด๋ฆญํ•  ๋•Œ๋งˆ๋‹ค, animatedRemainingSlices์˜ ๊ฐ’์ด 0.5์ดˆ๋™์•ˆ ์„œ์„œํžˆ ์ฆ๊ฐ€ํ•˜๋ฉฐ ๊ทธ๋•Œ๋งˆ๋‹ค animatedDrawPizza() ๋˜ํ•œ ์—ฐ์†์ ์œผ๋กœ trigger๋œ๋‹ค.

 

#3-4 ์‹คํ–‰ ํ™•์ธ

์Šคํฌ๋ฆฐ์ƒท์œผ๋กœ๋Š” #3-2์™€ ๋˜‘๊ฐ™์•„์„œ ๊ทธ๋ƒฅ #3-2์˜ ์ด๋ฏธ์ง€๋ฅผ ์žฌ์‚ฌ์šฉํ–ˆ๋‹ค. ์Šคํฌ๋ฆฐ์ƒท์œผ๋กœ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ๋‹ด์„ ์ˆ˜๊ฐ€ ์—†๋Š” ๊ฒŒ ์•„์‰ฝ๋‹ค. ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋™์ž‘ ํ™•์ธ์ด ํ•„์š”ํ•œ ์‚ฌ๋žŒ์€ #3-5์—์„œ ์ „์ฒด ํ”„๋กœ์ ํŠธ๋ฅผ ๋‹ค์šด๋กœ๋“œํ•ด์„œ ์•ˆ๋“œ๋กœ์ด๋“œ ์ŠคํŠœ๋””์˜ค์—์„œ ์‹คํ–‰ํ•˜๊ธฐ๋ฅผ ๋ฐ”๋ž€๋‹ค.

 

#3-5 ์™„์„ฑ๋œ ์•ฑ

 

android-practice/playground/ResponsivePizzaShape at master ยท Kanmanemone/android-practice

Contribute to Kanmanemone/android-practice development by creating an account on GitHub.

github.com

#3์˜ ์ „์ฒด ์†Œ์Šค์ฝ”๋“œ๋‹ค.

 

#4 ๋ ˆ์ด์–ด2 ๊ตฌํ˜„

#4-1 ์ค‘๊ฐ„ ์ ๊ฒ€

์ง€๊ธˆ๊นŒ์ง€ ๊ตฌํ˜„ํ•œ ๊ฑด 'ํ”ผ์ž'์— ๋ถˆ๊ณผํ•˜๋‹ค. #1-3์—์„œ ๋งํ•œ ๋ ˆ์ด์–ด1์˜ ๊ตฌํ˜„์— ๋ถˆ๊ณผํ•œ ๊ฒƒ์ด๋‹ค. ์•„์ด์ฝ˜์„ ํด๋ฆญํ•  ๋•Œ 'ํ”ผ์ž'์˜ ๋ชจ์–‘์ด ๋ฐ”๋€Œ๊ฒŒ ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค. ์ฆ‰, #1-3์—์„œ ๋งํ•œ ๋ ˆ์ด์–ด2์˜ ๊ตฌํ˜„์ด๋‹ค. ์ด ๊ตฌํ˜„์„ ์œ„ํ•ด ์ƒ˜ํ”Œ ํ”„๋กœ์ ํŠธ๋ฅผ ๋˜ ํ•˜๋‚˜ ๋งŒ๋“ค์—ˆ๋‹ค. ์ด๋ฒˆ ์ƒ˜ํ”Œ ํ”„๋กœ์ ํŠธ์˜ ์ด๋ฆ„์€ ResponsiveArcSurroundedIconButton์ด๋‹ค.

 

TMI

๋”๋ณด๊ธฐ

๊ทธ๋ƒฅ ์ฒ˜์Œ์— ๋งŒ๋“  ์ƒ˜ํ”Œ ํ”„๋กœ์ ํŠธ์— ๋ชฝ๋•… ๋‹ค ํ•ฉ์ณ ๋„ฃ์„๊นŒ๋„ ์‹ถ์—ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์ด๋ ‡๊ฒŒ 2๊ฐœ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ํŽธ์ด ์ข‹์„ ๊ฑฐ๋ž€ ์˜ˆ๊ฐ์ด ๋“ค์—ˆ๋‹ค. ๋‚ด๊ฐ€ ๋งŒ๋“  'ํ”ผ์ž'๊ฐ€ ์–ธ์  ๊ฐ„ ๋‹ค๋ฅธ ๊ณณ์—์„œ ์“ฐ์ผ ๊ฑฐ๋ผ๋Š” ์˜ˆ๊ฐ์ด.

 

#4-2 ๊ตฌ์กฐ ์Šค์ผ€์น˜

๋ ˆ์ด์–ด1 ์œ„์— ๋ ˆ์ด์–ด2๊ฐ€ ์˜ฌ๋ผ๊ฐ€ ์žˆ๋Š” ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค. ๋ ˆ์ด์–ด1์—๋Š” #3์—์„œ ๋งŒ๋“  'ํ”ผ์ž'๋ฅผ ๋‘˜ ๊ฒƒ์ด๋‹ค. ๋ ˆ์ด์–ด2์—๋Š” ๋™๊ทธ๋ž€ ์•„์ด์ฝ˜์„ ๋‘˜ ๊ฒƒ์ด๋‹ค. Box() ์•ˆ์— ๋ ˆ์ด์–ด1 ๋ฐ ๋ ˆ์ด์–ด2๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์„ ์–ธํ•˜๋ฉด ๋  ๊ฒƒ์ด๋‹ค. ๋จผ์ € ํ•  ๊ฒƒ์€ ๋ ˆ์ด์–ด1๊ณผ 2๋ฅผ ํ•ฉ์ณ๋„ฃ์„ ์ปดํฌ์ €๋ธ”์˜ ์„ ์–ธ์ด๋‹ค. ์ด ์ปดํฌ์ €๋ธ”์˜ ์ด๋ฆ„์€... ํ”„๋กœ์ ํŠธ์™€ ๋˜‘๊ฐ™์ด "ResponsiveArcSurroundedIconButton"์œผ๋กœ ์ •ํ–ˆ๋‹ค.

 

#4-2 ์ฝ”๋“œ - ์ •์ ์ธ ๋ชจ์–‘

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ResponsiveArcSurroundedIconButtonTheme {
                Scaffold(
                    modifier = Modifier.fillMaxSize()
                ) { innerPadding ->
                    Column(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(innerPadding),
                        verticalArrangement = Arrangement.SpaceEvenly,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        ResponsiveArcSurroundedIconButton(
                            imageVector = Icons.AutoMirrored.Filled.Send,
                            contentDescription = "์ „์†ก",
                            currentLevel = 3,
                            maxLevel = 8,
                            arcColor = Color.Red,
                            arcWidth = 10
                        )

                        ResponsiveArcSurroundedIconButton(
                            imageVector = Icons.Default.Call,
                            contentDescription = "์ „ํ™”",
                            currentLevel = 13,
                            maxLevel = 30,
                            arcColor = Color.Green,
                            arcWidth = 20
                        )

                        ResponsiveArcSurroundedIconButton(
                            imageVector = Icons.Sharp.Done,
                            contentDescription = "์™„๋ฃŒ",
                            currentLevel = 1,
                            maxLevel = 3,
                            arcColor = Color.Yellow,
                            arcWidth = 30
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun ResponsiveArcSurroundedIconButton(
    imageVector: ImageVector,
    contentDescription: String,
    currentLevel: Int,
    maxLevel: Int,
    arcColor: Color,
    arcWidth: Int
) {
    Box(
        modifier = Modifier.size((40 + arcWidth).dp),
        contentAlignment = Alignment.Center
    ) {
        // ๋ ˆ์ด์–ด 1
        val animatedCurrentLevel by animateFloatAsState(
            targetValue = currentLevel.toFloat(),
            animationSpec = tween(durationMillis = 500, easing = LinearOutSlowInEasing)
        )

        Canvas(modifier = Modifier.fillMaxSize()) {
            animatedDrawArc(
                currentLevel = animatedCurrentLevel,
                maxLevel = maxLevel,
                color = arcColor
            )
        }

        // ๋ ˆ์ด์–ด 2
        FilledTonalIconButton(
            onClick = {
                // TODO: currentLevel ์ฆ๊ฐ€์‹œํ‚ค๋Š” ์ฝ”๋“œ
            }
        ) {
            Icon(
                imageVector = imageVector,
                contentDescription = contentDescription
            )
        }
    }
}

private fun DrawScope.animatedDrawArc(
    currentLevel: Float, maxLevel: Int, color: Color
) {
    val radius = (size.minDimension / 2) // minDimension์€ size์˜ ๋„ˆ๋น„์™€ ๋†’์ด ์ค‘ ๋” ์ž‘์€ ๊ฐ’์„ ๋ฐ˜ํ™˜
    val center = Offset(size.width / 2, size.height / 2) // ๋งจ โ†–์—์„œ โ†’์ชฝ์œผ๋กœ (๋„ˆ๋น„/2)๋งŒํผ โ†“์ชฝ์œผ๋กœ (๋†’์ด/2)๋งŒํผ ์›€์ง์ธ ๊ณณ
    val anglePerLevel = 360f / maxLevel

    drawArc(
        color = color,
        startAngle = 180f, // ์‹œ์นจ์˜ 3์‹œ ๋ฐฉํ–ฅ๋ถ€ํ„ฐ ์‹œ์ž‘
        sweepAngle = anglePerLevel * currentLevel,
        useCenter = true,
        topLeft = Offset(center.x - radius, center.y - radius),
        size = Size(radius * 2, radius * 2)
    )
}

์‚ฌ์šฉ์ž์˜ ๋™์ž‘์ด ๊ฐ€๋ฏธ๋˜์ง€ ์•Š์€, ์ •์ ์ธ ๋ชจ์–‘์˜ ๊ตฌํ˜„์ด๋‹ค. ์‚ด์ง ์‹ ๊ฒฝ์“ฐ์ด๋Š” ๋ถ€๋ถ„์ด ์žˆ๋‹ค. Box() Size๋ฅผ ๊ฒฐ์ •ํ•˜๋Š” ๋ถ€๋ถ„์„ ๋ณด๋ผ. Box()์˜ Size๋Š” ๋งค๊ฐœ๋ณ€์ˆ˜ arcWidth๊ฐ’์— ์ข…์†๋˜์–ด์žˆ๋‹ค. ์ด ๋•Œ, ํ•ด๋‹น ์ฝ”๋“œ์—์„œ๋Š” FilledTonalButton์˜ Size๋ฅผ '์ฐธ์กฐ'ํ•˜๊ณ  ์žˆ์ง€ ์•Š๋‹ค. "40.dp ์ด๊ฒ ๊ฑฐ๋‹ˆ"ํ•˜๊ณ  ๋„˜๊ฒจ์งš์–ด ํ•˜๋“œ์ฝ”๋”ฉํ•˜๊ณ  ์žˆ๋‹ค. FilledTonalButton์˜ ์‚ฌ์ด์ฆˆ๋Š” internal ์ ‘๊ทผ์ œ์–ด์ž๋กœ ์ธํ•ด ์ฐธ์กฐ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. internal ์ ‘๊ทผ์ œ์–ด์ž๊ฐ€ ๋ถ™์€ ์ฝ”๋“œ์—์„œ๋Š” ์‚ฌ์ด์ฆˆ๊ฐ€ 40.dp์ด์—ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๋จธํ„ฐ๋ฆฌ์–ผ ๊ฐ€์ด๋“œ3์—์„œ๋„ 40.dp๋กœ ์•ˆ๋‚ดํ•˜๊ณ  ์žˆ๋‹ค. ๋”ฐ๋ผ์„œ 40.dp์ด๋ผ๊ณ  ๋„˜๊ฒจ์งš๋“ฏ ํ”„๋กœ๊ทธ๋ž˜๋ฐํ•œ ๊ฒƒ์— ๋Œ€ํ•œ ์ฐ์ฐํ•จ์€ ๋„˜์–ด๊ฐ€๊ธฐ๋กœ ํ•œ๋‹ค.

 

#4-3 ์‹คํ–‰ ํ™•์ธ

๋‚ด๊ฐ€ ์›ํ•ด์™”๋˜, ๋ฐ˜๊ฐ€์šด ๋ชจ์–‘์˜ ์•„์ด์ฝ˜์ด๋‹ค.

 

#4-4 ์ฝ”๋“œ - ์‚ฌ์šฉ์ž์˜ ์ž…๋ ฅ๊นŒ์ง€

์ด์ œ ์‚ฌ์šฉ์ž์˜ ๋™์ž‘๊นŒ์ง€ ๊ณ ๋ คํ•œ ์ฝ”๋“œ๋ฅผ ๊ตฌํ˜„ํ•œ๋‹ค.

 

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        setContent {
            ResponsiveArcSurroundedIconButtonTheme {
                Scaffold(
                    ...
                ) { ...
                    Column(
                        ...
                    ) {
                        var level1 by remember { mutableIntStateOf(0) }
                        val maxLevel1 = 8
                        var level2 by remember { mutableIntStateOf(0) }
                        val maxLevel2 = 30
                        var level3 by remember { mutableIntStateOf(0) }
                        val maxLevel3 = 3

                        ResponsiveArcSurroundedIconButton(
                            ...
                            currentLevel = level1,
                            maxLevel = maxLevel1,
                            ...
                        ) {
                            level1 = if(level1 < maxLevel1) {
                                level1 + 1
                            } else {
                                0
                            }
                        }

                        ResponsiveArcSurroundedIconButton(
                            ...
                            currentLevel = level2,
                            maxLevel = maxLevel2,
                            ...
                        ) {
                            level2 = if(level2 < maxLevel2) {
                                level2 + 1
                            } else {
                                0
                            }
                        }

                        ResponsiveArcSurroundedIconButton(
                            ...
                            currentLevel = level3,
                            maxLevel = maxLevel3,
                            ...
                        ) {
                            level3 = if(level3 < maxLevel3) {
                                level3 + 1
                            } else {
                                0
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun ResponsiveArcSurroundedIconButton(
    ...
    onIconClick: () -> Unit
) {
    Box(
        ...
    ) {
        // ๋ ˆ์ด์–ด 1
        ...

        // ๋ ˆ์ด์–ด 2
        FilledTonalIconButton(
            onClick = {
                onIconClick()
            }
        ) {
            Icon(
                ...
            )
        }
    }
}

private fun DrawScope.animatedDrawArc(
    ...
) {
    ...
}

๋ฒ„ํŠผ ํด๋ฆญ ์‹œ์˜ ๋™์ž‘์€ ์—ด๋ฆฐ ๊ฒฐ๋ง๋กœ ๋‘”๋‹ค. ์—ด๋ฆฐ ๊ฒฐ๋ง ์ฆ‰, ์ปค์Šคํ…€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋‘”๋‹ค๋Š” ๋ง์ด๋‹ค. ์ด ๋ฐฉ์‹์€ State Hoisting ํŒจํ„ด๊ณผ๋„ ๋“ค์–ด๋งž๊ธฐ์— ๋” ์ข‹๋‹ค. ์—ฌ๊ธฐ์—์„œ ๋ฐ”๋กœ ์“ธ ์ˆ˜ ์žˆ๋Š” State Hoisting ํŒจํ„ด์€ ๊ตฌ์ฒด์ ์œผ๋กœ ๋ญ˜๊นŒ. ์ฒซ์งธ๋กœ, State๋ฅผ ์ƒ์œ„ ์š”์†Œ์—์„œ ํ•˜์œ„ ์š”์†Œ๋กœ ๋‚ด๋ ค์ฃผ์–ด์•ผ ํ•œ๋‹ค. ๊ทธ๋ž˜์„œ currentLevel์€ State๋กœ์„œ ResponsiveArcSurroundedIconButton()์˜ ์ƒ์œ„ ํ•จ์ˆ˜์— ๋‘˜ ๊ฒƒ์ด๋‹ค. ๋‘˜์งธ๋กœ, Event๋Š” ํ•˜์œ„ ์š”์†Œ์—์„œ ์ƒ์œ„์š”์†Œ๋กœ ์˜ฌ๋ ค์ฃผ์–ด์•ผ ํ•œ๋‹ค. ๊ทธ๋ž˜์„œ ResponsiveArcSurroundedIconButton()์˜ ๋งˆ์ง€๋ง‰ ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ๋žŒ๋‹ค ํ•จ์ˆ˜๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค.

 

#4-5 ์ž‘๋™ ํ™•์ธ


์ž˜ ๋™์ž‘ํ•œ๋‹ค. ์Šคํฌ๋ฆฐ์ƒท์ด๋ผ ํ˜ธ๊ฐ€ ๊ทธ๋ ค์ง€๋Š” ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ํ‘œํ˜„ํ•  ์ˆ˜ ์—†๋‹ค. #4-6์— ์ „์ฒด ํ”„๋กœ์ ํŠธ ์†Œ์Šค์ฝ”๋“œ๊ฐ€ ์žˆ๋‹ค. ๋™์ž‘ ํ™•์ธ์ด ํ•„์š”ํ•œ ์‚ฌ๋žŒ์€ ์ฐธ์กฐํ•˜์ž.

 

#4-6 ์™„์„ฑ๋œ ์•ฑ

 

android-practice/playground/ResponsiveArcSurroundedIconButton at master ยท Kanmanemone/android-practice

Contribute to Kanmanemone/android-practice development by creating an account on GitHub.

github.com

#4์˜ ์ „์ฒด ์†Œ์Šค์ฝ”๋“œ๋‹ค.