Hulusi Şimşek
Hulusi Şimşek

Reputation: 21

Wrong Index Selection after Zoom and Swipe in Jetpack Compose Custom CandleStick Chart

I'm building a custom candlestick chart using Jetpack Compose in my Android app. The chart displays financial data, and users can zoom in/out and swipe to navigate through the candles. However, I'm facing an issue where the selected candle index is incorrect after zooming or swiping.

The problem occurs when I tap on a specific candle after zooming or panning (swiping); the selected candle index doesn't correspond to the candle I tapped on. It seems like the coordinates don't map correctly to the candle positions after transformations (zoom and pan).

I would appreciate any help or guidance to resolve this issue.

What I've tried so far:

Adjusting the coordinate transformations to account for scale and offset. Ensuring the touch coordinates are converted properly into the chart's coordinate system. Tried different ways to calculate the candle index based on the touch event location. Despite these attempts, the selected candle index remains incorrect after zoom or swipe.


import android.annotation.SuppressLint
import android.graphics.Matrix
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hulusimsek.cryptoapp.domain.model.Candle
import kotlin.math.abs

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.animateZoomBy
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateCentroidSize
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateRotation
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import com.hulusimsek.cryptoapp.domain.model.KlineModel
import com.hulusimsek.cryptoapp.presentation.theme.dusenKirmizi
import com.hulusimsek.cryptoapp.presentation.theme.yukselenYesil
import kotlin.math.abs
import androidx.compose.material3.\*
import androidx.compose.runtime.\*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.window.Popup
import com.hulusimsek.cryptoapp.util.Constants.removeTrailingZeros
import kotlinx.coroutines.launch
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt

@Composable
fun CandleStickChartView(
klines: List\<KlineModel\>,
modifier: Modifier = Modifier
) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }

    var selectedCandleIndex by remember { mutableStateOf<Int?>(null) }
    var showPopup by remember { mutableStateOf(false) }
    
    val density = LocalDensity.current.density
    val density2 = LocalDensity.current
    
    var guideLineX by remember { mutableStateOf<Float?>(null) }
    var guideLineY by remember { mutableStateOf<Float?>(null) }
    var isGuideLine by remember { mutableStateOf(false) }
    
    val candleWidthDp = 8.dp
    val candleSpacingDp = 4.dp
    val candleWidthPx = candleWidthDp.toPx(density)
    val candleSpacingPx = candleSpacingDp.toPx(density)
    
    val canvasWidthPx = with(density2) { 300.dp.toPx() }
    val canvasHeightPx = with(density2) { 400.dp.toPx() }
    
    val totalCandles = klines.size
    
    val transformableState = rememberTransformableState { zoomChange, offsetChange, _ ->
        // Control the zoom limits
        val newScale = (scale * zoomChange).coerceIn(0.5f, 2.0f)
        val scaleChange = newScale / scale
        scale = newScale
    
        // Adjust panning (scrolling) actions
        offsetX = (offsetX + offsetChange.x * scaleChange).checkRange(
            -((candleWidthPx * scale + candleSpacingPx) * totalCandles - canvasWidthPx),
            0f
        )
        offsetY = (offsetY + offsetChange.y * scaleChange).checkRange(
            -((canvasHeightPx * scale + candleSpacingPx) * totalCandles - canvasHeightPx),
            0f
        )
    
        // Update guide lines
        if (isGuideLine) {
            guideLineX = guideLineX?.let { (it - offsetX) * scaleChange + offsetX }
            guideLineY = guideLineY?.let { (it - offsetY) * scaleChange + offsetY }
        }
    }
    
    val dragModifier = Modifier.pointerInput(Unit) {
        detectDragGestures { change, dragAmount ->
            change.consume()
    
            offsetX = (offsetX + dragAmount.x).checkRange(
                -((candleWidthPx * scale + candleSpacingPx) * totalCandles - canvasWidthPx),
                0f
            )
            offsetY = (offsetY + dragAmount.y).checkRange(
                -((canvasHeightPx * scale + candleSpacingPx) * totalCandles - canvasHeightPx),
                0f
            )
    
            if (!isGuideLine) {
                guideLineX = null
                guideLineY = null
            }
        }
    }
        .pointerInput(Unit) {
            detectTapGestures(
                onLongPress = { offset ->
                    if (!isGuideLine) {
                        val transformedX = (offset.x - offsetX) / scale
                        val candleIndex = (transformedX / (candleWidthPx + candleSpacingPx)).toInt().coerceIn(0, totalCandles - 1)
    
                        if (candleIndex in klines.indices) {
                            selectedCandleIndex = candleIndex
                            showPopup = true
    
                            val candleCenterX = candleIndex * (candleWidthPx + candleSpacingPx) + (candleWidthPx / 2)
                            guideLineX = candleCenterX * scale + offsetX
                            guideLineY = offset.y
                            isGuideLine = true
                        }
                    }
                },
                onTap = {
                    showPopup = false
                    guideLineX = null
                    guideLineY = null
                    isGuideLine = false
                }
            )
        }
        .pointerInput(Unit) {
            detectDragGestures(
                onDragStart = { offset ->
                    if (isGuideLine) {
                        guideLineX = offset.x
                        guideLineY = offset.y
                    }
                },
                onDrag = { change, dragAmount ->
                    change.consume()
    
                    if (isGuideLine) {
                        guideLineX = (guideLineX!! + dragAmount.x).coerceIn(0f, canvasWidthPx)
                        guideLineY = (guideLineY!! + dragAmount.y).coerceIn(0f, canvasHeightPx)
                    }
                },
                onDragEnd = {
                    if (isGuideLine) {
                        selectedCandleIndex?.let { index ->
                            val candleCenterX = index * (candleWidthPx + candleSpacingPx) + (candleWidthPx / 2)
                            guideLineX = candleCenterX * scale + offsetX
                            guideLineY = size.height / 2f
                        }
                    }
                }
            )
        }
    
    LaunchedEffect(scale, klines) {
        val canvasWidth = with(density2) { 300.dp.toPx() }
        val totalWidth = (candleWidthPx * scale + candleSpacingPx) * totalCandles - candleSpacingPx
        offsetX = (-totalWidth + canvasWidth).checkRange(-totalWidth, canvasWidth)
    }
    
    Box(
        modifier = modifier
            .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
            .border(2.dp, Color.Gray)
            .clip(RoundedCornerShape(12.dp))
            .then(dragModifier)
            .transformable(state = transformableState)
    ) {
        Canvas(
            modifier = Modifier
                .fillMaxSize()
        ) {
            val canvasWidth = size.width
            val canvasHeight = size.height
    
            val totalWidth = (candleWidthPx * scale + candleSpacingPx) * totalCandles - candleSpacingPx
    
            val fromIndex = ((-offsetX) / (candleWidthPx * scale + candleSpacingPx)).toInt().coerceAtLeast(0)
            val toIndex = ((canvasWidth - offsetX) / (candleWidthPx * scale + candleSpacingPx)).toInt() + fromIndex
    
            val adjustedFromIndex = fromIndex.coerceAtLeast(0)
            val adjustedToIndex = minOf(
                adjustedFromIndex + (canvasWidth / (candleWidthPx * scale + candleSpacingPx)).toInt() + 1,
                totalCandles
            )
    
            if (adjustedFromIndex < adjustedToIndex && adjustedFromIndex < totalCandles) {
                val visibleKlines = klines.subList(adjustedFromIndex, adjustedToIndex)
    
                val minLow = visibleKlines.minOfOrNull { it.lowPrice.toFloatOrNull() ?: Float.MAX_VALUE } ?: 0f
                val maxHigh = visibleKlines.maxOfOrNull { it.highPrice.toFloatOrNull() ?: Float.MIN_VALUE } ?: 1f
                val priceRange = maxHigh - minLow
    
                if (priceRange > 0) {
                    drawCandles(
                        klines = visibleKlines,
                        candleWidth = candleWidthPx * scale,
                        candleSpacing = candleSpacingPx,
                        canvasHeight = canvasHeight,
                        minLow = minLow,
                        priceRange = priceRange
                    )
    
                    drawYLabels(
                        minLow = minLow,
                        maxHigh = maxHigh,
                        priceRange = priceRange,
                        canvasHeight = canvasHeight
                    )
    
                    // Draw guide lines with scaled and translated positions
                    if (isGuideLine) {
                        guideLineX?.let { x ->
                            drawLine(
                                color = Color.Blue,
                                start = Offset(x, 0f),
                                end = Offset(x, canvasHeight),
                                strokeWidth = 1.dp.toPx(density)
                            )
                        }
                        guideLineY?.let { y ->
                            drawLine(
                                color = Color.Blue,
                                start = Offset(0f, y),
                                end = Offset(canvasWidth, y),
                                strokeWidth = 1.dp.toPx(density)
                            )
                        }
                    }
                }
            }
        }
    
        // Show popup for the selected candlestick
        if (showPopup && selectedCandleIndex != null) {
            val candle = klines[selectedCandleIndex!!]
            Popup(
                alignment = Alignment.TopStart,
                offset = IntOffset((guideLineX ?: 0f).toInt(), (guideLineY ?: 0f).toInt())
            ) {
                Surface(
                    modifier = Modifier
                        .background(Color.White, RoundedCornerShape(8.dp))
                        .border(1.dp, Color.Black, RoundedCornerShape(8.dp))
                        .padding(8.dp)
                        .width(200.dp)
                ) {
                    Column {
                        Text("Open: ${removeTrailingZeros(candle.openPrice)}", fontSize = 14.sp)
                        Text("Close: ${removeTrailingZeros(candle.closePrice)}", fontSize = 14.sp)
                        Text("High: ${removeTrailingZeros(candle.highPrice)}", fontSize = 14.sp)
                        Text("Low: ${removeTrailingZeros(candle.lowPrice)}", fontSize = 14.sp)
                        Text("Date: ${formatDate(candle.openTime)}", fontSize = 14.sp)
                    }
                }
            }
        }
    }

}

