QtQuick实现瀑布流布局
前言
期末大作业要用qt做个什么管理系统, 这星期我要做个旅游笔记列表. 感觉用ListView页面就一列太单调了点, 也不能适配大屏幕, 就想着弄成多列的并且可以根据页面宽度改变列数.
简易实现: GridView
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// 主组件
Rectangle {
id: root
property var notes
color: "transparent"
border.color: "black"
ColumnLayout {
anchors.fill: parent
width:parent.width
height:parent.height
// 帖子列表
GridView {
id: noteContainer
Layout.fillWidth: true
Layout.fillHeight: true
property int column:2
width: parent.width
height: parent.height
cellWidth: width / column
cellHeight: 200
clip: true
property bool isLoading: false
property bool hasMore: true
model: ListModel {
id: noteListModel
}
Component.onCompleted: {
if ((!isLoading)&&hasMore) { // 误差范围
isLoading = true
getMoreData(()=>{isLoading = false},12);
}
}
delegate: Rectangle {
width: noteContainer.cellWidth
height: noteContainer.cellHeight
NoteCard {
id: content
note: article
anchors.fill: parent
anchors.margins: 10
}
}
onContentYChanged: {
if (contentY + height >= contentHeight - 10&&!isLoading&&hasMore) { // 误差范围
console.log("Reached the bottom");
isLoading = true
getMoreData(()=>{isLoading = false}); // 调用你的函数
}
}
}
}
property int pageIndex: 1
function getMoreData(callback,pageSize=10){
// 略, 大概作用是往listModel里加数据
}
function calculateColumns() {
if(width<=400){
return 2
}
if(width>400&&width<=800){
return 3
}
return 4
}
onWidthChanged:{
noteContainer.column = calculateColumns()
}
}
使用GridView实现比较简单一些, 但是对于没学多少就开写的地方有一些很让人红温的地方. 不知道是什么原因, 直接使用delegate:NoteCard{...}
, 数据传不进去, 问ai也什么都回答不出来. 真的红温了.
实现简单, 但是结果感觉不太符合我的审美. 因为这玩意单元格的顶部永远是对齐的, 但是每个单元格的高度又不一定相同. 有的单元格比较高, 有的比较矮. 矮的单元格就会有一块空白, 高的单元格内容又会被其它格子覆盖, 就很讨厌.
但是使用GridView单元格宽高就是固定的, 不管怎么样顶部都是对齐, 算是它本身的一个缺点吧.
Qt并没有提供现成的瀑布流布局, 只能自己手写了.
手写瀑布流布局
计算所需数据x,y,width,height
首先可以自己想一下一个格子需要什么数据才能画出来. 应该可以想到, 只要确定了这个单元格的x, y, width, height就可以把这个格子给画出来. height由子组件提供, width由根组件的宽度与列数决定, 因此只需要计算出x和y.
对于第一列的
那其实可以得到一个表达式:
y的表达式也可以通过类似方法得到, 但是这里需要注意, 列与列之间y不一定相同, 格子宽度固定但是高度由子组件决定. 因为需要用一个数组来存储y, 记为
对于第i列第j行的
初步实现
实现如下
import QtQuick
import QtQuick.Controls
Flickable {
id:flickableContainer
anchors.fill: parent
contentWidth: width
contentHeight: Math.min(yArray[0],yArray[1])
property var notes
property int column: 2
property real spacing: 10
property var yArray: [0,0]
property bool hasMore: true
property bool isLoading: false
Repeater {
id: noteContainer
model:ListModel{
id:listModel
}
delegate: Rectangle{
height: card.height
width:parent.width * 0.5 - flickableContainer.spacing
color:"transparent"
NoteCard {
id: card
Component.onCompleted: {
curY = yArray[card.num]
yArray[card.num] += card.height + flickableContainer.spacing * 2
}
width: parent.width
property int num: index%2
x: num * parent.width + 2 * flickableContainer.spacing * num
property real curY : 0
y:curY
note: article
}
}
Component.onCompleted: {
getMoreData(()=>{},()=>{hasMore=false},16)
}
}
property int pageIndex: 1
function getMoreData(loadingCallback,hasMoreCallback,pageSize=10){
// 略
}
onContentYChanged: {
if ((contentY >= contentHeight - height - 50) && !isLoading && hasMore) {
isLoading = true
getMoreData(() => {isLoading = false},()=>{hasMore=false}, 12)
console.log("load")
}
}
}
这里有两个坑, QML的冒号表示双向绑定, 并且不是深度响应(类似vue的ref), 而是浅层的, 相当于vue的shallowRef. 也就是说yArray[0]或者yArray[1]改变是不会触发yArrayChanged事件的, 所以需要直接修改yArray而不是修改其中某个元素.
同时由于双向绑定, 直接将yArray[0]或者yArray[1]赋值给y可能会出现一些奇奇怪怪的bug, 因此需要通过curY中转一下, 或者在onCompleted里用等号给y赋值.
NoteCard {
id: card
Component.onCompleted: {
curY = yArray[card.num]
yArray[card.num] += card.height + flickableContainer.spacing * 2
}
// ...
}
应该改成
NoteCard {
id: card
Component.onCompleted: {
curY = yArray[card.num]
yArray[card.num] += card.height + flickableContainer.spacing * 2
yArray = [...yArray]
}
// ...
}
contentHeight
也应改成Math.min(...yArray)
, 这样就能保证contentHeight正确更新了.
这里其实还没有实现响应式布局, 不管屏幕多宽都是两列.
实现响应式布局
首先需要确定每个宽度区间需要有多少列, 可以写一个简单的函数来获取:
// 在根组件加载时调用
function changeColumns(windowWidth){
if(windowWidth<=400){
return 2
}else if(windowWidth>400&&windowWidth<=800){
return 3
}else{
return 4
}
}
Component.onCompleted:{
column = changeColumns(width)
yArray.length = column
yArray.fill(0)
}
在根组件加一个属性property int column: 2
, 然后就可以将Rectangle的width:parent.width * 0.5 - flickableContainer.spacing
改成width:parent.width/flickableContainer.column - flickableContainer.spacing
了.
用于判断第几列的num也需要改一下:property int num: index%flickableContainer.column
这样就可以适应不同宽度的屏幕了.
不过这样还有不完美的地方, pc端可以拖动边框来改变宽度, 但是这里只在组件渲染的时候确定列数, 并不会随宽度改变而改变. 可以监听width改变:
onWidthChanged:{
// 防止过多的重置数据
if(isLoading){
return
}
const newColumn = changeColumns(width)
if(newColumn == column){
return
}
isLoading = true
column = newColumn
yArray.length = newColumn
yArray.fill(0)
isLoading = false
}
简单的重置了一下列数和yArray, 但是调试的时候会发现:拉宽的时候确实变成多列了, 但是上下的间隔也增大了, 只有往下滑加载数据之后才正常.
如果你也出现这种情况, 原因可能是NoteCard里由于变宽了, 高度变矮了, 但是它们的y是没有更新的, 所以间隔也就变大了.
对于这种情况, 需要让子组件重新加载. 在vue里的话可以通过v-if或者其它什么方式重新加载, 在这里只需要重置一下数据就可以了.
function reset(){
// 防止过多的重置数据
if(isLoading){
return
}
const newColumn = changeColumns(width)
if(newColumn == column){
return
}
isLoading = true
column = newColumn
yArray.length = newColumn
yArray.fill(0)
// 刷新数据, 让页面更新
noteContainer.model = null
noteContainer.model = listModel
isLoading = false
}
这里还可以做个防抖, 拖动边框的时候很丝滑一些. 这样写会有点卡.
进一步封装
这个组件基本上已经写好了, 不过如果想要让它更通用一些可以继续修改.
可以将changeColumns
和getMoreData
写成property, 让父组件来决定列数和如何加载数据.
如果要考虑跳转的话还可以写一个clickHandler
暴露出去, 实现一个事件托管.
最后
写了一晚上的QML, 我的评价是不如用vue. 感觉写的好痛苦, 果然我还是太菜了吧.