QML PathView:吸附对齐与环形轮播

PathView 不只是简单地把项沿着路径排开,它还提供了吸附、高亮范围控制等属性,能做出更精细的交互效果。当路径不再是直线,而是闭合的圆形时,还能做出环形轮播效果。

这篇文章用两个例子来演示:第一个例子讲吸附对齐效果;第二个例子用 PathAngleArc 画一个整圆,做出环形滚动效果。

吸附对齐

可以通过下拉框切换吸附模式,对比有吸附和没吸附的区别。

演示代码

qml 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Basic
import QtQuick.Layouts

FadeInAnimation {
    id: root

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 20
        spacing: 15

        // ... 省略标题组件 TitleSeparator ...

        // 内容卡片
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#fafafa"
            radius: 8
            border.color: "#e0e0e0"
            border.width: 1

            Rectangle {
                anchors.horizontalCenter: parent.horizontalCenter
                anchors.top: parent.top
                anchors.bottom: parent.bottom
                width: 2
                color: "#e74c3c"
                opacity: 0.45
            }

            PathView {
                id: pathView
                anchors.fill: parent
                anchors.margins: 10
                clip: true
                focus: true

                model: 12
                pathItemCount: 4
                preferredHighlightBegin: 0.5
                preferredHighlightEnd: 0.5
                highlightRangeMode: PathView.NoHighlightRange
                snapMode: snapCombo.currentIndex === 0 ? PathView.SnapToItem : PathView.NoSnap

                path: Path {
                    startX: 0
                    startY: pathView.height / 2
                    PathLine {
                        x: pathView.width
                        y: pathView.height / 2
                    }
                }

                delegate: Rectangle {
                    id: panel
                    required property int index

                    width: 84
                    height: 104
                    radius: 8
                    color: PathView.isCurrentItem ? "#1976D2" : "#ffffff"
                    border.width: 1
                    border.color: PathView.isCurrentItem ? "#0d47a1" : "#dcdcdc"

                    Text {
                        anchors.centerIn: parent
                        text: panel.index + 1
                        font.pointSize: 18
                        font.bold: true
                        color: PathView.isCurrentItem ? "white" : "#8a99aa"
                    }

                    MouseArea {
                        anchors.fill: parent
                        onClicked: panel.PathView.view.currentIndex = panel.index
                    }
                }

                WheelHandler {
                    acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
                    target: null
                    onWheel: (event) => {
                        if (event.angleDelta.y < 0)
                            pathView.incrementCurrentIndex()
                        else if (event.angleDelta.y > 0)
                            pathView.decrementCurrentIndex()
                    }
                }

                Keys.onLeftPressed: pathView.decrementCurrentIndex()
                Keys.onRightPressed: pathView.incrementCurrentIndex()
            }
        }

        // 控制条
        RowLayout {
            Layout.fillWidth: true
            spacing: 10

            Text {
                text: "吸附模式"
                font.pointSize: 10
                color: "#666"
            }

            ComboBox {
                id: snapCombo
                Layout.preferredWidth: 180
                font.pointSize: 10
                model: ["SnapToItem(吸附)", "NoSnap(不吸附)"]
            }

            Item { Layout.fillWidth: true }

            Button {
                text: "上一个"
                font.pointSize: 10
                onClicked: pathView.decrementCurrentIndex()
            }

            Button {
                text: "下一个"
                font.pointSize: 10
                onClicked: pathView.incrementCurrentIndex()
            }
        }

        RowLayout {
            Layout.fillWidth: true
            spacing: 16

            Text {
                text: "当前项:index " + pathView.currentIndex
                font.pointSize: 10
                color: "#999"
            }

            Text {
                text: "状态:" + (pathView.dragging ? "拖动中" : (pathView.flicking ? "滑行中" : "已静止"))
                font.pointSize: 10
                color: "#999"
            }
        }
    }
}

吸附功能由snapMode属性控制。PathView.SnapToItem代表松手后自动对齐到最近条目;PathView.NoSnap为关闭吸附,停留在当前位置。

这里highlightRangeMode设置为PathView.NoHighlightRange,如果使用StrictlyEnforceRange,当前项会被固定在preferredHighlightBegin位置,拖动时整体跟随移动,吸附效果不明显。使用NoHighlightRange,条目可以自由滑动,松手后才回弹吸附,直观体现吸附效果。

delegate内添加MouseArea,点击卡片可直接切换到对应条目:通过附加属性PathView.view获取所属PathView实例,修改currentIndex。点击卡片,卡片就滚动对齐到中间,交互更直观。

滚轮切换使用WheelHandler。PathView原生不处理滚轮事件,需要自行实现:滚轮向上切换上一项,向下切换下一项,逻辑和方向键保持一致。

底部状态栏展示当前索引与拖动状态。dragging和flicking属性可实时获取PathView交互状态,便于调试。

适用场景:商品横向浏览、图片选择器、任何需要"对齐到项"的滑动列表。

环形轮播

演示代码

qml 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Basic
import QtQuick.Layouts

FadeInAnimation {
    id: root

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 20
        spacing: 15

        // ... 省略标题组件 TitleSeparator ...

        // 内容卡片
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#fafafa"
            radius: 8
            border.color: "#e0e0e0"
            border.width: 1

            PathView {
                id: carousel
                anchors.fill: parent
                anchors.margins: 10
                clip: true
                focus: true

                readonly property real ringRadius: (Math.min(width, height) / 2 - 8) / 1.32
                readonly property real dotSize: ringRadius * 0.55

                model: 8
                pathItemCount: 8
                snapMode: PathView.SnapOneItem
                preferredHighlightBegin: 0
                preferredHighlightEnd: 0
                highlightRangeMode: PathView.StrictlyEnforceRange

                path: Path {
                    startX: carousel.width / 2
                    startY: carousel.height / 2 - carousel.ringRadius
                    PathAngleArc {
                        centerX: carousel.width / 2
                        centerY: carousel.height / 2
                        radiusX: carousel.ringRadius
                        radiusY: carousel.ringRadius
                        startAngle: -90
                        sweepAngle: 360
                        moveToStart: true
                    }
                }

                delegate: Rectangle {
                    id: tile
                    required property int index

                    width: carousel.dotSize
                    height: carousel.dotSize
                    radius: width / 2
                    color: PathView.isCurrentItem ? "#1976D2" : "#bdc3c7"
                    scale: PathView.isCurrentItem ? 1.15 : 0.85

                    Behavior on color { ColorAnimation { duration: 200 } }
                    Behavior on scale { NumberAnimation { duration: 200 } }

                    Text {
                        anchors.centerIn: parent
                        text: tile.index + 1
                        font.pointSize: carousel.dotSize * 0.28
                        font.bold: true
                        color: "white"
                    }
                }

                Keys.onLeftPressed: carousel.decrementCurrentIndex()
                Keys.onRightPressed: carousel.incrementCurrentIndex()
            }

            // 圆心信息
            Column {
                anchors.centerIn: parent
                spacing: 2

                Text {
                    anchors.horizontalCenter: parent.horizontalCenter
                    text: carousel.currentIndex + 1
                    font.pointSize: carousel.dotSize * 0.62
                    font.bold: true
                    color: "#1976D2"
                }

                Text {
                    anchors.horizontalCenter: parent.horizontalCenter
                    text: "共 " + carousel.count + " 项"
                    font.pointSize: carousel.dotSize * 0.19
                    color: "#999"
                }
            }
        }

        // 控制条
        RowLayout {
            Layout.fillWidth: true
            spacing: 12

            Switch {
                id: autoSwitch
                text: "自动轮播"
                font.pointSize: 10
            }

            Item { Layout.fillWidth: true }

            Button {
                text: "上一个"
                font.pointSize: 10
                onClicked: carousel.decrementCurrentIndex()
            }

            Button {
                text: "下一个"
                font.pointSize: 10
                onClicked: carousel.incrementCurrentIndex()
            }
        }
    }

    Timer {
        interval: 1200
        repeat: true
        running: autoSwitch.checked
        onTriggered: carousel.incrementCurrentIndex()
    }
}

环形路径使用PathAngleArc,设置圆心、半径、起始角度和扫角,即可绘制圆弧。示例中sweepAngle: 360绘制完整圆环,startAngle: -90把起点放在圆环正上方(12点方向),第0项就在此处,也是当前项位置。

preferredHighlightBegin和preferredHighlightEnd都设为0,搭配StrictlyEnforceRange,当前项固定在路径起点(圆环顶部)。切换时所有条目沿圆周旋转,转到顶部即为选中项,效果类似旋转轮盘。

圆环半径和圆点大小动态计算,关联视图尺寸。ringRadius取视图宽高较小值,除以2并预留边距,保证圆环完整显示。dotSize按环半径比例计算,窗口缩放时圆点同步变化,避免图形变形、圆点重叠。

圆心放置大号文字,展示当前项序号和总数量。该图层独立于PathView,用anchors.centerIn: parent居中,作为轮盘中心读数。

自动轮播依靠Timer,每1.2秒调用一次incrementCurrentIndex()。Switch开关控制Timer的running属性,开启自动旋转,关闭则停止。

适用场景:转盘抽奖、环形菜单、创意展示页的旋转导航。


已验证环境:

相关推荐
时间的拾荒人1 小时前
Qt 多线程详解:从 QThread 到实战
开发语言·qt·面试
时间的拾荒人4 小时前
Qt 事件机制全解析:从事件处理到事件过滤器
开发语言·qt·面试
时间的拾荒人7 小时前
Qt 窗口系统详解:从菜单栏到对话框的完整指南
开发语言·qt·面试
qq_401700418 小时前
Qt 炫酷曲线,图表,2D/3D开源库
开发语言·qt
扶尔魔ocy9 小时前
【qt openGL入门笔记】基础光照
qt·opengl
时间的拾荒人10 小时前
Qt 文件操作详解:从基础类到读写实战
开发语言·qt·面试
扶尔魔ocy13 小时前
【qt openGL入门笔记】坐标系统和摄像机
qt·opengl
C++ 老炮儿的技术栈1 天前
main函数之后的调用
c语言·c++·人工智能·qt·mfc·c
沫璃染墨1 天前
《Qt从零入门系列(十二):Qt文件操作详解——从QFile读写到QFileInfo与记事本实战》
开发语言·网络·c++·qt·交互·信号处理·文件