Skip to content

Instantly share code, notes, and snippets.

@kimji1
Last active February 23, 2025 14:03
Show Gist options
  • Save kimji1/7e07e2251f0ddb8d4149e82689ab6b6c to your computer and use it in GitHub Desktop.
Save kimji1/7e07e2251f0ddb8d4149e82689ab6b6c to your computer and use it in GitHub Desktop.
@Composable
fun HorizontalStackedBarChart(
modifier: Modifier = Modifier,
textMeasurer: TextMeasurer,
state: HorizontalStackedBarState,
legendState: LegendState? = null,
) {
fun elementSpaceCount(elements: List<HorizontalStackedBarElement>): Int =
elements.count { it.useSpace }
val chartTotalHeight by remember { mutableFloatStateOf(state.getTotalHeight(textMeasurer)) }
Column(
modifier = Modifier
.fillMaxWidth()
.height(chartTotalHeight.toDp())
.then(modifier)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(state.chartHeight.toDp())
) {
HorizontalStackedBar(
modifier = Modifier.align(Alignment.Center),
state = state,
elementSpaceCount = ::elementSpaceCount
)
val vectorPainterState = state.vectorPainterState
if (vectorPainterState != null) {
HorizontalStackedBarIcon(
modifier = Modifier.fillMaxSize(),
state = state,
vectorPainterState = vectorPainterState,
elementSpaceCount = ::elementSpaceCount
)
}
}
HorizontalStackedBarName(
textMeasurer = textMeasurer,
modifier = Modifier.fillMaxSize(),
state = state,
elementSpaceCount = ::elementSpaceCount
)
}
if (legendState != null) {
Legend(
modifier = legendState.modifier,
legendState = legendState
)
}
}
@Composable
fun HorizontalStackedBar(
modifier: Modifier = Modifier,
state: HorizontalStackedBarState,
elementSpaceCount: (List<HorizontalStackedBarElement>) -> Int,
) {
val cornerRadius = state.barHeight / 2
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(state.barHeight.toDp())
.clip(RoundedCornerShape(cornerRadius))
.then(modifier)
) {
val elements = state.normalizedElements
val elementTotalWidth = size.width - (state.elementSpace * elementSpaceCount(elements))
val unit = elementTotalWidth / state.normalizedBase
elements.forEachIndexed { idx, element ->
val spaceCount = elementSpaceCount(elements.subList(0, idx + 1))
val stackedWidth = element.min * unit + max(0f, spaceCount * state.elementSpace)
val elementWidth = max(element.base / 100f, element.max - element.min) * unit
drawHorizontalStackedBar(
currentIdx = idx,
lastIndex = elements.lastIndex,
barHeight = state.barHeight,
cornerRadius = cornerRadius,
stackedWidth = stackedWidth,
elementWidth = elementWidth,
topPadding = 0f,
color = element.color
)
}
}
}
fun DrawScope.drawHorizontalStackedBar(
currentIdx: Int,
lastIndex: Int,
barHeight: Float,
cornerRadius: Float,
stackedWidth: Float,
elementWidth: Float,
topPadding: Float,
color: Color
) {
val path = Path().apply {
val xPos = if (stackedWidth < cornerRadius) 0f else stackedWidth
val width = if (stackedWidth < cornerRadius) elementWidth + stackedWidth else elementWidth
val radius = CornerRadius(cornerRadius, cornerRadius)
addRoundRect(
RoundRect(
rect = Rect(
offset = Offset(x = xPos, y = topPadding),
size = size.copy(width = width, height = barHeight),
),
topLeft = if (currentIdx == 0) radius else CornerRadius.Zero,
topRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero,
bottomLeft = if (currentIdx == 0) radius else CornerRadius.Zero,
bottomRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero
)
)
}
drawPath(path, color = color)
}
@Composable
fun HorizontalStackedBarName(
textMeasurer: TextMeasurer,
modifier: Modifier = Modifier,
state: HorizontalStackedBarState,
elementSpaceCount: (List<HorizontalStackedBarElement>) -> Int,
) {
Canvas(modifier = modifier) {
val elements = state.normalizedElements
val elementTotalWidth = size.width - (state.elementSpace * elementSpaceCount(elements))
val unit = elementTotalWidth / state.normalizedBase
elements.forEachIndexed { idx, element ->
val spaceCount = elementSpaceCount(elements.subList(0, idx + 1))
val stackedWidth = element.min * unit + max(0f, spaceCount * state.elementSpace)
val elementWidth = max(0.1f, element.max - element.min) * unit
drawBarName(
textMeasurer = textMeasurer,
element = element,
stackedWidth = stackedWidth,
elementWidth = elementWidth,
nameTextStyle = state.elementNameTextStyle,
nameYPos = state.elementNameTopPadding,
)
}
}
}
fun DrawScope.drawBarName(
textMeasurer: TextMeasurer,
element: HorizontalStackedBarElement,
elementWidth: Float,
stackedWidth: Float,
nameTextStyle: TextStyle,
nameYPos: Float
) {
element.barName ?: return
val textSize = textMeasurer.measure(
text = element.barName.text,
style = nameTextStyle
).size
val alignmentOffset = when (element.barName.alignment) {
HorizontalStackedBarNameAlignment.Start -> 0f
HorizontalStackedBarNameAlignment.Center -> (elementWidth / 2) - (textSize.width / 2)
HorizontalStackedBarNameAlignment.End -> elementWidth - textSize.width
}
drawText(
textMeasurer = textMeasurer,
text = element.barName.text,
style = nameTextStyle.copy(color = element.barName.textColor),
topLeft = Offset(
stackedWidth + alignmentOffset,
nameYPos
),
size = Size(textSize.width.toFloat(), textSize.height.toFloat())
)
}
@Composable
fun HorizontalStackedBarIcon(
modifier: Modifier = Modifier,
state: HorizontalStackedBarState,
vectorPainterState: VectorPainterState,
elementSpaceCount: (List<HorizontalStackedBarElement>) -> Int,
) {
Canvas(modifier = modifier) {
val targetValue = min(state.normalizedValue, state.normalizedBase)
val targetElement = state.normalizedElements.firstOrNull {
state.normalizedValue in it.min..it.max
} ?: return@Canvas
val elements = state.normalizedElements
val elementTotalWidth = size.width - (state.elementSpace * elementSpaceCount(elements))
val spaceCount = elementSpaceCount(elements.subList(0, elements.indexOf(targetElement) + 1))
val xPos =
(targetValue * elementTotalWidth / state.normalizedBase) + (spaceCount * state.elementSpace)
drawVectorIcon(
color = targetElement.color,
start = xPos,
vectorPainterState = vectorPainterState,
barHeight = state.barHeight,
)
}
}
fun DrawScope.drawVectorIcon(
color: Color,
start: Float,
vectorPainterState: VectorPainterState,
barHeight: Float,
) {
val painter = vectorPainterState.vectorPainter
val position = vectorPainterState.vectorPainterPosition
val backgroundColor = vectorPainterState.vectorPainterBackgroundColor
val iconHorizontalPadding = vectorPainterState.vectorPainterHorizontalPadding
val iconWidth = painter.intrinsicSize.width
val iconHeight = painter.intrinsicSize.height
val xPos = if (start + iconWidth > size.width) {
size.width - iconWidth
} else if (start < (iconWidth / 2)) {
0f
} else start - (iconWidth / 2)
val yPos = when (position) {
is VectorPainterPosition.Top -> (size.height / 2) - (barHeight / 2) - position.bottomSpace - iconHeight
is VectorPainterPosition.Bottom -> (size.height / 2) + (barHeight / 2) + position.topSpace
VectorPainterPosition.Center -> (size.height / 2) - (iconHeight / 2)
}
val topLeft = Offset(xPos, yPos)
drawRect(
color = backgroundColor,
topLeft = topLeft.copy(x = topLeft.x - iconHorizontalPadding),
size = Size(
width = iconWidth + (iconHorizontalPadding * 2),
height = iconHeight
)
)
translate(left = topLeft.x, top = topLeft.y) {
with(painter) {
draw(
size = painter.intrinsicSize,
colorFilter = ColorFilter.tint(color = color)
)
}
}
}
/**
* @property chartHeight 바차트 기준 상하 패딩 포함한 전체 높이
* @property barHeight bar 의 height
* @property elementSpace 차트의 각 요소 사이의 간격
* @property elements 차트의 각 요소 목록,
* @property elementNameTextStyle 차트의 각 요소별 이름,
* @property elementNameTopPadding 차트의 각 요소별 이름과 차트 사이의 거리
* @property vectorPainterState 차트에 그려질 아이콘 상태
* @property value 해당 값, 아이콘 표출 위치
*/
data class HorizontalStackedBarState(
val chartHeight: Float,
val barHeight: Float,
val elementSpace: Float,
val elements: List<HorizontalStackedBarElement>,
val elementNameTextStyle: TextStyle,
val elementNameTopPadding: Float,
val vectorPainterState: VectorPainterState?,
val value: Float,
) {
private val start = elements.minOf { it.min }
val normalizedElements: List<HorizontalStackedBarElement> = elements.map {
it.copy(min = it.min - start, max = it.max - start, base = it.base - start)
}
val normalizedBase = normalizedElements.firstOrNull()?.base ?: 0f
val normalizedValue = value - start
fun getTotalHeight(textMeasurer: TextMeasurer): Float {
val name = firstElementName()
val nameHeight = if (name.isNullOrBlank()) 0f else {
textMeasurer.measure(name, style = elementNameTextStyle).size.height.toFloat()
}
return chartHeight + nameHeight
}
private fun firstElementName() = elements.firstOrNull { it.barName != null }?.barName?.text
}
data class HorizontalStackedBarElement(
val base: Float,
val min: Float,
val max: Float,
val color: Color,
val useSpace: Boolean = true,
val barName: HorizontalStackedBarName? = null,
) {
fun width(totalWidth: Float) = max(0.1f, ((max - min) * totalWidth) / base)
}
data class HorizontalStackedBarName(
val text: String,
val textColor: Color,
val alignment: HorizontalStackedBarNameAlignment,
)
enum class HorizontalStackedBarNameAlignment {
Start,
Center,
End;
}
/**
* @property vectorPainter chart icon
* @property vectorPainterHorizontalPadding icon background space
* @property vectorPainterPosition
* @property vectorPainterBackgroundColor chart background color
*/
data class VectorPainterState(
val vectorPainter: VectorPainter,
val vectorPainterHorizontalPadding: Float,
val vectorPainterPosition: VectorPainterPosition,
val vectorPainterBackgroundColor: Color,
)
sealed class VectorPainterPosition {
data class Top(val bottomSpace: Float) : VectorPainterPosition()
data class Bottom(val topSpace: Float) : VectorPainterPosition()
data object Center : VectorPainterPosition()
}
@Composable
fun rememberVectorPainterState(
vectorIcon: ImageVector? = null,
vectorPainterHorizontalPadding: Float = 1.dp.toPx(),
vectorPainterPosition: VectorPainterPosition = VectorPainterPosition.Center,
vectorPainterBackgroundColor: Color = VCTheme.colors.surface99,
): VectorPainterState? {
vectorIcon ?: return null
val painter = rememberVectorPainter(image = vectorIcon)
return remember(vectorIcon) {
VectorPainterState(
vectorPainter = painter,
vectorPainterHorizontalPadding = vectorPainterHorizontalPadding,
vectorPainterPosition = vectorPainterPosition,
vectorPainterBackgroundColor = vectorPainterBackgroundColor
)
}
}
@Composable
fun rememberHorizontalStackedBarState(
value: Float,
elements: List<HorizontalStackedBarElement>,
vectorPainterState: VectorPainterState? = null,
chartHeight: Float = 16.dp.toPx(),
barHeight: Float = 3.dp.toPx(),
elementSpace: Float = 1.dp.toPx(),
elementNameTextStyle: TextStyle = VCTheme.typo.caption1,
elementNameTopPadding: Float = 0.dp.toPx(),
): HorizontalStackedBarState {
return remember {
HorizontalStackedBarState(
value = value,
elements = elements,
chartHeight = chartHeight,
barHeight = barHeight,
elementSpace = elementSpace,
elementNameTextStyle = elementNameTextStyle,
elementNameTopPadding = elementNameTopPadding,
vectorPainterState = vectorPainterState,
)
}
}
@Composable
private fun elementsForPreview() = rememberHorizontalStackedBarState(
value = 90f,
elements = listOf(
HorizontalStackedBarElement(
base = 100f,
min = 0f,
max = 30f,
color = VCTheme.colors.graphLow,
barName = HorizontalStackedBarName(
"Low",
VCTheme.colors.graphLow,
HorizontalStackedBarNameAlignment.Start
),
useSpace = false,
),
HorizontalStackedBarElement(
base = 100f,
min = 31f,
max = 70f,
color = VCTheme.colors.graphSafety,
barName = HorizontalStackedBarName(
"Safety",
VCTheme.colors.graphSafety,
HorizontalStackedBarNameAlignment.Center
),
useSpace = true,
),
HorizontalStackedBarElement(
base = 100f,
min = 71f,
max = 100f,
color = VCTheme.colors.graphHigh,
barName = HorizontalStackedBarName(
"High",
VCTheme.colors.graphHigh,
HorizontalStackedBarNameAlignment.End
),
useSpace = false,
)
),
vectorPainterState = rememberVectorPainterState(
vectorIcon = ImageVector.vectorResource(id = R.drawable.ic_16_good),
vectorPainterBackgroundColor = Color.Transparent
),
chartHeight = 80f,
barHeight = 16f,
elementSpace = 1f,
elementNameTextStyle = VCTheme.typo.caption1,
elementNameTopPadding = 4f
)
@Preview
@Composable
fun HorizontalStackedBarPreview() {
VirtualCareTheme {
val elements = elementsForPreview().elements
HorizontalStackedBar(
state = elementsForPreview(),
elementSpaceCount = { elements.count { it.useSpace } }
)
}
}
@Preview
@Composable
fun HorizontalStackedBarNamePreview() {
VirtualCareTheme(darkTheme = true) {
val elements = elementsForPreview().elements
HorizontalStackedBarName(
textMeasurer = rememberTextMeasurer(),
state = elementsForPreview(),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 20.dp)
) {
elements.count { it.useSpace }
}
}
}
/**
* @see Legend
* @see ReportCard
* */
@Preview
@Composable
fun HorizontalStackedBarChartPreview() {
VirtualCareTheme {
Column {
HorizontalStackedBarChart(
textMeasurer = rememberTextMeasurer(),
state = elementsForPreview(),
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Legend(modifier: Modifier = Modifier, legendState: LegendState) {
when (legendState.direction) {
is LegendDirection.Horizontal -> {
FlowRow(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
horizontalArrangement = legendState.direction.arrangement
) {
legendState.legends.forEach { legend ->
Row(verticalAlignment = Alignment.CenterVertically) {
LegendShape(legend.shape)
Spacer(modifier = Modifier.width(legend.spaceToShape))
legend.content(this)
}
}
}
}
is LegendDirection.Vertical -> {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
verticalArrangement = Arrangement.spacedBy(legendState.direction.space),
) {
legendState.legends.forEach { legend ->
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
verticalAlignment = Alignment.CenterVertically,
) {
LegendShape(legend.shape)
Spacer(modifier = Modifier.width(legend.spaceToShape))
legend.content(this)
}
}
}
}
}
}
@Composable
fun LegendShape(shape: LegendShapeState) {
when (shape.type) {
DASH -> {
// TODO()
}
RECT -> {
// TODO()
}
OUTLINED_RECT -> {
// TODO()
}
CIRCLE -> {
Canvas(modifier = Modifier.size(shape.size)) {
val halfWidth = size.width / 2
drawCircle(
color = shape.color,
radius = halfWidth,
center = Offset(halfWidth, halfWidth)
)
}
}
}
}
@Composable
fun rememberLegendState(
modifier: Modifier = Modifier,
legends: List<LegendElement>,
direction: LegendDirection = LegendDirection.Horizontal(),
) = remember(legends) {
LegendState(modifier = modifier, legends = legends, direction = direction)
}
/**
* @property[legends] [LegendElement] 목록
* @property[direction] [LegendDirection]
*/
data class LegendState(
val modifier: Modifier,
val legends: List<LegendElement>,
val direction: LegendDirection,
)
sealed class LegendDirection {
data class Horizontal(
val arrangement: Arrangement.Horizontal = Arrangement.spacedBy(
space = 16.dp,
alignment = Alignment.Start
)
) : LegendDirection()
data class Vertical(val space: Dp = 8.dp) : LegendDirection()
}
/**
* @property[content] 범례
* @property[shape] 범례 모양 정보
* @property[spaceToShape] 범례 모양과 설명의 간격
*/
data class LegendElement(
val content: @Composable RowScope.() -> Unit,
val shape: LegendShapeState,
val spaceToShape: Dp = 4.dp,
)
/**
* @property[type] 범례 모양
* @property[size] 범례 모양 사이즈
* @property[color] 범례 모양 color
*/
data class LegendShapeState(
val type: LegendShapeType,
val size: DpSize,
val color: Color,
)
/**
* [DASH]
* [RECT]
* [OUTLINED_RECT]
* [CIRCLE]
*/
enum class LegendShapeType {
DASH,
RECT,
OUTLINED_RECT,
CIRCLE;
}
@Preview(widthDp = 360)
@Composable
fun Legend_Horizontal_Preview() {
VirtualCareTheme(darkTheme = true) {
Legend(
legendState = rememberLegendState(
legends = listOf(
LegendElement(
content = @Composable {
Text(
text = "범례1",
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40)
)
},
shape = LegendShapeState(
type = CIRCLE,
size = DpSize(4.dp, 4.dp),
color = VCTheme.colors.graphHigh
)
), LegendElement(
content = @Composable {
Text(
text = "범례2",
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40)
)
},
shape = LegendShapeState(
type = CIRCLE,
size = DpSize(4.dp, 4.dp),
color = VCTheme.colors.graphHigh40
)
), LegendElement(
content = @Composable {
Text(
text = "범례3",
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40)
)
},
shape = LegendShapeState(
type = CIRCLE,
size = DpSize(4.dp, 4.dp),
color = VCTheme.colors.graphHigh80
)
)
),
direction = LegendDirection.Horizontal()
)
)
}
}
@Preview(widthDp = 360)
@Composable
fun Legend_Vertical_Preview() {
VirtualCareTheme(darkTheme = true) {
Legend(
legendState = rememberLegendState(
legends = listOf(
LegendElement(
content = @Composable {
Text(
modifier = Modifier.weight(1f),
text = "범례1",
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40)
)
},
shape = LegendShapeState(
type = CIRCLE,
size = DpSize(4.dp, 4.dp),
color = VCTheme.colors.graphHigh
)
), LegendElement(
content = @Composable {
Text(
modifier = Modifier.weight(1f),
text = "범례2",
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40)
)
},
shape = LegendShapeState(
type = CIRCLE,
size = DpSize(4.dp, 4.dp),
color = VCTheme.colors.graphHigh40
)
), LegendElement(
content = @Composable {
Text(
modifier = Modifier.weight(1f),
text = "범례3",
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40)
)
},
shape = LegendShapeState(
type = CIRCLE,
size = DpSize(4.dp, 4.dp),
color = VCTheme.colors.graphHigh80
)
)
),
direction = LegendDirection.Vertical()
)
)
}
}
fun Float?.toIntegerableString(
nullStr: String = "",
requireRound: Boolean = false,
requireTwoDecimalPlaces: Boolean = false
): String = if (this == null) {
nullStr
} else if (requireRound || (this % 1).compareTo(0f) == 0) {
DecimalFormat("##,###").format(roundHalfUp(0).toInt())
} else if (requireTwoDecimalPlaces && (this % 0.1).compareTo(0f) != 0) {
DecimalFormat("##,###.##").format(this)
} else {
DecimalFormat("##,###.#").format(this)
}
fun Dp.toPx(): Float {
return this.value * getDensity()
}
fun Float.toDp(): Dp {
return (this / getDensity()).dp
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment