在 Jetpack Compose 自定义 CandleStick 图表中缩放和滑动后索引选择错误

问题描述 投票:0回答:1

我正在 Android 应用程序中使用 Jetpack Compose 构建自定义烛台图。该图表显示财务数据,用户可以放大/缩小并滑动以浏览蜡烛。但是,我面临一个问题,即缩放或滑动后所选蜡烛索引不正确。

当我在缩放或平移(滑动)后点击特定蜡烛时,会出现问题;所选蜡烛索引与我点击的蜡烛不对应。变换(缩放和平移)后坐标似乎没有正确映射到蜡烛位置。

如果您能提供任何帮助或指导来解决此问题,我将不胜感激。

到目前为止我尝试过的:

调整坐标变换以考虑比例和偏移。 确保触摸坐标正确转换为图表的坐标系。 尝试了不同的方法来根据触摸事件位置计算蜡烛指数。 尽管进行了这些尝试,缩放或滑动后所选蜡烛索引仍然不正确。


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)
}

android kotlin android-jetpack-compose android-canvas candlestick-chart
1个回答
0
投票

您遇到的问题是由于显示的蜡烛坐标与触摸检测之间未对准造成的,特别是在缩放和平移等转换之后。以下是如何通过在变换后重新计算坐标来改进该方法以确保准确的触摸检测。

按键调整 调整缩放和平移画布的触摸坐标

您需要确保触摸事件坐标进行转换以匹配缩放和平移位置。根据转换后的坐标而不是原始触摸坐标来计算索引。 确保缩放和偏移计算的一致性

在确定转换后的蜡烛指数时,需要在计算中直接考虑scale和offsetX值。这将使所选索引与缩放级别和平移保持一致。 以下是调整点击检测逻辑以纠正问题的方法:

    detectTapGestures(
        onLongPress = { offset ->
            if (!isGuideLine) {
                // Adjust coordinates based on zoom and pan to get the actual index
                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

                    // Position the guide line based on the transformed center of the selected candle
                    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
        }
    )
}```

© www.soinside.com 2019 - 2024. All rights reserved.