效果展示:
![]()
DraggableLineChart.qml
import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import QtQuick.Shapes 1.15 Item { id: rootItem // ========== 新增:容器与标题栏属性 ========== property real containerHeight: 300 property string title: "" property color titleBgColor: "#00a8e8" property color containerBorderColor: "#00a8e8" property color containerBgColor: "#0a0a0a" property color titleTextColor: "#ffffff" property real titleHeight: 28 property real containerRadius: 4 property real containerBorderWidth: 1 // ========== 自定义信号 ========== signal dragPointUpdated(int index, real x, real y) signal dragPointFinished(int index, real x, real y) // ========== 控制点绑定接口 ========== property real controlPoint1X: 20 property real controlPoint1Y: 60 property real controlPoint2X: 40 property real controlPoint2Y: 75 property real controlPoint3X: 60 property real controlPoint3Y: 85 property real controlPoint4X: 80 property real controlPoint4Y: 98 property bool __updatingFromExternal: false property bool __isSliderUpdating: false // ========== ScrollView关联 ========== property var scrollView: null property bool scrollPrevented: false // ========== 图表可配置属性 ========== property color bgColor: "#1a1a1a" property color toolbarColor: "#252525" property color tableBgColor: "#252525" property real xAxisMax: 160 property real xAxisTickInterval: 20 property real yAxisMax: 160 property real yAxisTickInterval: 20 property string xAxisLabel: "X 轴" property string yAxisLabel: "Y 轴" property real xAxisLabelMargin: 20 property real yAxisLabelMargin: 40 property bool enableYIncrementOnly: false property bool enableFixedXAxis: false property bool interceptMouse: true property int longPressDelay: 200 property real xAxisTickCount: Math.ceil(xAxisMax / xAxisTickInterval) property real yAxisTickCount: Math.ceil(yAxisMax / yAxisTickInterval) property var dataPoints: [ {x: 0, y: 0}, {x: controlPoint1X, y: controlPoint1Y}, {x: controlPoint2X, y: controlPoint2Y}, {x: controlPoint3X, y: controlPoint3Y}, {x: controlPoint4X, y: controlPoint4Y}, {x: 100, y: 100} ] function recalcDataPoints() { let cp1X = isNaN(controlPoint1X) ? 20 : controlPoint1X; let cp1Y = isNaN(controlPoint1Y) ? 60 : controlPoint1Y; let cp2X = isNaN(controlPoint2X) ? 40 : controlPoint2X; let cp2Y = isNaN(controlPoint2Y) ? 75 : controlPoint2Y; let cp3X = isNaN(controlPoint3X) ? 60 : controlPoint3X; let cp3Y = isNaN(controlPoint3Y) ? 85 : controlPoint3Y; let cp4X = isNaN(controlPoint4X) ? 80 : controlPoint4X; let cp4Y = isNaN(controlPoint4Y) ? 98 : controlPoint4Y; return [ {x: 0, y: 0}, {x: cp1X, y: cp1Y}, {x: cp2X, y: cp2Y}, {x: cp3X, y: cp3Y}, {x: cp4X, y: cp4Y}, {x: 100, y: 100} ] } onControlPoint1YChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 1) { dataPoints[1].y = controlPoint1Y; canvas.requestPaint() } } onControlPoint2YChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 2) { dataPoints[2].y = controlPoint2Y; canvas.requestPaint() } } onControlPoint3YChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 3) { dataPoints[3].y = controlPoint3Y; canvas.requestPaint() } } onControlPoint4YChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 4) { dataPoints[4].y = controlPoint4Y; canvas.requestPaint() } } onControlPoint1XChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 1) { dataPoints[1].x = controlPoint1X; canvas.requestPaint() } } onControlPoint2XChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 2) { dataPoints[2].x = controlPoint2X; canvas.requestPaint() } } onControlPoint3XChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 3) { dataPoints[3].x = controlPoint3X; canvas.requestPaint() } } onControlPoint4XChanged: { if (!__isSliderUpdating && dataPoints && dataPoints.length > 4) { dataPoints[4].x = controlPoint4X; canvas.requestPaint() } } property int chartMargin: 40 property int pointRadius: 10 property int lineWidth: 3 property color lineColor: "#00a8e8" property color pointColor: "#ffffff" property color pointHoverColor: "#00a8e8" property color gridColor: "#333333" property color textColor: "#888888" property var nonDraggablePoints: [] property int selectedPointIndex: -1 property int hoveredPointIndex: -1 property bool isDragging: false property int draggedPointIndex: -1 property real dragOffsetX: 0 property real dragOffsetY: 0 // ========== 长按处理 ========== QtObject { id: longPressHandler property bool isPressing: false property bool longPressTriggered: false property int pressX: 0 property int pressY: 0 property Timer pressTimer: Timer { interval: rootItem.longPressDelay repeat: false onTriggered: { } } function preventScroll(prevent) { if (rootItem.scrollView) { var flickable = rootItem.scrollView.flickableItem || rootItem.scrollView if (flickable) { flickable.interactive = !prevent rootItem.scrollPrevented = prevent } } } function startPress(x, y) { isPressing = true; longPressTriggered = false pressX = x; pressY = y; pressTimer.start() } function endPress() { isPressing = false; pressTimer.stop(); longPressTriggered = false if (rootItem.scrollPrevented) preventScroll(false) } function cancelPress() { if (pressTimer.running) pressTimer.stop() longPressTriggered = false if (rootItem.scrollPrevented) preventScroll(false) } } // ========== 外层容器 ========== Rectangle { anchors.fill: parent color: rootItem.containerBgColor border.color: rootItem.containerBorderColor border.width: rootItem.containerBorderWidth radius: rootItem.containerRadius clip: true // 标题栏 Rectangle { id: titleBar width: parent.width height: rootItem.titleHeight color: rootItem.titleBgColor radius: rootItem.containerRadius visible: rootItem.title !== "" Text { anchors.centerIn: parent text: rootItem.title color: rootItem.titleTextColor font.pixelSize: 13 font.bold: true } Row { anchors.right: parent.right anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter spacing: 15 Text { id: dragText text: "未拖动" color: rootItem.titleTextColor font.bold: true font.pixelSize: 13 } Text { id: dragDetailText text: "" color: rootItem.titleTextColor font.bold: true font.pixelSize: 13 } } } // 图表内容区域 Item { anchors.fill: parent anchors.topMargin: rootItem.title !== "" ? rootItem.titleHeight : 0 anchors.margins: 10 clip: true Rectangle { anchors.fill: parent color: rootItem.bgColor } ColumnLayout { anchors.fill: parent spacing: 0 Item { Layout.fillWidth: true Layout.fillHeight: true // 背景网格 Canvas { id: gridCanvas anchors.fill: parent anchors.margins: chartMargin onPaint: { var ctx = getContext("2d") ctx.clearRect(0, 0, width, height) ctx.strokeStyle = gridColor ctx.lineWidth = 1 for (var i = 0; i <= xAxisTickCount; i++) { var x = (i / xAxisTickCount) * width ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke() } for (var j = 0; j <= yAxisTickCount; j++) { var y = (j / yAxisTickCount) * height ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke() } } Connections { target: rootItem function onXAxisMaxChanged() { gridCanvas.requestPaint() } function onXAxisTickIntervalChanged() { gridCanvas.requestPaint() } function onYAxisMaxChanged() { gridCanvas.requestPaint() } function onYAxisTickIntervalChanged() { gridCanvas.requestPaint() } } } // 坐标轴标签 Item { anchors.fill: parent anchors.margins: chartMargin Repeater { model: xAxisTickCount + 1 delegate: Text { x: (index / xAxisTickCount) * parent.width - width / 2 y: parent.height + 10 text: (index * xAxisTickInterval).toFixed(1) color: textColor font.pixelSize: 12 visible: xAxisTickInterval >= 0.1 || index % 2 === 0 } } Repeater { model: yAxisTickCount + 1 delegate: Text { x: -width - 10 y: parent.height - (index / yAxisTickCount) * parent.height - height / 2 text: (index * yAxisTickInterval).toFixed(1) color: textColor font.pixelSize: 12 visible: yAxisTickInterval >= 0.1 || index % 2 === 0 } } } // 主画布 Canvas { id: canvas anchors.fill: parent anchors.margins: chartMargin onPaint: { var ctx = getContext("2d") ctx.clearRect(0, 0, width, height) if (!dataPoints || !Array.isArray(dataPoints) || dataPoints.length < 2) return ctx.strokeStyle = lineColor ctx.lineWidth = lineWidth ctx.lineCap = "round" ctx.lineJoin = "round" ctx.beginPath() for (var i = 0; i < dataPoints.length; i++) { var px = (dataPoints[i].x / xAxisMax) * width var py = height - (dataPoints[i].y / yAxisMax) * height if (i === 0) ctx.moveTo(px, py) else ctx.lineTo(px, py) } ctx.stroke() ctx.fillStyle = "rgba(0, 168, 232, 0.1)" ctx.beginPath() ctx.moveTo((dataPoints[0].x / xAxisMax) * width, height) for (var j = 0; j < dataPoints.length; j++) { var fx = (dataPoints[j].x / xAxisMax) * width var fy = height - (dataPoints[j].y / yAxisMax) * height ctx.lineTo(fx, fy) } ctx.lineTo((dataPoints[dataPoints.length - 1].x / xAxisMax) * width, height) ctx.closePath() ctx.fill() for (var k = 0; k < dataPoints.length; k++) { var cx = (dataPoints[k].x / xAxisMax) * width var cy = height - (dataPoints[k].y / yAxisMax) * height var isHovered = (k === hoveredPointIndex) var isSelected = (k === selectedPointIndex) var isDragged = (k === draggedPointIndex) && isDragging var radius = isDragged ? pointRadius + 4 : (isHovered || isSelected ? pointRadius + 2 : pointRadius) if (isHovered || isSelected || isDragged) { ctx.beginPath() ctx.arc(cx, cy, radius + 4, 0, Math.PI * 2) ctx.fillStyle = isDragged ? "rgba(0, 168, 232, 0.4)" : "rgba(0, 168, 232, 0.2)" ctx.fill() } ctx.beginPath() ctx.arc(cx, cy, radius, 0, Math.PI * 2) ctx.fillStyle = isDragged ? "#00a8e8" : (isHovered ? pointHoverColor : (isSelected ? "#00a8e8" : lineColor)) ctx.fill() ctx.beginPath() ctx.arc(cx, cy, pointRadius - 2, 0, Math.PI * 2) ctx.fillStyle = pointColor ctx.fill() if (isDragged) { ctx.strokeStyle = "rgba(0, 168, 232, 0.5)" ctx.lineWidth = 1 ctx.setLineDash([5, 5]) ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx, height); ctx.stroke() ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(0, cy); ctx.stroke() ctx.setLineDash([]) } } } Connections { target: rootItem function onXAxisMaxChanged() { canvas.requestPaint() } function onXAxisTickIntervalChanged() { canvas.requestPaint() } function onYAxisMaxChanged() { canvas.requestPaint() } function onYAxisTickIntervalChanged() { canvas.requestPaint() } function onEnableYIncrementOnlyChanged() { canvas.requestPaint() } function onEnableFixedXAxisChanged() { canvas.requestPaint() } } } // 鼠标事件 MouseArea { id: mouseArea anchors.fill: canvas hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton propagateComposedEvents: true preventStealing: true pressAndHoldInterval: rootItem.longPressDelay onExited: { hoveredPointIndex = -1; canvas.requestPaint() if (!isDragging) longPressHandler.cancelPress() } onPressAndHold: { if (!rootItem.interceptMouse) return var idx = getPointIndex(mouse.x, mouse.y) if (idx >= 0 && rootItem.nonDraggablePoints.indexOf(idx) === -1) { longPressHandler.preventScroll(true) startDrag(idx, mouse.x, mouse.y) } } onPressed: { if (!rootItem.interceptMouse) { mouse.accepted = false; return } longPressHandler.startPress(mouse.x, mouse.y) var idx = getPointIndex(mouse.x, mouse.y) mouse.accepted = (idx >= 0) } onPositionChanged: { if (isDragging) { updateDrag(mouse.x, mouse.y) mouse.accepted = true longPressHandler.preventScroll(true) } else if (longPressHandler.isPressing && !longPressHandler.longPressTriggered) { var moveDist = Math.sqrt(Math.pow(mouse.x - longPressHandler.pressX, 2) + Math.pow(mouse.y - longPressHandler.pressY, 2)) if (moveDist > 5) longPressHandler.cancelPress() mouse.accepted = false var idx = getPointIndex(mouse.x, mouse.y) if (idx !== hoveredPointIndex) { hoveredPointIndex = idx cursorShape = idx >= 0 ? (enableFixedXAxis ? Qt.SizeVerCursor : Qt.PointingHandCursor) : Qt.ArrowCursor canvas.requestPaint() } } else { var idx = getPointIndex(mouse.x, mouse.y) if (idx !== hoveredPointIndex) { hoveredPointIndex = idx cursorShape = idx >= 0 ? (enableFixedXAxis ? Qt.SizeVerCursor : Qt.PointingHandCursor) : Qt.ArrowCursor canvas.requestPaint() } mouse.accepted = false } } onReleased: { longPressHandler.endPress() if (isDragging) { endDrag(); mouse.accepted = true } else { longPressHandler.cancelPress(); mouse.accepted = false } longPressHandler.preventScroll(false) } onDoubleClicked: { if (!isDragging && rootItem.interceptMouse) { if (!dataPoints || !Array.isArray(dataPoints)) return var newX = (mouse.x / width) * xAxisMax var newY = yAxisMax - (mouse.y / height) * yAxisMax if (enableFixedXAxis) { newX = dataPoints.length > 0 ? dataPoints[dataPoints.length - 1].x + xAxisTickInterval : 0 } if (enableYIncrementOnly && dataPoints.length > 0) { newY = Math.max(newY, dataPoints[dataPoints.length - 1].y) } var insertIdx = 0 for (var i = 0; i < dataPoints.length; i++) { if (dataPoints[i].x < newX) insertIdx = i + 1 } dataPoints.splice(insertIdx, 0, {x: newX, y: newY}) selectedPointIndex = insertIdx forceTableUpdate() mouse.accepted = true } else { mouse.accepted = false } } onClicked: { if (!isDragging && rootItem.interceptMouse) { var idx = getPointIndex(mouse.x, mouse.y) if (idx >= 0) { selectedPointIndex = idx; canvas.requestPaint(); mouse.accepted = true } else { selectedPointIndex = -1; canvas.requestPaint(); mouse.accepted = false } } else { mouse.accepted = false } } } // 触摸事件 MultiPointTouchArea { anchors.fill: canvas touchPoints: [TouchPoint { id: tp1 }] property int activePointIndex: -1 onPressed: { if (!rootItem.interceptMouse) return longPressHandler.startPress(tp1.x, tp1.y) var pt = getPointIndex(tp1.x, tp1.y) if (pt >= 0 && rootItem.nonDraggablePoints.indexOf(pt) === -1) { longPressHandler.preventScroll(true) activePointIndex = pt startDrag(pt, tp1.x, tp1.y) } } onUpdated: { if (activePointIndex >= 0 && isDragging) { updateDrag(tp1.x, tp1.y) longPressHandler.preventScroll(true) } else if (longPressHandler.isPressing && !longPressHandler.longPressTriggered) { var moveDist = Math.sqrt(Math.pow(tp1.x - longPressHandler.pressX, 2) + Math.pow(tp1.y - longPressHandler.pressY, 2)) if (moveDist > 5) longPressHandler.cancelPress() } } onReleased: { longPressHandler.endPress() if (activePointIndex >= 0) { endDrag(); activePointIndex = -1 } } } // 坐标轴标题 Text { id: xAxisLabelText anchors.bottom: parent.bottom anchors.bottomMargin: rootItem.xAxisLabelMargin anchors.horizontalCenter: parent.horizontalCenter text: rootItem.xAxisLabel color: textColor font.pixelSize: 14 } Text { id: yAxisLabelText anchors.left: parent.left anchors.leftMargin: rootItem.yAxisLabelMargin anchors.verticalCenter: parent.verticalCenter text: rootItem.yAxisLabel color: textColor font.pixelSize: 14 rotation: -90 } } // 底部数据表格 Rectangle { visible: false enabled: false Layout.fillWidth: true height: 150 color: rootItem.tableBgColor Flickable { id: tableFlickable anchors.fill: parent anchors.margins: 10 contentWidth: rowLayout.width clip: true Row { id: rowLayout spacing: 10 Repeater { id: pointRepeater model: dataPoints delegate: Rectangle { width: 80; height: 130 color: selectedPointIndex === index ? "#00a8e8" : (isDragging && draggedPointIndex === index ? "#004466" : "#333333") radius: 4 border.color: isDragging && draggedPointIndex === index ? "#00a8e8" : "transparent" border.width: 2 Column { anchors.centerIn: parent spacing: 5 Text { text: "#" + (index + 1) color: selectedPointIndex === index ? "#ffffff" : "#888888" font.pixelSize: 12 anchors.horizontalCenter: parent.horizontalCenter } Text { text: "X: " + modelData.x.toFixed(1) color: enableFixedXAxis ? "#aaaaaa" : "#ffffff" font.pixelSize: 11 anchors.horizontalCenter: parent.horizontalCenter } Text { text: "Y: " + modelData.y.toFixed(1) color: "#ffffff" font.pixelSize: 11 anchors.horizontalCenter: parent.horizontalCenter } Rectangle { width: 60; height: 24 color: "#444444" radius: 2 anchors.horizontalCenter: parent.horizontalCenter TextInput { anchors.fill: parent text: modelData.y.toFixed(1) color: "#ffffff" font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter selectByMouse: true validator: DoubleValidator { bottom: 0; top: rootItem.yAxisMax; decimals: 1 } onEditingFinished: { var newY = parseFloat(text) if (enableYIncrementOnly && index > 0) { if (dataPoints && dataPoints.length > index - 1) { newY = Math.max(newY, dataPoints[index - 1].y) } } if (dataPoints && dataPoints.length > index) { dataPoints[index].y = newY canvas.requestPaint() } } } } } MouseArea { anchors.fill: parent onClicked: { selectedPointIndex = index; canvas.requestPaint() } } } } } } } } } } // ========== 状态重置方法 ========== function resetDragState() { isDragging = false draggedPointIndex = -1 hoveredPointIndex = -1 selectedPointIndex = -1 longPressHandler.endPress() if (scrollPrevented) longPressHandler.preventScroll(false) __updatingFromExternal = false __isSliderUpdating = false canvas.requestPaint() } function forceTableUpdate() { canvas.requestPaint() gridCanvas.requestPaint() pointRepeater.model = [] pointRepeater.model = dataPoints if (selectedPointIndex >= 0) { tableFlickable.contentX = Math.max(0, (selectedPointIndex * 90) - (tableFlickable.width / 2)) } } function getPointIndex(mx, my) { var threshold = 25 var minDist = threshold var foundIdx = -1 var chartWidth = canvas.width var chartHeight = canvas.height if (!dataPoints || !Array.isArray(dataPoints) || dataPoints.length === 0) return -1 for (var i = 0; i < dataPoints.length; i++) { var px = (dataPoints[i].x / xAxisMax) * chartWidth var py = chartHeight - (dataPoints[i].y / yAxisMax) * chartHeight var dist = Math.sqrt(Math.pow(mx - px, 2) + Math.pow(my - py, 2)) if (dist < minDist) { minDist = dist; foundIdx = i } } return foundIdx } function startDrag(idx, mx, my) { if (!dataPoints || !Array.isArray(dataPoints) || idx < 0 || idx >= dataPoints.length) return isDragging = true draggedPointIndex = idx selectedPointIndex = idx var chartWidth = canvas.width var chartHeight = canvas.height var px = (dataPoints[idx].x / xAxisMax) * chartWidth var py = chartHeight - (dataPoints[idx].y / yAxisMax) * chartHeight dragOffsetX = mx - px dragOffsetY = my - py canvas.requestPaint() } function updateDrag(mx, my) { if (!dataPoints || !Array.isArray(dataPoints) || !isDragging || draggedPointIndex < 0 || draggedPointIndex >= dataPoints.length) return var chartWidth = canvas.width var chartHeight = canvas.height var newPixelX = mx - dragOffsetX var newPixelY = my - dragOffsetY var newX = (newPixelX / chartWidth) * xAxisMax var newY = yAxisMax - (newPixelY / chartHeight) * yAxisMax newX = Math.max(0, Math.min(xAxisMax, newX)) newY = Math.max(0, Math.min(yAxisMax, newY)) if (!enableFixedXAxis) { if (draggedPointIndex > 0) newX = Math.max(newX, dataPoints[draggedPointIndex - 1].x + 0.01) if (draggedPointIndex < dataPoints.length - 1) newX = Math.min(newX, dataPoints[draggedPointIndex + 1].x - 0.01) } else { newX = dataPoints[draggedPointIndex].x } if (enableYIncrementOnly) { if (draggedPointIndex > 0) newY = Math.max(newY, dataPoints[draggedPointIndex - 1].y) if (draggedPointIndex < dataPoints.length - 1) newY = Math.min(newY, dataPoints[draggedPointIndex + 1].y) } dataPoints[draggedPointIndex].x = newX dataPoints[draggedPointIndex].y = newY canvas.requestPaint() if (draggedPointIndex >= 1 && draggedPointIndex <= 4) { __updatingFromExternal = true __isSliderUpdating = true switch(draggedPointIndex) { case 1: controlPoint1X = newX; controlPoint1Y = newY; break case 2: controlPoint2X = newX; controlPoint2Y = newY; break case 3: controlPoint3X = newX; controlPoint3Y = newY; break case 4: controlPoint4X = newX; controlPoint4Y = newY; break } __updatingFromExternal = false __isSliderUpdating = false } // 内部更新拖动状态文字 dragText.text = "拖动中... 松开固定位置" dragDetailText.text = "点#" + (draggedPointIndex + 1) + " X:" + newX.toFixed(1) + " Y:" + newY.toFixed(1) rootItem.dragPointUpdated(draggedPointIndex, newX, newY) } function endDrag() { isDragging = false var lastIndex = draggedPointIndex var lastX = (dataPoints && dataPoints.length > lastIndex) ? dataPoints[lastIndex].x : 0 var lastY = (dataPoints && dataPoints.length > lastIndex) ? dataPoints[lastIndex].y : 0 draggedPointIndex = -1 canvas.requestPaint() longPressHandler.endPress() // 内部更新拖动状态文字 dragText.text = "未拖动" dragDetailText.text = "点#" + (lastIndex + 1) + " 最终值 X:" + lastX.toFixed(1) + " Y:" + lastY.toFixed(1) rootItem.dragPointFinished(lastIndex, lastX, lastY) } Component.onCompleted: { dataPoints = recalcDataPoints() gridCanvas.requestPaint() canvas.requestPaint() } }