[QML]BaseComponentsQML基础自定义控件-DraggableLineChart.qml可拖动折线图
效果展示DraggableLineChart.qmlimport 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() } }