QML GridView 动态:增删过渡与滚动定位

0 阅读5分钟

GridView 网格搭好之后,数据一变动,视图就得跟着动起来。那么新增的项从哪进场、删掉的项怎么退场、被挤动的项目怎么动态挪位?

这篇用两个简单的例子说说滚动控制和定位。

增删动画

4.gif

演示代码

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

FadeInAnimation {
    ListModel {
        id: tagModel
        ListElement { tagName: "风景"; tagColor: "#2ecc71" }
        ListElement { tagName: "人像"; tagColor: "#3498db" }
        ListElement { tagName: "街拍"; tagColor: "#e67e22" }
        ListElement { tagName: "美食"; tagColor: "#e74c3c" }
        ListElement { tagName: "夜景"; tagColor: "#9b59b6" }
        ListElement { tagName: "建筑"; tagColor: "#1abc9c" }
    }

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

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

        // 操作按钮
        RowLayout {
            Layout.fillWidth: true
            spacing: 10

            Button {
                text: "随机新增"
                font.pointSize: 10
                onClicked: {
                    var colors = ["#16a085", "#2980b9", "#8e44ad", "#c0392b", "#d35400", "#27ae60"]
                    var names = ["星空", "海洋", "森林", "沙漠", "雪山", "草原"]
                    var i = Math.floor(Math.random() * names.length)
                    tagModel.insert(Math.floor(Math.random() * tagModel.count), {
                        "tagName": names[i],
                        "tagColor": colors[i]
                    })
                }
            }

            Button {
                text: "删除首项"
                font.pointSize: 10
                enabled: tagModel.count > 0
                onClicked: tagModel.remove(0)
            }

            Button {
                text: "打乱顺序"
                font.pointSize: 10
                onClicked: {
                    // 未定义 move 过渡:被移动项直接跳到新格,邻居走 displaced 滑过去
                    for (var i = 0; i < 3; i++) {
                        var from = Math.floor(Math.random() * tagModel.count)
                        var to = Math.floor(Math.random() * tagModel.count)
                        tagModel.move(from, to, 1)
                    }
                }
            }

            Item { Layout.fillWidth: true }

            Text {
                text: "共 " + tagModel.count + " 项"
                font.pointSize: 10
                color: "#666"
            }
        }

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

            GridView {
                id: grid
                anchors.fill: parent
                anchors.margins: 10
                clip: true

                cellWidth: 90
                cellHeight: 70
                model: tagModel

                // 新增的项从 (100,100) 位置滑入并淡入
                add: Transition {
                    NumberAnimation { properties: "x,y"; from: 100; duration: 300; easing.type: Easing.OutBack }
                    NumberAnimation { property: "opacity"; from: 0; duration: 300 }
                }

                // 删除的项淡出
                remove: Transition {
                    NumberAnimation { property: "opacity"; to: 0; duration: 250 }
                }

                // 被挤动的项滑到新格子
                displaced: Transition {
                    NumberAnimation { properties: "x,y"; duration: 300; easing.type: Easing.OutCubic }
                }

                delegate: Item {
                    id: cell
                    required property int index
                    required property string tagName
                    required property string tagColor

                    // remove 过渡期间本 delegate 仍存活,但 index 已过期,置位后忽略重复点击
                    property bool removing: false

                    width: grid.cellWidth
                    height: grid.cellHeight

                    Rectangle {
                        anchors.fill: parent
                        anchors.margins: 5
                        radius: 8
                        color: cell.tagColor

                        Column {
                            anchors.centerIn: parent
                            spacing: 4

                            Text {
                                anchors.horizontalCenter: parent.horizontalCenter
                                text: cell.tagName
                                color: "white"
                                font.pointSize: 12
                                font.bold: true
                            }

                            Text {
                                anchors.horizontalCenter: parent.horizontalCenter
                                text: "#" + (cell.index + 1)
                                color: "#FFFFFFAA"
                                font.pointSize: 9
                            }
                        }

                        MouseArea {
                            anchors.fill: parent
                            onClicked: {
                                // 点谁删谁,观察 remove 和 displaced 的配合
                                // 动画期间 index 不再更新,先校验范围防止删错项
                                if (cell.removing || cell.index < 0 || cell.index >= tagModel.count)
                                    return
                                cell.removing = true
                                tagModel.remove(cell.index)
                            }
                        }
                    }
                }
            }
        }

        Text {
            Layout.fillWidth: true
            text: "点击任意标签可删除该项"
            font.pointSize: 10
            color: "#999"
        }
    }
}

add 属性只管新的项如何进场,remove 属性只管被删的项怎么退场,displaced 属性统一负责那些被挤动的项。

删除这里有个细节,移除动画期间,delegate 还没被销毁,但 index 已经不可靠了,所以 delegate 里加了 removing 作为标记,点过一次就忽略后续点击,删除前再校验一次索引范围,避免连点删错项。

