我正在 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)
}
您遇到的问题是由于显示的蜡烛坐标与触摸检测之间未对准造成的,特别是在缩放和平移等转换之后。以下是如何通过在变换后重新计算坐标来改进该方法以确保准确的触摸检测。
按键调整 调整缩放和平移画布的触摸坐标
您需要确保触摸事件坐标进行转换以匹配缩放和平移位置。根据转换后的坐标而不是原始触摸坐标来计算索引。 确保缩放和偏移计算的一致性
在确定转换后的蜡烛指数时,需要在计算中直接考虑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
}
)
}```