// Helper Functions
fun Float.checkRange(min: Float, max: Float): Float {
return coerceIn(min, max)
}

private fun DrawScope.drawCandles(
klines: List\<KlineModel\>,
candleWidth: Float,
candleSpacing: Float,
canvasHeight: Float,
minLow: Float,
priceRange: Float
) {
val heightRatio = canvasHeight / priceRange

    klines.forEachIndexed { index, kline ->
        val openPrice = kline.openPrice.toFloatOrNull() ?: 0f
        val closePrice = kline.closePrice.toFloatOrNull() ?: 0f
        val highPrice = kline.highPrice.toFloatOrNull() ?: 0f
        val lowPrice = kline.lowPrice.toFloatOrNull() ?: 0f
    
        val candleX = index * (candleWidth + candleSpacing)
        val candleYOpen = canvasHeight - ((openPrice - minLow) * heightRatio)
        val candleYClose = canvasHeight - ((closePrice - minLow) * heightRatio)
        val candleYHigh = canvasHeight - ((highPrice - minLow) * heightRatio)
        val candleYLow = canvasHeight - ((lowPrice - minLow) * heightRatio)
    
        // Candle body
        drawRect(
            color = if (closePrice >= openPrice) Color.Green else Color.Red,
            topLeft = Offset(candleX, minOf(candleYOpen, candleYClose)),
            size = Size(candleWidth, Math.abs(candleYOpen - candleYClose))
        )
    
        // Candle wick
        drawLine(
            color = if (closePrice >= openPrice) Color.Green else Color.Red,
            start = Offset(candleX + candleWidth / 2, candleYHigh),
            end = Offset(candleX + candleWidth / 2, candleYLow),
            strokeWidth = 2f
        )
    }

}

private fun DrawScope.drawYLabels(
minLow: Float,
maxHigh: Float,
priceRange: Float,
canvasHeight: Float
) {
val labelCount = 9

    for (i in 0 until labelCount) {
        val labelValue = minLow + (priceRange / (labelCount - 1)) * i
        val yPos = canvasHeight - ((labelValue - minLow) * (canvasHeight / priceRange))
        val labelText = removeTrailingZeros(labelValue.toString())
    
        drawContext.canvas.nativeCanvas.apply {
            drawText(
                labelText,
                20f,
                yPos,
                Paint().apply {
                    color = Color.Gray.toArgb()
                    textSize = 24f
                    textAlign = Paint.Align.LEFT
                }
            )
        }
    }

}

private fun Dp.toPx(density: Float): Float = this.value \* density

fun formatDate(epochMillis: Long): String {
val date = Date(epochMillis)
val format = SimpleDateFormat("dd MMM HH:mm", Locale.getDefault())
return format.format(date)
}

Upvotes: 2

Views: 72

Answers (0)

Related Questions