Skip to content

Commit 4729069

Browse files
authored
Merge pull request #123 from lcazalbasu/feature/show-popup-when-tap-the-line-chart
Feature/Show popup when tap the line chart
2 parents f944a17 + 2aedf8d commit 4729069

File tree

3 files changed

+164
-101
lines changed

3 files changed

+164
-101
lines changed

compose-charts/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ mavenPublishing{
1515
coordinates(
1616
groupId = "io.github.ehsannarmani",
1717
artifactId = "compose-charts",
18-
version = "0.1.7"
18+
version = "0.1.8"
1919
)
2020
pom{
2121
name.set("Compose Charts")

compose-charts/src/commonMain/kotlin/ir/ehsannarmani/compose_charts/LineChart.kt

Lines changed: 161 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.compose.animation.core.AnimationVector1D
55
import androidx.compose.animation.core.tween
66
import androidx.compose.foundation.Canvas
77
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
8+
import androidx.compose.foundation.gestures.detectTapGestures
89
import androidx.compose.foundation.layout.Arrangement
910
import androidx.compose.foundation.layout.Column
1011
import androidx.compose.foundation.layout.Row
@@ -34,6 +35,7 @@ import androidx.compose.ui.graphics.PathMeasure
3435
import androidx.compose.ui.graphics.SolidColor
3536
import androidx.compose.ui.graphics.drawscope.DrawScope
3637
import androidx.compose.ui.graphics.drawscope.Stroke
38+
import androidx.compose.ui.input.pointer.PointerInputScope
3739
import androidx.compose.ui.input.pointer.pointerInput
3840
import androidx.compose.ui.platform.LocalDensity
3941
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -42,6 +44,7 @@ import androidx.compose.ui.text.TextStyle
4244
import androidx.compose.ui.text.drawText
4345
import androidx.compose.ui.text.rememberTextMeasurer
4446
import androidx.compose.ui.unit.Dp
47+
import androidx.compose.ui.unit.IntSize
4548
import androidx.compose.ui.unit.LayoutDirection
4649
import androidx.compose.ui.unit.dp
4750
import androidx.compose.ui.unit.sp
@@ -69,6 +72,7 @@ import ir.ehsannarmani.compose_charts.models.ZeroLineProperties
6972
import ir.ehsannarmani.compose_charts.utils.HorizontalLabels
7073
import ir.ehsannarmani.compose_charts.utils.calculateOffset
7174
import kotlinx.coroutines.CoroutineScope
75+
import kotlinx.coroutines.Job
7276
import kotlinx.coroutines.delay
7377
import kotlinx.coroutines.launch
7478

@@ -149,7 +153,7 @@ fun LineChart(
149153
val linesPathData = remember {
150154
mutableStateListOf<PathData>()
151155
}
152-
val indicators = remember(indicatorProperties.indicators,minValue,maxValue) {
156+
val indicators = remember(indicatorProperties.indicators, minValue, maxValue) {
153157
indicatorProperties.indicators.ifEmpty {
154158
split(
155159
count = indicatorProperties.count,
@@ -186,7 +190,7 @@ fun LineChart(
186190
launch {
187191
data.forEach {
188192
val animators = mutableListOf<Animatable<Float, AnimationVector1D>>()
189-
it.values.forEach {
193+
repeat(it.values.size) {
190194
animators.add(Animatable(0f))
191195
}
192196
dotAnimators.add(animators)
@@ -242,6 +246,101 @@ fun LineChart(
242246
linesPathData.clear()
243247
}
244248

249+
var tapJob: Job? = null
250+
var isDisplayingPopup = false
251+
suspend fun hidePopup(
252+
withAnimation: Boolean = true
253+
) {
254+
val duration = if (withAnimation) {
255+
500
256+
} else {
257+
0
258+
}
259+
260+
popupAnimation.animateTo(0f, animationSpec = tween(duration))
261+
popups.clear()
262+
popupsOffsetAnimators.clear()
263+
isDisplayingPopup = false
264+
}
265+
266+
fun PointerInputScope.showPopup(
267+
data: List<Line>,
268+
size: IntSize,
269+
position: Offset
270+
) {
271+
isDisplayingPopup = true
272+
popups.clear()
273+
274+
data.forEachIndexed { dataIndex, line ->
275+
val properties = line.popupProperties ?: popupProperties
276+
if (!properties.enabled) return@forEachIndexed
277+
278+
val positionX = position.x.coerceIn(0f, size.width.toFloat())
279+
val pathData = linesPathData[dataIndex]
280+
281+
if (positionX >= pathData.xPositions[pathData.startIndex] &&
282+
positionX <= pathData.xPositions[pathData.endIndex]
283+
) {
284+
val showOnPointsThreshold =
285+
((properties.mode as? PopupProperties.Mode.PointMode)?.threshold
286+
?: 0.dp).toPx()
287+
val pointX = pathData.xPositions.find {
288+
it in positionX - showOnPointsThreshold..positionX + showOnPointsThreshold
289+
}
290+
291+
if (properties.mode !is PopupProperties.Mode.PointMode || pointX != null) {
292+
val relevantX =
293+
if (properties.mode is PopupProperties.Mode.PointMode) (pointX?.toFloat()
294+
?: 0f) else positionX
295+
val fraction = (relevantX / size.width)
296+
297+
val valueIndex = calculateValueIndex(
298+
fraction = fraction.toDouble(),
299+
values = line.values,
300+
pathData = pathData
301+
)
302+
303+
val popupValue = getPopupValue(
304+
points = line.values,
305+
fraction = fraction.toDouble(),
306+
rounded = line.curvedEdges ?: curvedEdges,
307+
size = size.toSize(),
308+
minValue = minValue,
309+
maxValue = maxValue
310+
)
311+
312+
popups.add(
313+
Popup(
314+
position = popupValue.offset,
315+
value = popupValue.calculatedValue,
316+
properties = properties,
317+
dataIndex = dataIndex,
318+
valueIndex = valueIndex
319+
)
320+
)
321+
322+
if (popupsOffsetAnimators.count() < popups.count()) {
323+
repeat(popups.count() - popupsOffsetAnimators.count()) {
324+
popupsOffsetAnimators.add(
325+
if (properties.mode is PopupProperties.Mode.PointMode) {
326+
Animatable(popupValue.offset.x) to Animatable(popupValue.offset.y)
327+
} else {
328+
Animatable(0f) to Animatable(0f)
329+
}
330+
)
331+
}
332+
}
333+
}
334+
}
335+
}
336+
337+
scope.launch {
338+
if (popupAnimation.value != 1f && !popupAnimation.isRunning) {
339+
popupAnimation.animateTo(1f, animationSpec = tween(500))
340+
}
341+
}
342+
}
343+
245344
Column(modifier = modifier) {
246345
if (labelHelperProperties.enabled) {
247346
LabelHelper(
@@ -262,97 +361,59 @@ fun LineChart(
262361
}
263362
}
264363
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
265-
Canvas(modifier = Modifier
266-
.weight(1f)
267-
.fillMaxSize()
268-
.pointerInput(data, minValue, maxValue, linesPathData) {
269-
if (!popupProperties.enabled || data.all { it.popupProperties?.enabled == false }) return@pointerInput
270-
detectHorizontalDragGestures(
271-
onDragEnd = {
272-
scope.launch {
273-
popupAnimation.animateTo(0f, animationSpec = tween(500))
274-
popups.clear()
275-
popupsOffsetAnimators.clear()
364+
Canvas(
365+
modifier = Modifier
366+
.weight(1f)
367+
.fillMaxSize()
368+
.pointerInput(data, minValue, maxValue, linesPathData) {
369+
if (!popupProperties.enabled || data.all { it.popupProperties?.enabled == false })
370+
return@pointerInput
371+
372+
detectHorizontalDragGestures(
373+
onDragEnd = {
374+
scope.launch {
375+
hidePopup()
376+
}
377+
},
378+
onHorizontalDrag = { change, amount ->
379+
showPopup(
380+
data = data,
381+
size = size,
382+
position = change.position
383+
)
276384
}
277-
},
278-
onHorizontalDrag = { change, amount ->
279-
val _size = size.toSize()
280-
.copy(height = (size.height).toFloat())
281-
popups.clear()
282-
data.forEachIndexed { dataIndex, line ->
283-
val properties = line.popupProperties ?: popupProperties
284-
if(properties.enabled){
285-
val positionX =
286-
(change.position.x).coerceIn(
287-
0f,
288-
size.width.toFloat()
289-
)
290-
val pathData = linesPathData[dataIndex]
291-
292-
if (positionX >= pathData.xPositions[pathData.startIndex] && positionX <= pathData.xPositions[pathData.endIndex]) {
293-
val showOnPointsThreshold =
294-
((properties.mode as? PopupProperties.Mode.PointMode)?.threshold
295-
?: 0.dp).toPx()
296-
val pointX =
297-
pathData.xPositions.find { it in positionX - showOnPointsThreshold..positionX + showOnPointsThreshold }
298-
299-
if (properties.mode !is PopupProperties.Mode.PointMode || pointX != null) {
300-
val fraction =
301-
((if (properties.mode is PopupProperties.Mode.PointMode) (pointX?.toFloat()
302-
?: 0f) else positionX) / size.width)
303-
304-
//Calculate the data index
305-
val valueIndex = calculateValueIndex(
306-
fraction = fraction.toDouble(),
307-
values = line.values,
308-
pathData = pathData
309-
)
310-
311-
val popupValue = getPopupValue(
312-
points = line.values,
313-
fraction = fraction.toDouble(),
314-
rounded = line.curvedEdges ?: curvedEdges,
315-
size = _size,
316-
minValue = minValue,
317-
maxValue = maxValue
318-
)
319-
popups.add(
320-
Popup(
321-
position = popupValue.offset,
322-
value = popupValue.calculatedValue,
323-
properties = properties,
324-
dataIndex = dataIndex,
325-
valueIndex = valueIndex
326-
)
327-
)
328-
if (popupsOffsetAnimators.count() < popups.count()) {
329-
repeat(popups.count() - popupsOffsetAnimators.count()) {
330-
popupsOffsetAnimators.add(
331-
// add fixed position for popup when mode is point mode
332-
if (properties.mode is PopupProperties.Mode.PointMode) {
333-
Animatable(popupValue.offset.x) to Animatable(
334-
popupValue.offset.y
335-
)
336-
} else {
337-
Animatable(0f) to Animatable(0f)
338-
}
339-
)
340-
}
341-
}
342-
}
343-
}
385+
)
386+
}
387+
.pointerInput(Unit) {
388+
if (!popupProperties.enabled || data.all { it.popupProperties?.enabled == false })
389+
return@pointerInput
390+
391+
detectTapGestures(
392+
onTap = {
393+
if (tapJob != null && tapJob?.isActive == true) {
394+
tapJob?.cancel()
395+
tapJob = null
344396
}
345397

346-
}
347-
scope.launch {
348-
// animate popup (alpha)
349-
if (popupAnimation.value != 1f && !popupAnimation.isRunning) {
350-
popupAnimation.animateTo(1f, animationSpec = tween(500))
398+
tapJob = scope.launch {
399+
if (isDisplayingPopup) {
400+
hidePopup(withAnimation = false)
401+
}
402+
403+
showPopup(
404+
data = data,
405+
size = size,
406+
position = it
407+
)
408+
409+
delay(500)
410+
if (isDisplayingPopup) {
411+
hidePopup(withAnimation = true)
412+
}
351413
}
352-
}
353-
}
354-
)
355-
}
414+
},
415+
)
416+
}
356417
) {
357418
val chartAreaHeight = size.height
358419
chartWidth.value = size.width
@@ -373,9 +434,12 @@ fun LineChart(
373434
}
374435
if (linesPathData.isEmpty() || linesPathData.count() != data.count()) {
375436
data.map {
376-
val startIndex = if(it.viewRange.startIndex < 0 || it.viewRange.startIndex >= it.values.size - 1) 0 else it.viewRange.startIndex
377-
val endIndex = if(it.viewRange.endIndex < 0 || it.viewRange.endIndex <= it.viewRange.startIndex
378-
|| it.viewRange.endIndex > it.values.size - 1) it.values.size - 1 else it.viewRange.endIndex
437+
val startIndex =
438+
if (it.viewRange.startIndex < 0 || it.viewRange.startIndex >= it.values.size - 1) 0 else it.viewRange.startIndex
439+
val endIndex =
440+
if (it.viewRange.endIndex < 0 || it.viewRange.endIndex <= it.viewRange.startIndex
441+
|| it.viewRange.endIndex > it.values.size - 1
442+
) it.values.size - 1 else it.viewRange.endIndex
379443

380444
getLinePath(
381445
dataPoints = it.values.map { it.toFloat() },
@@ -430,11 +494,11 @@ fun LineChart(
430494

431495
var startOffset = 0f
432496
var endOffset = size.width
433-
if(pathData.startIndex > 0) {
434-
startOffset = pathData.xPositions[pathData.startIndex] .toFloat()
497+
if (pathData.startIndex > 0) {
498+
startOffset = pathData.xPositions[pathData.startIndex].toFloat()
435499
}
436500

437-
if(pathData.endIndex < line.values.size - 1) {
501+
if (pathData.endIndex < line.values.size - 1) {
438502
endOffset = pathData.xPositions[pathData.endIndex].toFloat()
439503
}
440504

@@ -521,11 +585,10 @@ fun LineChart(
521585
}
522586

523587

524-
525588
@Composable
526589
private fun Indicators(
527590
modifier: Modifier = Modifier,
528-
indicators:List<Double>,
591+
indicators: List<Double>,
529592
indicatorProperties: HorizontalIndicatorProperties,
530593
) {
531594
Column(
@@ -676,7 +739,7 @@ fun DrawScope.drawDots(
676739
pathMeasure.setPath(linePath, false)
677740
val lastPosition = pathMeasure.getPosition(pathMeasure.length)
678741
dataPoints.forEachIndexed { valueIndex, value ->
679-
if(valueIndex in startIndex..endIndex) {
742+
if (valueIndex in startIndex..endIndex) {
680743
val dotOffset = Offset(
681744
x = _size.width.spaceBetween(
682745
itemCount = dataPoints.count(),
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
#Sun May 19 11:34:05 IRST 2024
1+
#Fri Aug 01 19:14:22 EEST 2025
22
distributionBase=GRADLE_USER_HOME
33
distributionPath=wrapper/dists
4-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
4+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
55
zipStoreBase=GRADLE_USER_HOME
66
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)