适用场景:可增删的标签云、可编辑的宫格列表、带动画的数据看板。

动态操作

可以直接定位到某一具体的项,或者用键盘或鼠标点击能够切换选中项,模型也能随时追加和删除。如图所示:

5.gif

演示代码

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

FadeInAnimation {
    id: root

    property int nextId: 14
    // 模型就是一个普通数组,1~14 不用逐条声明 ListElement
    property var numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]

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

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

        // 操作区
        RowLayout {
            Layout.fillWidth: true
            spacing: 8

            SpinBox {
                id: jumpBox
                from: 0
                to: Math.max(0, root.numbers.length - 1)
                value: 12
                font.pointSize: 10
            }

            Button {
                text: "定位到该格"
                font.pointSize: 10
                enabled: root.numbers.length > 0
                onClicked: {
                    grid.positionViewAtIndex(jumpBox.value, GridView.Contain)
                    grid.currentIndex = jumpBox.value
                }
            }

            Button {
                text: "末尾追加"
                font.pointSize: 10
                onClicked: {
                    // 换成新数组会触发模型整体 reset,currentIndex 会被清空,
                    // 所以先记下来,等视图处理完更新再恢复
                    var keep = grid.currentIndex
                    root.numbers = root.numbers.concat(++root.nextId)
                    Qt.callLater(function() {
                        grid.positionViewAtIndex(root.numbers.length - 1, GridView.Contain)
                        if (keep >= 0)
                            grid.currentIndex = keep
                    })
                }
            }

            Button {
                text: "删除选中"
                font.pointSize: 10
                enabled: grid.currentIndex >= 0 && grid.currentIndex < root.numbers.length
                onClicked: {
                    var i = grid.currentIndex
                    root.numbers = root.numbers.slice(0, i).concat(root.numbers.slice(i + 1))
                    // reset 后恢复选中:优先原位置,删的是末尾项则前移一格
                    Qt.callLater(function() {
                        grid.currentIndex = Math.min(i, root.numbers.length - 1)
                    })
                }
            }

            Item { Layout.fillWidth: true }
        }

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

            GridView {
                id: grid
                anchors.fill: parent
                anchors.margins: 10
                clip: true
                focus: true

                cellWidth: 66
                cellHeight: 66
                model: root.numbers

                delegate: Item {
                    id: cell
                    required property int index
                    required property var modelData

                    width: grid.cellWidth
                    height: grid.cellHeight

                    Rectangle {
                        anchors.fill: parent
                        anchors.margins: 5
                        radius: 8
                        color: cell.GridView.isCurrentItem ? "#1296FF" : "#FFFFFF"
                        border.color: "#e0e0e0"
                        border.width: 1

                        Behavior on color { ColorAnimation { duration: 120 } }

                        Text {
                            anchors.centerIn: parent
                            text: cell.modelData
                            color: cell.GridView.isCurrentItem ? "white" : "#333"
                            font.pointSize: 14
                            font.bold: true
                        }
                    }

                    MouseArea {
                        anchors.fill: parent
                        onClicked: {
                            grid.currentIndex = cell.index
                            grid.forceActiveFocus()
                        }
                    }
                }
            }
        }

        // 状态提示
        Text {
            Layout.fillWidth: true
            text: "共 %1 项 | 当前 index:%2 | 点击格子或用方向键切换选中项"
                    .arg(root.numbers.length)
                    .arg(grid.currentIndex)
            font.pointSize: 10
            color: "#666"
        }
    }
}

positionViewAtIndex(index, mode) 接口把指定项滚动进视野中,第二个参数是停靠方式,这里用的 GridView.Contain 表示让整项完整可见,项比视图还大时就贴到顶部。想让它停在中间或用 GridView.Center,想只保证可见用 GridView.Visible。

currentIndex 记住当前选中项,GridView.isCurrentItem 这个附加属性让 delegate 知道自己是不是被选中的那个。选中的格子填成蓝色、文字变白,颜色变化加了一段 Behavior 过渡。方向键切换不用自己写按键处理,只要 focus: true,GridView 会自动响应上下左右并移动 currentIndex;点格子也会把选中项设过去并抢回焦点,鼠标和键盘两边能接着用。

模型用的是普通 JS 数组。追加和删除都是换成一份新数组,这会让 GridView 整体重置、currentIndex 被清空,所以代码在Qt.callLater里再恢复选中项并滚动到末尾。

过渡属性速查

属性作用
add新加入项自身的入场
remove被移除项自身的退场
move被移动项自身的位置变化
displaced被任意操作挤动的邻居
addDisplaced / moveDisplaced / removeDisplaced按增、移、删分别指定挤动动画

已验证环境: