#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์ ์ ์ฒด ์์ค์ฝ๋๋ค.
'๊ฐ๋ฐ ์ผ์ง ๐ป > Nutri Capture' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
Nutri Capture ๋ฐฑ์๋ - NutritionInfo ๋ฆฌํฉํ ๋ง (0) | 2025.03.26 |
---|---|
Nutri Capture ํ๋ก ํธ์๋ - 'ํผ์' ์์ด์ฝ ์์ ์ ์ฉ (0) | 2025.03.25 |
Nutri Capture ํ๋ก ํธ์๋ - ์ปค์คํ BottomSheetScaffold ๊ฐ๋ฐ ์ ์ (0) | 2025.03.19 |
Nutri Capture ๋ฐฑ์๋ - Hilt ๋์ (0) | 2025.02.01 |
Nutri Capture ํ๋ก ํธ์๋ - windowInsetsPadding() (0) | 2025.01.29 |