Updated on 2026-08-14

This commit is contained in:
Tangem 2024-06-04 20:57:05 +03:00
parent 9c5490bced
commit a2792223a3
9 changed files with 534 additions and 0 deletions

1
common/ui-charts/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,24 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.common.ui.charts"
}
dependencies {
/** Project - Core */
implementation(projects.core.ui)
/** Compose */
implementation(deps.tangem.vico.core)
implementation(deps.tangem.vico.compose)
implementation(deps.tangem.vico.compose.m3)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
}

View file

@ -0,0 +1,8 @@
package com.tangem.common.ui.charts
import androidx.compose.runtime.Composable
@Composable
fun MarketChart() {
TODO()
}

View file

@ -0,0 +1,269 @@
package com.tangem.common.ui.charts.layer
import android.content.res.Configuration
import androidx.annotation.FloatRange
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.fullWidth
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLineSpec
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.common.shader.BrushShader
import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout
import com.patrykandpatrick.vico.core.cartesian.Zoom
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import com.patrykandpatrick.vico.core.common.shader.ColorShader
import com.patrykandpatrick.vico.core.common.shader.DynamicShader
import com.tangem.common.ui.charts.preview.getPreviewBigCartData
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* Creates and remembers a LineCartesianLayer for a chart with specific characteristics.
*
* @param lineColor The color of the main line in the chart.
* @param backgroundLineColor The color of the line's background.
* @param secondLineColor The color of the line for the second part of the chart.
* @param backgroundSecondLineColor The color of the line's background for the second part of the chart.
* @param startDrawingAnimation A mutable state that triggers the start of the drawing animation when set to true.
* @param axisValueOverrider An AxisValueOverrider that provides custom values for the axis.
* @param secondColorOnTheRightSide A boolean that determines if the second color should be on the right side of the chart. Default is false.
* @param markerFraction A float between 0.0 and 1.0 that represents the fraction of the chart where the marker is located. Default is null.
*
* @return A LineCartesianLayer that represents a layer in a chart with the specified characteristics.
*/
@Suppress("LongParameterList")
@Composable
fun rememberMarketChartLayer(
lineColor: Color,
backgroundLineColor: Color,
secondLineColor: Color,
backgroundSecondLineColor: Color,
startDrawingAnimation: MutableState<Boolean>,
axisValueOverrider: AxisValueOverrider,
secondColorOnTheRightSide: Boolean = false,
@FloatRange(from = 0.0, to = 1.0) markerFraction: Float? = null,
): LineCartesianLayer {
var animationFraction: Float? by remember { mutableStateOf(null) }
LaunchedEffect(startDrawingAnimation.value) {
if (startDrawingAnimation.value) {
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(easing = LinearEasing, durationMillis = 1000),
) { start, _ ->
if (start == 1f) {
animationFraction = null
startDrawingAnimation.value = false
} else {
animationFraction = start
}
}
}
}
return rememberRawMarketChartLayer(
lineColor = lineColor,
backgroundLineColor = backgroundLineColor,
secondLineColor = secondLineColor,
backgroundSecondLineColor = backgroundSecondLineColor,
axisValueOverrider = axisValueOverrider,
secondColorOnTheRightSide = secondColorOnTheRightSide,
markerFraction = markerFraction,
animationFraction = animationFraction,
)
}
@Composable
private fun rememberRawMarketChartLayer(
lineColor: Color,
backgroundLineColor: Color,
secondLineColor: Color,
backgroundSecondLineColor: Color,
axisValueOverrider: AxisValueOverrider,
secondColorOnTheRightSide: Boolean = false,
@FloatRange(from = 0.0, to = 1.0) markerFraction: Float? = null,
@FloatRange(from = 0.0, to = 1.0) animationFraction: Float? = null,
): LineCartesianLayer {
val backgroundColorLineGradient = listOf(backgroundLineColor, Color.Transparent)
val backgroundSecondLineColorGradient = listOf(backgroundSecondLineColor, Color.Transparent)
val markerSet = markerFraction != null
val animationRunning = animationFraction != null && animationFraction != 1f
val layerColors = remember(animationRunning, markerSet, secondColorOnTheRightSide) {
when {
!animationRunning && markerSet && secondColorOnTheRightSide -> {
LayerColors(
lineColor = lineColor,
backLineColor = backgroundColorLineGradient,
lineColorRight = secondLineColor,
backLineColorRight = backgroundSecondLineColorGradient,
)
}
!animationRunning && markerSet && !secondColorOnTheRightSide -> {
LayerColors(
lineColor = secondLineColor,
backLineColor = backgroundSecondLineColorGradient,
lineColorRight = lineColor,
backLineColorRight = backgroundColorLineGradient,
)
}
animationRunning -> {
LayerColors(
lineColor = lineColor,
backLineColor = backgroundColorLineGradient,
lineColorRight = Color.Transparent,
backLineColorRight = listOf(Color.Transparent, Color.Transparent),
)
}
else -> {
LayerColors(
lineColor = lineColor,
backLineColor = backgroundColorLineGradient,
)
}
}
}
return rememberLayer(
fractionValue = animationFraction ?: markerFraction,
axisValueOverrider = axisValueOverrider,
layerColors = layerColors,
)
}
private data class LayerColors(
val lineColor: Color,
val backLineColor: List<Color>,
val lineColorRight: Color? = null,
val backLineColorRight: List<Color>? = null,
)
@Composable
private fun rememberLayer(
fractionValue: Float?,
axisValueOverrider: AxisValueOverrider,
layerColors: LayerColors,
): LineCartesianLayer {
return rememberLineCartesianLayer(
listOf(
if (layerColors.lineColorRight == null || layerColors.backLineColorRight == null || fractionValue == null) {
rememberLineSpec(
shader = remember(layerColors.lineColor) { ColorShader(color = layerColors.lineColor.toArgb()) },
backgroundShader = remember(layerColors.backLineColor) {
BrushShader(brush = Brush.verticalGradient(layerColors.backLineColor))
},
)
} else {
rememberSplitLineSpec(
shader = remember(layerColors.lineColor, layerColors.lineColorRight, fractionValue) {
DynamicShader.Companion.horizontalGradient(
colors = intArrayOf(layerColors.lineColor.toArgb(), layerColors.lineColorRight.toArgb()),
positions = floatArrayOf(fractionValue, fractionValue),
)
},
backgroundShaderFirst = remember(layerColors.backLineColor) {
BrushShader(brush = Brush.verticalGradient(layerColors.backLineColor))
},
backgroundShaderSecond = remember(layerColors.backLineColorRight) {
BrushShader(brush = Brush.verticalGradient(layerColors.backLineColorRight))
},
xSplitFraction = fractionValue,
)
},
),
axisValueOverrider = axisValueOverrider,
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun LayerChartPreview() {
val xListKey = ExtraStore.Key<List<Long>>()
val model = getPreviewBigCartData(xListKey)
TangemThemePreview {
Column(
modifier = Modifier.background(TangemTheme.colors.background.primary),
verticalArrangement = Arrangement.spacedBy(48.dp),
) {
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberRawMarketChartLayer(
lineColor = Color.Blue,
backgroundLineColor = Color.Blue.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f),
secondColorOnTheRightSide = true,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
model = model,
)
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberRawMarketChartLayer(
lineColor = Color.Blue,
backgroundLineColor = Color.Blue.copy(alpha = 0.24f),
secondLineColor = Color.Gray,
backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f),
markerFraction = 0.35f,
secondColorOnTheRightSide = true,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
model = model,
)
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberRawMarketChartLayer(
lineColor = Color.Blue,
backgroundLineColor = Color.Blue.copy(alpha = 0.24f),
secondLineColor = Color.Blue,
backgroundSecondLineColor = Color.Blue.copy(alpha = 0.24f),
markerFraction = 0.35f,
secondColorOnTheRightSide = true,
animationFraction = 0.7f,
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
),
),
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
model = model,
)
}
}
}
// endregion Preview

View file

@ -0,0 +1,135 @@
package com.tangem.common.ui.charts.marker
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.fullWidth
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import com.patrykandpatrick.vico.compose.common.component.rememberLayeredComponent
import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent
import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent
import com.patrykandpatrick.vico.compose.common.of
import com.patrykandpatrick.vico.compose.common.shader.color
import com.patrykandpatrick.vico.compose.common.shape.dashed
import com.patrykandpatrick.vico.core.cartesian.*
import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider
import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.core.common.Dimensions
import com.patrykandpatrick.vico.core.common.component.TextComponent
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import com.patrykandpatrick.vico.core.common.shader.DynamicShader
import com.patrykandpatrick.vico.core.common.shape.Shape
import com.tangem.common.ui.charts.preview.getPreviewBigCartData
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* @param color The color of the indicator and guideline.
* @param innerCircleColor The color of the inner circle of the indicator.
*
* @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect.
*/
@Composable
internal fun rememberTangemChartMarker(color: Color, innerCircleColor: Color): CartesianMarker {
val indicatorFrontComponent = rememberShapeComponent(
shape = Shape.Pill,
color = innerCircleColor,
)
val indicatorCenterComponent = rememberShapeComponent(
shape = Shape.Pill,
color = color,
)
val indicatorRearComponent = rememberShapeComponent(
shape = Shape.Pill,
color = color.copy(alpha = INDICATOR_REAR_COLOR_ALPHA),
)
val indicator = rememberLayeredComponent(
rear = indicatorRearComponent,
front = rememberLayeredComponent(
rear = indicatorCenterComponent,
front = indicatorFrontComponent,
padding = indicatorPadding,
),
padding = indicatorPadding,
)
val guideline = rememberUnboundedLineComponent(
color = color,
verticalAddDrawSpace = TangemTheme.dimens.spacing24,
shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) },
)
return remember(indicator, guideline) {
object : DefaultCartesianMarker(
label = TextComponent.build { textSizeSp = 0f },
indicator = indicator,
indicatorSizeDp = INDICATOR_SIZE_DP,
guideline = guideline,
) {
override fun getInsets(
context: CartesianMeasureContext,
outInsets: Insets,
horizontalDimensions: HorizontalDimensions,
) {
with(context) {
super.getInsets(context, outInsets, horizontalDimensions)
val baseShadowInsetDp =
CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP
outInsets.top += (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels
outInsets.bottom += (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels
}
}
}
}
}
private val indicatorPadding = Dimensions.of(3.dp)
private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f
private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f
private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f
private const val INDICATOR_SIZE_DP = 16f
private const val INDICATOR_REAR_COLOR_ALPHA = .24f
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemChartMarkerPreview() {
val marker = rememberTangemChartMarker(Color.Red, Color.White)
val xListKey = ExtraStore.Key<List<Long>>()
val model = getPreviewBigCartData(xListKey)
val centerAprx = (model.models[0].minX + model.models[0].maxX) / 2f
val center = model.models[0].getXDeltaGcd().let { centerAprx - centerAprx % it }
TangemThemePreview {
Box(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
CartesianChartHost(
modifier = Modifier.fillMaxWidth(),
chart = rememberCartesianChart(
rememberLineCartesianLayer(
listOf(rememberLineSpec(shader = DynamicShader.color(Color.Blue))),
axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY),
),
persistentMarkers = mapOf(center to marker),
),
model = model,
marker = marker,
zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false),
horizontalLayout = HorizontalLayout.fullWidth(),
)
}
}
}
// endregion Preview

