Untitled
unknown
kotlin
7 months ago
30 kB
8
Indexable
package ir.ehsannarmani.compose_charts
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.*
import ir.ehsannarmani.compose_charts.components.LabelHelper
import ir.ehsannarmani.compose_charts.extensions.drawGridLines
import ir.ehsannarmani.compose_charts.extensions.line_chart.PathData
import ir.ehsannarmani.compose_charts.extensions.line_chart.drawLineGradient
import ir.ehsannarmani.compose_charts.extensions.line_chart.getLinePath
import ir.ehsannarmani.compose_charts.extensions.line_chart.getPopupValue
import ir.ehsannarmani.compose_charts.extensions.spaceBetween
import ir.ehsannarmani.compose_charts.extensions.split
import ir.ehsannarmani.compose_charts.models.AnimationMode
import ir.ehsannarmani.compose_charts.models.DividerProperties
import ir.ehsannarmani.compose_charts.models.DotProperties
import ir.ehsannarmani.compose_charts.models.DrawStyle
import ir.ehsannarmani.compose_charts.models.GridProperties
import ir.ehsannarmani.compose_charts.models.HorizontalIndicatorProperties
import ir.ehsannarmani.compose_charts.models.IndicatorPosition
import ir.ehsannarmani.compose_charts.models.LabelHelperProperties
import ir.ehsannarmani.compose_charts.models.LabelProperties
import ir.ehsannarmani.compose_charts.models.Line
import ir.ehsannarmani.compose_charts.models.PopupProperties
import ir.ehsannarmani.compose_charts.models.ZeroLineProperties
import ir.ehsannarmani.compose_charts.utils.HorizontalLabels
import ir.ehsannarmani.compose_charts.utils.calculateOffset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
private data class Popup(
val properties: PopupProperties,
val position: Offset,
val value: Double
)
@Composable
fun LineChart(
modifier: Modifier = Modifier,
data: List<Line>,
curvedEdges: Boolean = true,
animationDelay: Long = 300,
animationMode: AnimationMode = AnimationMode.Together(),
dividerProperties: DividerProperties = DividerProperties(),
gridProperties: GridProperties = GridProperties(),
zeroLineProperties: ZeroLineProperties = ZeroLineProperties(),
indicatorProperties: HorizontalIndicatorProperties = HorizontalIndicatorProperties(
textStyle = TextStyle.Default,
padding = 16.dp
),
labelHelperProperties: LabelHelperProperties = LabelHelperProperties(),
labelHelperPadding: Dp = 26.dp,
textMeasurer: TextMeasurer = rememberTextMeasurer(),
popupProperties: PopupProperties = PopupProperties(
textStyle = TextStyle.Default.copy(
color = Color.White,
fontSize = 12.sp
)
),
dotsProperties: DotProperties = DotProperties(),
labelProperties: LabelProperties = LabelProperties(enabled = false),
maxValue: Double = 150.0,
minValue: Double = 0.0,
) {
if (data.isNotEmpty()) {
require(minValue <= (data.minOfOrNull { it.values.minOfOrNull { it } ?: 0.0 } ?: 0.0)) {
"Chart data must be at least $minValue (Specified Min Value)"
}
require(maxValue >= (data.maxOfOrNull { it.values.maxOfOrNull { it } ?: 0.0 } ?: 0.0)) {
"Chart data must be at most $maxValue (Specified Max Value)"
}
}
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val pathMeasure = remember {
PathMeasure()
}
val popupAnimation = remember {
Animatable(0f)
}
val zeroLineAnimation = remember {
Animatable(0f)
}
val chartWidth = remember {
mutableFloatStateOf(0f)
}
val dotAnimators = remember {
mutableStateListOf<List<Animatable<Float, AnimationVector1D>>>()
}
val popups = remember {
mutableStateListOf<Popup>()
}
val popupsOffsetAnimators = remember {
mutableStateListOf<Pair<Animatable<Float, AnimationVector1D>, Animatable<Float, AnimationVector1D>>>()
}
val linesPathData = remember {
mutableStateListOf<PathData>()
}
val indicators = remember(indicatorProperties.indicators,minValue,maxValue) {
indicatorProperties.indicators.ifEmpty {
split(
count = indicatorProperties.count,
minValue = minValue,
maxValue = maxValue
)
}
}
val indicatorAreaWidth = remember {
if (indicatorProperties.enabled) {
indicators.maxOf { textMeasurer.measure(indicatorProperties.contentBuilder(it)).size.width } + (indicatorProperties.padding.value * density.density)
} else {
0f
}
}
val xPadding = remember {
if (indicatorProperties.enabled && indicatorProperties.position == IndicatorPosition.Horizontal.Start) {
indicatorAreaWidth
} else {
0f
}
}
LaunchedEffect(Unit) {
if (zeroLineProperties.enabled) {
zeroLineAnimation.snapTo(0f)
zeroLineAnimation.animateTo(1f, animationSpec = zeroLineProperties.animationSpec)
}
}
// make animators
LaunchedEffect(data) {
dotAnimators.clear()
launch {
data.forEach {
val animators = mutableListOf<Animatable<Float, AnimationVector1D>>()
it.values.forEach {
animators.add(Animatable(0f))
}
dotAnimators.add(animators)
}
}
}
// animate
LaunchedEffect(data) {
delay(animationDelay)
val animateStroke: suspend (Line) -> Unit = { line ->
line.strokeProgress.animateTo(1f, animationSpec = line.strokeAnimationSpec)
}
val animateGradient: suspend (Line) -> Unit = { line ->
delay(line.gradientAnimationDelay)
line.gradientProgress.animateTo(1f, animationSpec = line.gradientAnimationSpec)
}
launch {
data.forEachIndexed { index, line ->
when (animationMode) {
is AnimationMode.OneByOne -> {
animateStroke(line)
}
is AnimationMode.Together -> {
launch {
delay(animationMode.delayBuilder(index))
animateStroke(line)
}
}
}
}
}
launch {
data.forEachIndexed { index, line ->
when (animationMode) {
is AnimationMode.OneByOne -> {
animateGradient(line)
}
is AnimationMode.Together -> {
launch {
delay(animationMode.delayBuilder(index))
animateGradient(line)
}
}
}
}
}
}
LaunchedEffect(data, minValue, maxValue) {
linesPathData.clear()
}
Column(modifier = modifier) {
if (labelHelperProperties.enabled) {
LabelHelper(
data = data.map { it.label to it.color },
textStyle = labelHelperProperties.textStyle
)
Spacer(modifier = Modifier.height(labelHelperPadding))
}
Row(modifier = Modifier.fillMaxSize().weight(1f)) {
if (indicatorProperties.enabled) {
if (indicatorProperties.position == IndicatorPosition.Horizontal.Start) {
Indicators(
indicatorProperties = indicatorProperties,
indicators = indicators
)
Spacer(modifier = Modifier.width(indicatorProperties.padding))
}
}
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Canvas(modifier = Modifier
.weight(1f)
.fillMaxSize()
.pointerInput(data, minValue, maxValue, linesPathData) {
if (!popupProperties.enabled) return@pointerInput
detectHorizontalDragGestures(
onDragEnd = {
scope.launch {
popupAnimation.animateTo(0f, animationSpec = tween(500))
popups.clear()
popupsOffsetAnimators.clear()
}
},
onHorizontalDrag = { change, amount ->
val _size = size.toSize()
.copy(height = (size.height).toFloat())
popups.clear()
data.forEachIndexed { index, line ->
val properties = line.popupProperties ?: popupProperties
val positionX =
(change.position.x).coerceIn(
0f,
size.width.toFloat()
)
val pathData = linesPathData[index]
if(positionX >= pathData.xPositions[pathData.startIndex] && positionX <= pathData.xPositions[pathData.endIndex]) {
val showOnPointsThreshold =
((properties.mode as? PopupProperties.Mode.PointMode)?.threshold
?: 0.dp).toPx()
val pointX =
pathData.xPositions.find { it in positionX - showOnPointsThreshold..positionX + showOnPointsThreshold }
if (properties.mode !is PopupProperties.Mode.PointMode || pointX != null) {
val fraction =
((if (properties.mode is PopupProperties.Mode.PointMode) (pointX?.toFloat()
?: 0f) else positionX) / size.width)
val popupValue = getPopupValue(
points = line.values,
fraction = fraction.toDouble(),
rounded = line.curvedEdges ?: curvedEdges,
size = _size,
minValue = minValue,
maxValue = maxValue
)
popups.add(
Popup(
position = popupValue.offset,
value = popupValue.calculatedValue,
properties = properties
)
)
if (popupsOffsetAnimators.count() < popups.count()) {
repeat(popups.count() - popupsOffsetAnimators.count()) {
popupsOffsetAnimators.add(
// add fixed position for popup when mode is point mode
if (properties.mode is PopupProperties.Mode.PointMode) {
Animatable(popupValue.offset.x) to Animatable(
popupValue.offset.y
)
} else {
Animatable(0f) to Animatable(0f)
}
)
}
}
}
}
}
scope.launch {
// animate popup (alpha)
if (popupAnimation.value != 1f && !popupAnimation.isRunning) {
popupAnimation.animateTo(1f, animationSpec = tween(500))
}
}
}
)
}
) {
val chartAreaHeight = size.height
chartWidth.value = size.width
val drawZeroLine = {
val zeroY = chartAreaHeight - calculateOffset(
minValue = minValue,
maxValue = maxValue,
total = chartAreaHeight,
value = 0f
).toFloat()
drawLine(
brush = zeroLineProperties.color,
start = Offset(x = 0f, y = zeroY),
end = Offset(x = size.width * zeroLineAnimation.value, y = zeroY),
pathEffect = zeroLineProperties.style.pathEffect,
strokeWidth = zeroLineProperties.thickness.toPx()
)
}
if (linesPathData.isEmpty() || linesPathData.count() != data.count()) {
data.map {
val startIndex = if(it.viewRange.startIndex < 0 || it.viewRange.startIndex >= it.values.size - 1) 0 else it.viewRange.startIndex
val endIndex = if(it.viewRange.endIndex < 0 || it.viewRange.endIndex <= it.viewRange.startIndex
|| it.viewRange.endIndex > it.values.size - 1) it.values.size - 1 else it.viewRange.endIndex
getLinePath(
dataPoints = it.values.map { it.toFloat() },
maxValue = maxValue.toFloat(),
minValue = minValue.toFloat(),
rounded = it.curvedEdges ?: curvedEdges,
size = size.copy(height = chartAreaHeight),
startIndex,
endIndex
)
}.also {
linesPathData.addAll(it)
}
}
drawGridLines(
dividersProperties = dividerProperties,
indicatorPosition = indicatorProperties.position,
xAxisProperties = gridProperties.xAxisProperties,
yAxisProperties = gridProperties.yAxisProperties,
size = size.copy(height = chartAreaHeight),
gridEnabled = gridProperties.enabled
)
if (zeroLineProperties.enabled && zeroLineProperties.zType == ZeroLineProperties.ZType.Under) {
drawZeroLine()
}
data.forEachIndexed { index, line ->
val pathData = linesPathData.getOrNull(index) ?: return@Canvas
val segmentedPath = Path()
pathMeasure.setPath(pathData.path, false)
pathMeasure.getSegment(
0f,
pathMeasure.length * line.strokeProgress.value,
segmentedPath
)
var pathEffect: PathEffect? = null
val stroke: Float = when (val drawStyle = line.drawStyle) {
is DrawStyle.Fill -> {
0f
}
is DrawStyle.Stroke -> {
pathEffect = drawStyle.strokeStyle.pathEffect
drawStyle.width.toPx()
}
}
drawPath(
path = segmentedPath,
brush = line.color,
style = Stroke(width = stroke, pathEffect = pathEffect)
)
var startOffset = 0f
var endOffset = size.width
if(pathData.startIndex > 0) {
startOffset = pathData.xPositions[pathData.startIndex] .toFloat()
}
if(pathData.endIndex < line.values.size - 1) {
endOffset = pathData.xPositions[pathData.endIndex].toFloat()
}
if (line.firstGradientFillColor != null && line.secondGradientFillColor != null) {
drawLineGradient(
path = pathData.path,
color1 = line.firstGradientFillColor,
color2 = line.secondGradientFillColor,
progress = line.gradientProgress.value,
size = size.copy(height = chartAreaHeight),
startOffset,
endOffset
)
} else if (line.drawStyle is DrawStyle.Fill) {
var fillColor = Color.Unspecified
if (line.color is SolidColor) {
fillColor = line.color.value
}
drawLineGradient(
path = pathData.path,
color1 = fillColor,
color2 = fillColor,
progress = 1f,
size = size.copy(height = chartAreaHeight),
startOffset,
endOffset
)
}
if ((line.dotProperties?.enabled ?: dotsProperties.enabled)) {
drawDots(
dataPoints = line.values.mapIndexed { mapIndex, value ->
(dotAnimators.getOrNull(
index
)?.getOrNull(mapIndex) ?: Animatable(0f)) to value.toFloat()
},
properties = line.dotProperties ?: dotsProperties,
linePath = segmentedPath,
maxValue = maxValue.toFloat(),
minValue = minValue.toFloat(),
pathMeasure = pathMeasure,
scope = scope,
size = size.copy(height = chartAreaHeight),
startIndex = pathData.startIndex,
endIndex = pathData.endIndex
)
}
}
if (zeroLineProperties.enabled && zeroLineProperties.zType == ZeroLineProperties.ZType.Above) {
drawZeroLine()
}
popups.forEachIndexed { index, popup ->
drawPopup(
popup = popup,
nextPopup = popups.getOrNull(index + 1),
textMeasurer = textMeasurer,
scope = scope,
progress = popupAnimation.value,
offsetAnimator = popupsOffsetAnimators.getOrNull(index)
)
}
}
}
if (indicatorProperties.enabled) {
if (indicatorProperties.position == IndicatorPosition.Horizontal.End) {
Spacer(modifier = Modifier.width(indicatorProperties.padding))
Indicators(
indicatorProperties = indicatorProperties,
indicators = indicators
)
}
}
}
HorizontalLabels(
labelProperties = labelProperties,
labels = labelProperties.labels,
indicatorProperties = indicatorProperties,
chartWidth = chartWidth.value,
density = density,
textMeasurer = textMeasurer,
xPadding = xPadding
)
}
}
@Composable
private fun Indicators(
modifier: Modifier = Modifier,
indicators:List<Double>,
indicatorProperties: HorizontalIndicatorProperties,
) {
Column(
modifier = modifier
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
indicators.forEach {
BasicText(
text = indicatorProperties.contentBuilder(it),
style = indicatorProperties.textStyle
)
}
}
}
private fun DrawScope.drawPopup(
popup: Popup,
nextPopup: Popup?,
textMeasurer: TextMeasurer,
scope: CoroutineScope,
progress: Float,
offsetAnimator: Pair<Animatable<Float, AnimationVector1D>, Animatable<Float, AnimationVector1D>>? = null
) {
val offset = popup.position
val popupProperties = popup.properties
val measureResult = textMeasurer.measure(
popupProperties.contentBuilder(popup.value),
style = popupProperties.textStyle.copy(
color = popupProperties.textStyle.color.copy(
alpha = 1f * progress
)
)
)
var rectSize = measureResult.size.toSize()
rectSize = rectSize.copy(
width = (rectSize.width + (popupProperties.contentHorizontalPadding.toPx() * 2)),
height = (rectSize.height + (popupProperties.contentVerticalPadding.toPx() * 2))
)
val conflictDetected =
((nextPopup != null) && offset.y in nextPopup.position.y - rectSize.height..nextPopup.position.y + rectSize.height) ||
(offset.x + rectSize.width) > size.width
val rectOffset = if (conflictDetected) {
offset.copy(x = offset.x - rectSize.width)
} else {
offset
}
offsetAnimator?.also { (x, y) ->
if (x.value == 0f || y.value == 0f || popupProperties.mode is PopupProperties.Mode.PointMode) {
scope.launch {
x.snapTo(rectOffset.x)
y.snapTo(rectOffset.y)
}
} else {
scope.launch {
x.animateTo(rectOffset.x)
}
scope.launch {
y.animateTo(rectOffset.y)
}
}
}
if (offsetAnimator != null) {
val animatedOffset = if (popup.properties.mode is PopupProperties.Mode.PointMode) {
rectOffset
} else {
Offset(
x = offsetAnimator.first.value,
y = offsetAnimator.second.value
)
}
val rect = Rect(
offset = animatedOffset,
size = rectSize
)
drawPath(
path = Path().apply {
addRoundRect(
RoundRect(
rect = rect.copy(
top = rect.top,
left = rect.left,
),
topLeft = CornerRadius(
if (conflictDetected) popupProperties.cornerRadius.toPx() else 0f,
if (conflictDetected) popupProperties.cornerRadius.toPx() else 0f
),
topRight = CornerRadius(
if (!conflictDetected) popupProperties.cornerRadius.toPx() else 0f,
if (!conflictDetected) popupProperties.cornerRadius.toPx() else 0f
),
bottomRight = CornerRadius(
popupProperties.cornerRadius.toPx(),
popupProperties.cornerRadius.toPx()
),
bottomLeft = CornerRadius(
popupProperties.cornerRadius.toPx(),
popupProperties.cornerRadius.toPx()
),
)
)
},
color = popupProperties.containerColor,
alpha = 1f * progress
)
drawText(
textLayoutResult = measureResult,
topLeft = animatedOffset.copy(
x = animatedOffset.x + popupProperties.contentHorizontalPadding.toPx(),
y = animatedOffset.y + popupProperties.contentVerticalPadding.toPx()
)
)
}
}
fun DrawScope.drawDots(
dataPoints: List<Pair<Animatable<Float, AnimationVector1D>, Float>>,
properties: DotProperties,
linePath: Path,
maxValue: Float,
minValue: Float,
pathMeasure: PathMeasure,
scope: CoroutineScope,
size: Size? = null,
startIndex: Int,
endIndex: Int,
) {
val _size = size ?: this.size
val pathEffect = properties.strokeStyle.pathEffect
pathMeasure.setPath(linePath, false)
val lastPosition = pathMeasure.getPosition(pathMeasure.length)
dataPoints.forEachIndexed { valueIndex, value ->
if(valueIndex in startIndex..endIndex) {
val dotOffset = Offset(
x = _size.width.spaceBetween(
itemCount = dataPoints.count(),
index = valueIndex
),
y = (_size.height - calculateOffset(
maxValue = maxValue.toDouble(),
minValue = minValue.toDouble(),
total = _size.height,
value = value.second
)).toFloat()
)
if (lastPosition != Offset.Unspecified && lastPosition.x >= dotOffset.x - 20 || !properties.animationEnabled) {
if (!value.first.isRunning && properties.animationEnabled && value.first.value != 1f) {
scope.launch {
value.first.animateTo(1f, animationSpec = properties.animationSpec)
}
}
val radius: Float
val strokeRadius: Float
if (properties.animationEnabled) {
radius =
(properties.radius.toPx() + properties.strokeWidth.toPx() / 2) * value.first.value
strokeRadius = properties.radius.toPx() * value.first.value
} else {
radius = properties.radius.toPx() + properties.strokeWidth.toPx() / 2
strokeRadius = properties.radius.toPx()
}
drawCircle(
brush = properties.strokeColor,
radius = radius,
center = dotOffset,
style = Stroke(width = properties.strokeWidth.toPx(), pathEffect = pathEffect),
)
drawCircle(
brush = properties.color,
radius = strokeRadius,
center = dotOffset,
)
}
}
}
}
Editor is loading...
Leave a Comment