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 수준이 다른걸 알 수 있다.

snackbarHostState

아래는 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좌표를 기준으로 얼마큼의 가로 길이를 갖는지에 대한 설정이다.


snackbarHostState


snackbarHostState

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으로 나타낸다.


    snackbarHostState

    x축 좌표의 8번째 데이터 부터 차트가 흐릿해지는 볼 수 있는데 이걸 fade 효과라고 하고 scroll해서 다음 데이터를 확인할 수 있음을 알려준다.


  • rememberStartAxis()의 guideline 매개변수
    rememberStartAxis()의 guideline은 y축의 모든 좌표가 점선으로 연결되어 데이터를 보기 편하게 나타낸것이다. rememberBottomAxis()의 guideline까지 설정 하면 x축의 모든 좌표와 y축의 모든 좌표가 점섬으로 연결되어진다. 그림으로 보면 편할 것이다. rememberStartAxis()rememberBottomAxis()에는 디폴트 값으로 guideline = axisGuidelineComponent()이 설정되어 있어 따로 guideline을 설정하지 않아도 된다.
    snackbarHostState


  • rememberBottomAxis()의 itemPlacer 매개변수
    itemPlacer은 x축의 데이터간의 공간을 설정할 수 있다. 아래의 그림으로 이해해보자. 따로 itemPlacer를 설정하지 않아도 기본적으로 itemPlacer = AxisItemPlacer.Horizontal.default() 로 적용되어 있다.
    snackbarHostState


  • 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좌표의 최대값과 최소값을 반올림 여부 설정
      snackbarHostState


  • legend
    차트의 부가적인 설명을 도와주는 Text라고 생각하면된다. 아래의 사진을 보고 이해하자.
    legend


CartesianChartModelProducer

이름에서 알 수 있듯이 CartesianChartModelProducerCartesianChartHost() 컴포저블 함수의 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

Market.kt

VicoViewModel.kt

VicoViewModel.kt

References

Vico 공식 홈페이지
Vico Git