View file

@ -0,0 +1,77 @@
@file:Suppress("MagicNumber", "LongMethod")
package com.tangem.common.ui.charts.preview
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel
import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel
import com.patrykandpatrick.vico.core.common.data.ExtraStore
import com.patrykandpatrick.vico.core.common.data.MutableExtraStore
internal fun getPreviewBigCartData(xListKey: ExtraStore.Key<List<Long>>): CartesianChartModel {
val y = listOf(
59270, 61748, 61941, 62889, 62740, 62989, 63858, 63608, 63951, 63917,
63253, 63765, 63862, 64502, 63876, 64028, 63875, 64531, 64249, 63680,
63228, 63159, 63249, 63664, 63469, 63711, 63003, 62333, 62882, 62183,
62231, 62244, 62200, 61188, 61683, 61199, 61036, 62116, 62436, 63065,
62908, 63067, 63294, 60904, 60677, 60790, 60705, 61041, 60667, 61158,
61112, 60795, 60900, 60771, 61124, 61343, 61371, 61484, 61133, 62372,
62704, 63007, 63092, 62905, 62470, 62019, 61756, 61776, 61557, 61540,
61904, 62151, 62419, 64663, 65955, 66229, 65991, 66176, 66517, 65838,
65210, 65216, 65611, 66467, 66209, 67247, 66913, 67059, 66978, 66845,
67240, 66874, 66993, 66940, 67185, 67330, 67340, 66845, 66062, 66273,
66645, 66865, 67005, 67382, 70049, 71464, 71293, 70875, 71137, 69720,
69323, 70139, 69964, 69716, 69855, 70442, 69774, 69125, 69404, 69714,
69967, 68042, 67077, 67938, 67833, 67182, 67306, 68327, 69093, 68517,
68726, 68759, 69061, 68880, 69148, 69305, 69044, 69313, 69122, 68821,
68854, 68506, 68823, 68613, 68420, 70356, 69241, 69401, 68004, 67630,
68311, 68311, 68291, 68302, 68717, 67926, 67690, 67314, 67285, 67567,
68026, 67552, 67740, 68519, 68611, 68363, 68503, 68171, 68326, 67121,
67626, 67481, 67657, 67567, 67608, 67607, 67683, 67729, 67733, 67709,
)
val timestamps: List<Long> = listOf(
1714752000000, 1714766400000, 1714780800000, 1714795200000, 1714809600000,
1714824000000, 1714838400000, 1714852800000, 1714867200000, 1714881600000,
1714896000000, 1714910400000, 1714924800000, 1714939200000, 1714953600000,
1714968000000, 1714982400000, 1714996800000, 1715011200000, 1715025600000,
1715040000000, 1715054400000, 1715068800000, 1715083200000, 1715097600000,
1715112000000, 1715126400000, 1715140800000, 1715155200000, 1715169600000,
1715184000000, 1715198400000, 1715212800000, 1715227200000, 1715241600000,
1715256000000, 1715270400000, 1715284800000, 1715299200000, 1715313600000,
1715328000000, 1715342400000, 1715356800000, 1715371200000, 1715385600000,
1715400000000, 1715414400000, 1715428800000, 1715443200000, 1715457600000,
1715472000000, 1715486400000, 1715500800000, 1715515200000, 1715529600000,
1715544000000, 1715558400000, 1715572800000, 1715587200000, 1715601600000,
1715616000000, 1715630400000, 1715644800000, 1715659200000, 1715673600000,
1715688000000, 1715702400000, 1715716800000, 1715731200000, 1715745600000,
1715760000000, 1715774400000, 1715788800000, 1715803200000, 1715817600000,
1715832000000, 1715846400000, 1715860800000, 1715875200000, 1715889600000,
1715904000000, 1715918400000, 1715932800000, 1715947200000, 1715961600000,
1715976000000, 1715990400000, 1716004800000, 1716019200000, 1716033600000,
1716048000000, 1716062400000, 1716076800000, 1716091200000, 1716105600000,
1716120000000, 1716134400000, 1716148800000, 1716163200000, 1716177600000,
1716192000000, 1716206400000, 1716220800000, 1716235200000, 1716249600000,
1716264000000, 1716278400000, 1716292800000, 1716307200000, 1716321600000,
1716336000000, 1716350400000, 1716364800000, 1716379200000, 1716393600000,
1716408000000, 1716422400000, 1716436800000, 1716451200000, 1716465600000,
1716480000000, 1716494400000, 1716508800000, 1716523200000, 1716537600000,
1716552000000, 1716566400000, 1716580800000, 1716595200000, 1716609600000,
1716624000000, 1716638400000, 1716652800000, 1716667200000, 1716681600000,
1716696000000, 1716710400000, 1716724800000, 1716739200000, 1716753600000,
1716768000000, 1716782400000, 1716796800000, 1716811200000, 1716825600000,
1716840000000, 1716854400000, 1716868800000, 1716883200000, 1716897600000,
1716912000000, 1716926400000, 1716940800000, 1716955200000, 1716969600000,
1716984000000, 1716998400000, 1717012800000, 1717027200000, 1717041600000,
1717056000000, 1717070400000, 1717084800000, 1717099200000, 1717113600000,
1717128000000, 1717142400000, 1717156800000, 1717171200000, 1717185600000,
1717200000000, 1717214400000, 1717228800000, 1717243200000, 1717257600000,
1717272000000, 1717286400000, 1717300800000, 1717315200000, 1717329600000,
)
val x = List(y.size) { index -> index }
return CartesianChartModel(LineCartesianLayerModel.build { series(x, y) }).copy(
extraStore = MutableExtraStore().apply {
set(xListKey, timestamps)
},
)
}