[Kotlin] Vico 라이브러리를 사용해서 그래프 구현
Vico란?
Koltin Jetpack Compose에서는 그래프를 그리기 위해 그래프를 그리는 컴포저블 함수를 찾았지만 찾을 수 없었다. 그래서 발견하게 된 라이브리리가 Vico 이다.
Vico 라이브러리란 Koltin Jetpack Compose에서 통계를 내는 경우 한 눈에 보기 편하게 차트로 보여줄 수 있는 라이브러리이다.
Vico 종속을 위한 필수 설정
나는 아무것도 모르고 vico 공식 홈페이지에 들어가 종속성을 추가하고 sync 했지만 오류가 났다. 자세한 원인을 알아보려고 구글링을 몇 시간 동안한 끝에 이유를 알았다. 이유는 targetSdk였다. 지금까지 나는 targetSdk가 33인 환경에서 안드로이드스튜디오를 실행했던 것이다.
TargetSdk 설정
- build.gradle.kts(Module 수준)
android { defaultConfig { .. targetSdk = 34 // 최소 34이상 .. } }
두 번째 문제 발생
targetSdk를 33 -> 34로 바꾸서 sync를 했지만 또 오류가 났다. 순간 욕이 나왔지만 다시 한번 오류 원인을 알아보기 위해 구글링을 했다. 그 결과 코틀린 버전마다 지원하는 targetSdk 레벨이 달랐다. 즉 나는 코틀린 버전을 바꾸려고 했고 그 과정속에 안드로이드 스튜디오의 버전도 바꿨다.
AndroidStudio Hedgehog으로 버전을 바꿨다.
공식 홈페이지 AndroidStudio 버전
아래의 사진을 보면 AndroidStudio 버전마다 API 수준이 다른걸 알 수 있다.
아래는 targetSdk가 34이기 위한 코틀린 버전이다. “1.9.0”으로 설정했다.
- build.gradle.kts(App 수준)
plugins { id("com.android.application") version "8.2.1" apply false id("org.jetbrains.kotlin.android") version "1.9.0" apply false id("com.google.devtools.ksp") version "1.9.0-1.0.12" apply false }
Vico 종속성 추가
- build.gradle.kts(Module 수준)
// vico implementation("com.patrykandpatrick.vico:compose:2.0.0-alpha.6") // For Jetpack Compose. implementation("com.patrykandpatrick.vico:compose-m3:2.0.0-alpha.6") // For `compose`. Creates a `ChartStyle` based on an M3 Material Theme.
CartesianChartHost
그래프를 그리기 위해 가장 기본이 되는 컴포저블 함수이다. CartesianChartHost의 가장 중요한 매개변수는 chart와 modelProducer이다.
CartesianChartHost(
chart = rememberCartesianChart(...),
modelProducer = ...,
marker = ...,
isZoomEnabled = false,
horizontalLayout = HorizontalLayout.fullWidth()
)
📌 공부하면서 알게된 사실을 이해하기 쉽게 간단하게 설명하려고 한다.
model: 그래프의 수치를 표현을 위한 아래의 x축과 y축을 의미
modelProducer: 데이터를 넣으면 차트 모델을 만들어 준다.(당연히 동적으로 변경 가능하다.)
marker: 화면을 터치하거나 화면을 누르고 있을 때 해당 x좌표의 marker를 사용자에게 보여준다.
isZoomEnabled: 차트의 확대 가능 여부를 설정하는 매개변수
horizontalLayout: 차트가 x좌표를 기준으로 얼마큼의 가로 길이를 갖는지에 대한 설정이다.
HorizontalLayout.segmented() 는 디폴트 값이다. 차트를 x좌표에 가득 채우고 싶으면 별도로 HorizontalLayout.fullWidth()를 설정해야 한다.
rememberCartesianChartart
rememberCartesianChart() 컴포저블 함수는 앞에서 설명했던 CartesianChartHost 컴포저블 함수의 chart 매개변수에 사용된다. 아래의 코드를 살펴보자.
private const val AXIS_VALUE_OVERRIDER_Y_FRACTION = 1f
private val axisValueOverrider =
AxisValueOverrider.adaptiveYValues<LineCartesianLayerModel>(
yFraction = AXIS_VALUE_OVERRIDER_Y_FRACTION,
round = false, // 반올림
)
CartesianChartHost(
chart = rememberCartesianChart(
rememberLineCartesianLayer(axisValueOverrider = axisValueOverrider),
startAxis = rememberStartAxis(guideline = null),
bottomAxis = rememberBottomAxis(
guideline = null,
itemPlacer = AxisItemPlacer.Horizontal.default(),
),
persistentMarkers = remember(marker) { mapOf(2.0f to marker) },
fadingEdges = rememberFadingEdges(),
legend = rememberLegend()
)
)
-
rememberCartesianChartart() 매개변수 설명
startAxis는 y축 좌표이며, 좌측 하단에서 시작한다.
bottomAxis는 x축 좌표이며, 좌측 하단에서 시작한다.
persistentMarkers은 해당 x좌표의 y에 지속적인 marker를 표시하는 매개변수, (코드에서는 2.0f 인 x 좌표에 marker를 찍어줌.)
fadingEdges은 특정 가장자리 너머에 더 많은 콘텐츠가 있으며 사용자가 스크롤하여 이를 드러낼 수 있음을 알려준다.
legend은 차트에 대해 부가적인 설명을 String으로 나타낸다.x축 좌표의 8번째 데이터 부터 차트가 흐릿해지는 볼 수 있는데 이걸 fade 효과라고 하고 scroll해서 다음 데이터를 확인할 수 있음을 알려준다.
- rememberStartAxis()의 guideline 매개변수
rememberStartAxis()의 guideline은 y축의 모든 좌표가 점선으로 연결되어 데이터를 보기 편하게 나타낸것이다.rememberBottomAxis()의 guideline까지 설정 하면 x축의 모든 좌표와 y축의 모든 좌표가 점섬으로 연결되어진다. 그림으로 보면 편할 것이다.rememberStartAxis()와rememberBottomAxis()에는 디폴트 값으로guideline = axisGuidelineComponent()이 설정되어 있어 따로 guideline을 설정하지 않아도 된다. - rememberBottomAxis()의 itemPlacer 매개변수
itemPlacer은 x축의 데이터간의 공간을 설정할 수 있다. 아래의 그림으로 이해해보자. 따로 itemPlacer를 설정하지 않아도 기본적으로itemPlacer = AxisItemPlacer.Horizontal.default()로 적용되어 있다. -
rememberLineCartesianLayer()
y축 좌표에 최대와 최소, x축 좌표의 최대와 최소 값을 설정할 수 있고 동적으로 y축 좌표의 값을 변경 할 수 있다.
AxisValueOverrider.adaptiveYValues(): 동적으로 y축 좌표의 값을 변경AxisValueOverrider.fixed(): 고정적으로 y축 좌표에 최대와 최소, x축 좌표의 최대와 최소 값을 설정- AxisValueOverrider.adaptiveYValues()의 매개변수
yFraction: y좌표중 최대값의 배수 만큼 y좌표의 최대값을 설정 ex) y좌표중 최대값이 9.2인 경우 y축 좌표는 18.4의 크기까지 만들어 진다.
round: y좌표의 최대값과 최소값을 반올림 여부 설정
- AxisValueOverrider.adaptiveYValues()의 매개변수
- legend
차트의 부가적인 설명을 도와주는 Text라고 생각하면된다. 아래의 사진을 보고 이해하자.
CartesianChartModelProducer
이름에서 알 수 있듯이 CartesianChartModelProducer 는 CartesianChartHost() 컴포저블 함수의 modelProducer 매개변수에 쓰인다. 말 그대로 model을 생산한다. 아래의 코드를 살펴보자.
-
VicoViewModel.kt
class VicoViewModel @Inject constructor() : ViewModel() { private val modelProducer = CartesianChartModelProducer.build() private var _list = mutableStateListOf<Double>(1.0, 9.2, 3.3, 7.8) val list: List<Double> = _list init { changeSeries() } fun changeSeries() { modelProducer.tryRunTransaction { lineSeries { series(y = list) } } } fun setList(list: List<Double>) { _list.clear() list.forEach { _list.add(it) } changeSeries() } fun getModelProducer() = modelProducer }CartesianChartModelProducer.build()코드로 modelProducer를 만들고tryRunTransaction{}을 통해 데이터를 넣어준다. 본 포스팅에서는 LineChart를 사용하기 떄문에lineSeries{}를 통해 데이터를 삽입했다.
ViewModel이 생성될 당시changeSeries()메서드 호출을 통해 차트에 데이터를 넣어줬고 차트 수정이 필요할 때마다setList()를 호출함으로써 차트 수정및 UI에 반영이 되게 했다.
Marker
Marker는 화면을 터치하거나 누르고 있을 때, 해당 x좌표의 y 값을 표시하는 컴포저블 함수이다.
필자 또한 git에 있는 코드를 Ctrl + C, Ctrl + V 했기 때문에 코드 설명을 생략하겠다.
@Composable
internal fun rememberMarker(): Marker {
val labelBackgroundColor = MaterialTheme.colorScheme.surface
val labelBackground =
remember(labelBackgroundColor) {
ShapeComponent(labelBackgroundShape, labelBackgroundColor.toArgb()).setShadow(
radius = LABEL_BACKGROUND_SHADOW_RADIUS,
dy = LABEL_BACKGROUND_SHADOW_DY,
applyElevationOverlay = true,
)
}
val label =
rememberTextComponent(
background = labelBackground,
lineCount = LABEL_LINE_COUNT,
padding = labelPadding,
typeface = Typeface.MONOSPACE,
)
val indicatorInnerComponent = rememberShapeComponent(Shapes.pillShape, MaterialTheme.colorScheme.surface)
val indicatorCenterComponent = rememberShapeComponent(Shapes.pillShape, Color.White)
val indicatorOuterComponent = rememberShapeComponent(Shapes.pillShape, Color.White)
val indicator =
overlayingComponent(
outer = indicatorOuterComponent,
inner =
overlayingComponent(
outer = indicatorCenterComponent,
inner = indicatorInnerComponent,
innerPaddingAll = indicatorInnerAndCenterComponentPaddingValue,
),
innerPaddingAll = indicatorCenterAndOuterComponentPaddingValue,
)
val guideline =
rememberLineComponent(
MaterialTheme.colorScheme.onSurface.copy(GUIDELINE_ALPHA),
guidelineThickness,
guidelineShape,
)
return remember(label, indicator, guideline) {
object : MarkerComponent(label, indicator, guideline) {
init {
indicatorSizeDp = INDICATOR_SIZE_DP
onApplyEntryColor = { entryColor ->
indicatorOuterComponent.color = entryColor.copyColor(INDICATOR_OUTER_COMPONENT_ALPHA)
with(indicatorCenterComponent) {
color = entryColor
setShadow(radius = INDICATOR_CENTER_COMPONENT_SHADOW_RADIUS, color = entryColor)
}
}
}
override fun getInsets(
context: MeasureContext,
outInsets: Insets,
horizontalDimensions: HorizontalDimensions,
) = with(context) {
outInsets.top = label.getHeight(context) + labelBackgroundShape.tickSizeDp.pixels +
LABEL_BACKGROUND_SHADOW_RADIUS.pixels * SHADOW_RADIUS_MULTIPLIER -
LABEL_BACKGROUND_SHADOW_DY.pixels
}
}
}
}
private const val LABEL_BACKGROUND_SHADOW_RADIUS = 4f
private const val LABEL_BACKGROUND_SHADOW_DY = 2f
private const val LABEL_LINE_COUNT = 1
private const val GUIDELINE_ALPHA = .2f
private const val INDICATOR_SIZE_DP = 36f
private const val INDICATOR_OUTER_COMPONENT_ALPHA = 32
private const val INDICATOR_CENTER_COMPONENT_SHADOW_RADIUS = 12f
private const val GUIDELINE_DASH_LENGTH_DP = 8f
private const val GUIDELINE_GAP_LENGTH_DP = 4f
private const val SHADOW_RADIUS_MULTIPLIER = 1.3f
private val labelBackgroundShape = MarkerCorneredShape(Corner.FullyRounded)
private val labelHorizontalPaddingValue = 8.dp
private val labelVerticalPaddingValue = 4.dp
private val labelPadding = dimensionsOf(labelHorizontalPaddingValue, labelVerticalPaddingValue)
private val indicatorInnerAndCenterComponentPaddingValue = 5.dp
private val indicatorCenterAndOuterComponentPaddingValue = 10.dp
private val guidelineThickness = 2.dp
private val guidelineShape = DashedShape(Shapes.pillShape, GUIDELINE_DASH_LENGTH_DP, GUIDELINE_GAP_LENGTH_DP)
ProvideChartStyle
ProvideChartStyle을 이용해 차트 스타일을 적용해 주었다.
ProvideChartStyle() 안에 자신이 만든 ChartStyle을 넣어주면 된다.
ProvideChartStyle(rememberChartStyle(...)) {
CartesianChartHost(...)
}
전체 코드
LineChart.kt
private const val AXIS_VALUE_OVERRIDER_Y_FRACTION = 1f
private val axisValueOverrider =
AxisValueOverrider.adaptiveYValues<LineCartesianLayerModel>(
yFraction = AXIS_VALUE_OVERRIDER_Y_FRACTION,
round = false, // 반올림
)
@Composable
fun LineChart(viewModel: VicoViewModel = viewModel()) {
val marker = rememberMarker()
ProvideChartStyle(rememberChartStyle(chartColors = listOf(Color(0xFF007AFF)))) {
CartesianChartHost(
chart = rememberCartesianChart(
rememberLineCartesianLayer(axisValueOverrider = axisValueOverrider),
startAxis = rememberStartAxis(guideline = null),
bottomAxis = rememberBottomAxis(
guideline = null,
itemPlacer = AxisItemPlacer.Horizontal.default(),
),
persistentMarkers = remember(marker) { mapOf(2.0f to marker) },
fadingEdges = rememberFadingEdges(),
legend = rememberLegend()
),
modelProducer = viewModel.getModelProducer(),
marker = marker,
isZoomEnabled = false,
horizontalLayout = HorizontalLayout.fullWidth()
)
}
}
ChartStyle.kt
@Composable
internal fun rememberChartStyle(
columnLayerColors: List<Color>,
lineLayerColors: List<Color>,
): ChartStyle {
val isSystemInDarkTheme = isSystemInDarkTheme()
return remember(columnLayerColors, lineLayerColors, isSystemInDarkTheme) {
val defaultColors = if (isSystemInDarkTheme) DefaultColors.Dark else DefaultColors.Light
ChartStyle(
ChartStyle.Axis(
axisLabelColor = Color(defaultColors.axisLabelColor),
axisGuidelineColor = Color(defaultColors.axisGuidelineColor),
axisLineColor = Color(defaultColors.axisLineColor),
),
ChartStyle.ColumnLayer(
columnLayerColors.map { columnChartColor ->
LineComponent(
columnChartColor.toArgb(),
DefaultDimens.COLUMN_WIDTH,
Shapes.roundedCornerShape(DefaultDimens.COLUMN_ROUNDNESS_PERCENT),
)
},
),
ChartStyle.LineLayer(
lineLayerColors.map { lineChartColor ->
LineCartesianLayer.LineSpec(
shader = DynamicShaders.color(lineChartColor),
backgroundShader =
DynamicShaders.fromBrush(
Brush.verticalGradient(
listOf(
lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START),
lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END),
),
),
),
)
},
),
ChartStyle.Marker(),
Color(defaultColors.elevationOverlayColor),
)
}
}
@Composable
internal fun rememberChartStyle(chartColors: List<Color>) =
rememberChartStyle(
columnLayerColors = chartColors,
lineLayerColors = chartColors
)
Legend.kt
private const val MAIN_COLOR = 0xFF007AFF
private val color1 = Color(MAIN_COLOR)
private val chartColors = listOf(color1)
private val legendItemLabelTextSize = 12.sp
private val legendItemIconSize = 8.dp
private val legendItemIconPaddingValue = 10.dp
private val legendItemSpacing = 4.dp
private val legendTopPaddingValue = 8.dp
private val legendPadding = dimensionsOf(top = legendTopPaddingValue)
@Composable
fun rememberLegend() =
verticalLegend(
items =
chartColors.mapIndexed { _, chartColor ->
legendItem(
icon = rememberShapeComponent(Shapes.pillShape, chartColor),
label =
rememberTextComponent(
color = currentChartStyle.axis.axisLabelColor,
textSize = legendItemLabelTextSize,
typeface = Typeface.MONOSPACE,
),
labelText = "12월"
)
},
iconSize = legendItemIconSize,
iconPadding = legendItemIconPaddingValue,
spacing = legendItemSpacing,
padding = legendPadding,
)
코드를 보고 대부분 이해가 가능하겠지만 items에서 mapIndexed()를 사용한 이유에 대해 설명해보려 한다. verticalLegend() 컴포저블 함수의 items 매개변수는 Collection 타입이기 때문에 listOf() 로 리스트화 해서 적용했다.
Marker.kt
VicoViewModel.kt
References
- 버전이 약간 다를 수 있으니 참고할 것(Vico는 버전마다 차이가 크다.)
Vico라이브러리로 ComposedChart 만들기
Chart를 Compose로 구현