PyQt+QML 实现蜂巢菜单源码分享
- 一、效果展示
- 二、源码分享
- 1、工程结构
- 2、库安装
- 3、main.py
- 4、honeycomb.qml
- 三、实现原理
- 1、整体架构
- 2、蜂巢网格布局
- 3、圆形裁剪
- 4、鱼眼凸起效果
- 5、点击检测
- 6、交互细节
一、效果展示
二、源码分享
1、工程结构
2、库安装
3、main.py
importsysimportosfromPyQt6.QtCoreimportQUrlfromPyQt6.QtGuiimportQGuiApplicationfromPyQt6.QtQmlimportQQmlApplicationEnginedefmain():app=QGuiApplication(sys.argv)engine=QQmlApplicationEngine()qml_path=os.path.join(os.path.dirname(os.path.abspath(__file__)),"honeycomb.qml")engine.load(QUrl.fromLocalFile(qml_path))ifnotengine.rootObjects():print("错误:无法加载 QML 文件",file=sys.stderr)sys.exit(-1)sys.exit(app.exec())if__name__=="__main__":main()4、honeycomb.qml
importQtQuickimportQtQuick.Controls Window{id:root width:700height:480visible:true title:"蜂巢菜单"color:"#0a0a0a"//─── 凸起效果中心(跟随鼠标)───propertyreal bulgeCX:350propertyreal bulgeCY:245//─── 选中图标索引(-1=未选中)───propertyintselectedIndex:-1//─── 布局常量 ─── readonlypropertyreal clockCX:350readonlypropertyreal clockCY:220readonlypropertyreal gridOffsetY:22//图标整体下移量 readonlypropertyreal visualCY:clockCY+gridOffsetY//实际视觉中心 Y readonlypropertyreal clipR:155readonlypropertyreal baseSize:54//═══════════════════════════════════════════════//图标数据//═══════════════════════════════════════════════ readonlypropertyvar iconData:[//第1行(y=100,3个){baseX:290,baseY:100,name:"apple",image:"image/apple.svg"},{baseX:348,baseY:100,name:"avocado",image:"image/avocado.svg"},{baseX:406,baseY:100,name:"banana",image:"image/banana.svg"},//第2行(y=150,5个){baseX:205,baseY:150,name:"bayberry",image:"image/bayberry.svg"},{baseX:263,baseY:150,name:"blueberry",image:"image/blueberry.svg"},{baseX:321,baseY:150,name:"cherry",image:"image/cherry.svg"},{baseX:379,baseY:150,name:"dragonfruit",image:"image/dragonfruit.svg"},{baseX:437,baseY:150,name:"grape",image:"image/grape.svg"},//第3行(y=200,5个,中间行){baseX:234,baseY:200,name:"mango",image:"image/mango.svg"},{baseX:292,baseY:200,name:"mangosteen",image:"image/mangosteen.svg"},{baseX:350,baseY:200,name:"orange",image:"image/orange.svg"},{baseX:408,baseY:200,name:"peach",image:"image/peach.svg"},{baseX:466,baseY:200,name:"persimmon",image:"image/persimmon.svg"},//第4行(y=250,5个){baseX:205,baseY:250,name:"pineapple",image:"image/pineapple.svg"},{baseX:263,baseY:250,name:"strawberry",image:"image/strawberry.svg"},{baseX:321,baseY:250,name:"watermelon",image:"image/watermelon.svg"},{baseX:379,baseY:250,name:"waxapple",image:"image/waxapple.svg"},{baseX:437,baseY:250,name:"apple",image:"image/apple.svg"},//第5行(y=300,3个){baseX:290,baseY:300,name:"avocado",image:"image/avocado.svg"},{baseX:348,baseY:300,name:"banana",image:"image/banana.svg"},{baseX:406,baseY:300,name:"bayberry",image:"image/bayberry.svg"},]//═══════════════════════════════════════════════//点击检测//遍历所有图标,找距点击位置最近且在圆形范围内的图标//═══════════════════════════════════════════════ function findIconAt(mx,my){var bestIdx=-1var bestDist=Infinityfor(var i=0;i<iconData.length;i++){var icon=iconData[i]var iy=icon.baseY+gridOffsetY//偏移后的实际 Y var dx=icon.baseX-mx var dy=iy-my var dist=Math.sqrt(dx*dx+dy*dy)//圆形裁剪:偏移后的图标到表盘中心的距离 var cdx=icon.baseX-clockCX var cdy=iy-visualCY var cdist=Math.sqrt(cdx*cdx+cdy*cdy)if(cdist<=clipR-14&&dist<baseSize/2&&dist<bestDist){bestDist=dist bestIdx=i}}returnbestIdx}//═══════════════════════════════════════════════//全屏背景//═══════════════════════════════════════════════ Rectangle{anchors.fill:parent color:"#0a0a0a"}//═══════════════════════════════════════════════//表盘外框(三层渐变金属边框)//═══════════════════════════════════════════════//外层装饰环 Rectangle{x:root.clockCX-root.clipR-8y:root.clockCY-root.clipR-8width:root.clipR*2+16height:root.clipR*2+16radius:root.clipR+8gradient:Gradient{orientation:Gradient.Horizontal GradientStop{position:0.0;color:"#2e2e2e"}GradientStop{position:0.5;color:"#0f0f0f"}GradientStop{position:1.0;color:"#1e1e1e"}}border.width:1.5border.color:"#555"}//中层倒角 Rectangle{x:root.clockCX-root.clipR-3y:root.clockCY-root.clipR-3width:root.clipR*2+6height:root.clipR*2+6radius:root.clipR+3gradient:Gradient{orientation:Gradient.Horizontal GradientStop{position:0.0;color:"#222"}GradientStop{position:0.5;color:"#0a0a0a"}GradientStop{position:1.0;color:"#181818"}}border.width:1border.color:"#333"}//内层(黑色表盘底色) Rectangle{x:root.clockCX-root.clipR y:root.clockCY-root.clipR width:root.clipR*2height:root.clipR*2radius:root.clipR color:"#050505"border.width:0.5border.color:"#1a1a1a"}//═══════════════════════════════════════════════//蜂巢图标网格+鱼眼凸起效果//═══════════════════════════════════════════════ Repeater{model:root.iconData delegate:Rectangle{//─── 从 JS 对象提取数据 ─── readonlypropertyreal baseX:modelData.baseX readonlypropertyreal baseY:modelData.baseY+root.gridOffsetY//─── 到表盘中心的距离(决定是否裁剪)─── readonlypropertyreal cdx:baseX-root.clockCX readonlypropertyreal cdy:baseY-root.clockCY readonlypropertyreal cdist:Math.sqrt(cdx*cdx+cdy*cdy)readonlypropertyboolinCircle:cdist<=root.clipR-14//─── 到鼠标(凸起中心)的距离 → 放大系数 ─── readonlypropertyreal mdx:baseX-root.bulgeCX readonlypropertyreal mdy:baseY-root.bulgeCY readonlypropertyreal mdist2:mdx*mdx+mdy*mdy readonlypropertyreal bulge:0.38+0.82*Math.exp(-mdist2/5500.0)//─── 缩放后的尺寸 ─── readonlypropertyreal scaledSz:root.baseSize*bulge readonlypropertyboolisSelected:index===root.selectedIndex visible:inCircle x:baseX-scaledSz/2y:baseY-scaledSz/2width:scaledSz height:scaledSz radius:scaledSz*0.24gradient:Gradient{orientation:Gradient.Horizontal GradientStop{position:0.0;color:isSelected ?"#2a3a50":"#1e1e20"}GradientStop{position:1.0;color:isSelected ?"#152030":"#0c0c0e"}}border.width:isSelected ?1.5:0.5border.color:isSelected ?"#4a8eff80":"#3a3a3a50"//─── 图标图片(居中,占瓦片60%)─── Image{source:modelData.image width:parent.width*0.60height:parent.height*0.60anchors.centerIn:parent sourceSize.width:width*2//提高 SVG 渲染清晰度 sourceSize.height:height*2}}}//═══════════════════════════════════════════════//鼠标交互(鱼眼凸起跟随+点击检测)//═══════════════════════════════════════════════ MouseArea{anchors.fill:parent hoverEnabled:false//关闭悬浮监听,鼠标悬浮不再触发效果propertyboolisPressed:falsepropertyboolisClick:false onPressed:{isPressed=true isClick=true}onReleased:{isPressed=falseif(isClick){root.selectedIndex=root.findIconAt(mouse.x,mouse.y)}isClick=false}//只有按住鼠标拖动时,才更新凸起中心点 onPositionChanged:{if(isPressed){root.bulgeCX=mouse.x root.bulgeCY=mouse.y}isClick=false}onClicked:{}/*onExited:{isPressed=false//鼠标移出窗口时重置凸起中心(可选,不需要可以删掉) root.bulgeCX=root.clockCX root.bulgeCY=root.clockCY}*/}//═══════════════════════════════════════════════//底部信息面板//═══════════════════════════════════════════════ Rectangle{x:root.clockCX-150y:root.clockCY+root.clipR+24width:300height:46radius:23color:root.selectedIndex>=0?"#1a1a2e":"#111118"border.width:1border.color:root.selectedIndex>=0?"#4a8eff40":"#222"Text{anchors.centerIn:parent width:parent.width-20horizontalAlignment:Text.AlignHCenter verticalAlignment:Text.AlignVCenter font.pixelSize:14font.weight:Font.Light color:root.selectedIndex>=0?"#8ab4f8":"#555"text:{if(root.selectedIndex>=0&&root.iconData[root.selectedIndex]){return"📌 #"+root.selectedIndex+" "+root.iconData[root.selectedIndex].name}return"点击图标查看详情"}}}}三、实现原理
1、整体架构
本项目采用PyQt6 + QML混合架构:Python 负责程序入口与 QML 文件加载,QML 负责全部界面渲染与交互逻辑。核心思路是用 QML 的声明式语法描述蜂巢网格,用 JavaScript 函数实现点击检测与鱼眼凸起效果,两者通过属性绑定与信号机制协同工作。
2、蜂巢网格布局
蜂巢网格并非使用传统网格布局组件,而是通过Repeater + 手工坐标实现:
- 在
iconData数组中,每个图标预先定义好基准坐标baseX/baseY,按行错位排列,形成蜂巢状交错布局。 - 第 1 行 3 个、第 2 行 5 个、第 3 行 5 个……通过行内数量与 X 坐标的错位,自然形成六边形蜂巢轮廓。
- 每个瓦片由
Repeater的 delegate 动态生成,modelData提供图标名称、图片路径与坐标。
3、圆形裁剪
蜂巢整体被限制在一个圆形表盘内,实现方式为距离判断 + 可见性控制:
- 以表盘中心
(clockCX, clockCY)为圆心,clipR为半径。 - 每个瓦片计算自身到圆心的距离
cdist,当cdist <= clipR - 14时inCircle为真,瓦片才可见。 - 这样超出圆形范围的瓦片自动隐藏,形成干净的圆形裁剪效果。
4、鱼眼凸起效果
鱼眼效果是本项目的视觉亮点,核心公式为高斯衰减函数:
- 鼠标位置作为凸起中心
(bulgeCX, bulgeCY)。 - 每个瓦片计算到凸起中心的距离平方
mdist2。 - 放大系数
bulge = 0.38 + 0.82 * exp(-mdist2 / 5500.0),距离越近放大越明显,距离越远越接近基础大小。 - 瓦片的实际尺寸
scaledSz = baseSize * bulge,同时 X / Y 坐标也随尺寸调整,保证瓦片始终以自身中心为基准缩放。
5、点击检测
点击检测在findIconAt(mx, my)函数中完成:
- 遍历所有图标,计算点击点到图标中心的距离
dist。 - 同时判断该图标是否在圆形裁剪范围内。
- 当
dist < baseSize / 2且距离最近时,判定为命中该图标。 - 命中后更新
selectedIndex,底部信息面板随之显示对应图标名称。
6、交互细节
- 凸起跟随:只有按住鼠标拖动时,
onPositionChanged才更新凸起中心点,实现“按住拖动”的鱼眼跟随效果。 - 点击判定:按下时记录
isClick,拖动时清除isClick,松开时若仍为点击状态则执行选中逻辑,避免拖动误触。 - 视觉反馈:选中瓦片使用高亮渐变与边框颜色,底部面板同步显示选中信息。