Qt Quick 综合开发复习:自定义组件、Loader 与 HarmonyUI 实战

Qt Quick 综合开发复习:自定义组件、Loader 与 HarmonyUI 实战

本文根据《Qt Quick 开发教程综合讲解 2026年6月29日》整理,适合作为课后复习博客。重点包括自定义滑动区域 SwipeArea、自定义按钮 QuickButtonLoader 动态加载界面,以及一个类 HarmonyOS 的综合 UI 示例。

一、这节课到底在讲什么

这节课的核心不是某一个单独控件,而是 Qt Quick 的一个重要能力:把基础元素组合成自己的组件

前面学过的 RectangleMouseAreaImageLabelTimerGridLoader 等,都可以继续组合,封装成更贴近实际项目的组件。

本节主要目标:

  1. 会基于 MouseArea 自定义滑动区域。
  2. 会基于 Rectangle + MouseArea 自定义按钮。
  3. 会使用 Loader 实现页面切换。
  4. 会把自定义组件放进一个综合 UI 项目中复用。

二、自定义滑动区域 SwipeArea

1. 为什么要自定义 SwipeArea

Qt Quick 里有很多原生控件,但实际项目中经常会遇到"原生控件不完全满足需求"的情况。

比如手机系统常见操作:

  • 从顶部下滑,唤出控制中心。
  • 从底部上滑,返回桌面或查看后台。
  • 左右滑动,切换页面。

这些都可以用 MouseArea 的按下、移动、释放事件来模拟。

SwipeArea 的本质是:

qml 复制代码
MouseArea + 坐标记录 + 阈值判断 + 自定义信号

2. 实现思路

一次滑动事件可以拆成 3 步:

  1. onPressed:记录鼠标或手指按下时的原点坐标。
  2. onPositionChanged:判断当前移动更偏向 X 方向还是 Y 方向。
  3. onReleased:根据释放点和原点的差值,判断是上滑、下滑、左滑还是右滑。

为了避免轻微抖动也触发滑动,需要设置一个阈值:

qml 复制代码
property int threshold: 50

只有移动距离超过 threshold,才认为这是一次有效滑动。

三、Demo 1:封装 SwipeArea.qml

新建文件:SwipeArea.qml

qml 复制代码
import QtQuick

MouseArea {
    id: root

    // 记录按下时的起点坐标。
    property point origin: Qt.point(0, 0)

    // 滑动阈值。移动距离超过这个值,才算有效滑动。
    property int threshold: 50

    // 记录当前判断出来的主要拖动方向。
    // 初始允许 X 和 Y 两个方向,移动时再判断主方向。
    property int currentAxis: Drag.XAndYAxis

    // 对外暴露一个滑动信号。
    // direction 可取值:"left"、"right"、"up"、"down"。
    signal swipe(string direction)

    onPressed: function(mouse) {
        // 保存按下那一刻的位置。
        origin = Qt.point(mouse.x, mouse.y)

        // 每次重新按下时,都重置方向判断。
        currentAxis = Drag.XAndYAxis
    }

    onPositionChanged: function(mouse) {
        var dx = mouse.x - origin.x
        var dy = mouse.y - origin.y

        // 还没超过阈值时,不急着判断方向,避免误触。
        if (Math.abs(dx) < threshold && Math.abs(dy) < threshold) {
            return
        }

        // 哪个方向变化更大,就认为用户主要想往哪个方向滑。
        if (Math.abs(dx) > Math.abs(dy)) {
            currentAxis = Drag.XAxis
        } else {
            currentAxis = Drag.YAxis
        }
    }

    onReleased: function(mouse) {
        var dx = mouse.x - origin.x
        var dy = mouse.y - origin.y

        // 如果释放时依然没有超过阈值,就不触发滑动信号。
        if (Math.abs(dx) < threshold && Math.abs(dy) < threshold) {
            return
        }

        if (currentAxis === Drag.XAxis) {
            if (dx > 0) {
                root.swipe("right")
            } else {
                root.swipe("left")
            }
        } else if (currentAxis === Drag.YAxis) {
            if (dy > 0) {
                root.swipe("down")
            } else {
                root.swipe("up")
            }
        }
    }
}

练习说明

这个组件本身不显示任何东西,因为它继承自 MouseArea。它的作用是接收滑动动作,然后发出 swipe(direction) 信号。

四、Demo 2:使用 SwipeArea 实现下滑显示控制中心

新建或修改 Main.qml

qml 复制代码
import QtQuick
import QtQuick.Controls

Window {
    width: 480
    height: 800
    visible: true
    title: "SwipeArea Practice"

    Rectangle {
        id: page
        anchors.fill: parent
        color: "#eaf1f8"

        Label {
            anchors.centerIn: parent
            text: "向下滑动显示控制中心\n向上滑动隐藏控制中心"
            horizontalAlignment: Text.AlignHCenter
            font.pixelSize: 24
        }

        Rectangle {
            id: controlCenter
            width: parent.width
            height: 260
            y: -height
            color: "#222831"
            radius: 16

            Label {
                anchors.centerIn: parent
                text: "控制中心"
                color: "white"
                font.pixelSize: 32
                font.bold: true
            }

            // y 变化时加动画,让下拉效果更自然。
            Behavior on y {
                NumberAnimation {
                    duration: 220
                    easing.type: Easing.OutCubic
                }
            }
        }

        SwipeArea {
            anchors.fill: parent
            threshold: 60

            onSwipe: function(direction) {
                if (direction === "down") {
                    // 下滑:控制中心滑入页面。
                    controlCenter.y = 0
                } else if (direction === "up") {
                    // 上滑:控制中心隐藏到顶部外面。
                    controlCenter.y = -controlCenter.height
                }
            }
        }
    }
}

重点记忆

SwipeArea 负责识别动作,业务界面负责响应动作。这样组件就能复用,不会和某一个具体页面绑死。

五、自定义按钮 QuickButton

1. 为什么要自定义按钮

Qt Quick Controls 里有原生 Button,但实际项目里经常需要高度定制:

  • 自定义背景色。
  • 自定义按下颜色。
  • 自定义圆角。
  • 自定义字体。
  • 自定义图标。
  • 自定义点击信号。

所以可以用 Rectangle 做外观,用 MouseArea 做交互,用 LabelImage 显示内容。

六、Demo 3:封装 QuickButton.qml

新建文件:QuickButton.qml

qml 复制代码
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root

    width: 140
    height: 52
    radius: 10

    // 正常状态颜色。
    property color normalColor: "#2f80ed"

    // 按下状态颜色。
    property color pressedColor: "#1c5fb8"

    // 按钮文字。
    property string buttonText: "按钮"

    // 文字颜色。
    property color textColor: "white"

    // 字体设置。
    property int fontPixelSize: 20
    property bool fontBold: false
    property string fontFamily: ""

    // 对外暴露按钮事件。
    signal pressed()
    signal released()
    signal clicked()

    color: normalColor

    Label {
        anchors.centerIn: parent
        text: root.buttonText
        color: root.textColor
        font.pixelSize: root.fontPixelSize
        font.bold: root.fontBold
        font.family: root.fontFamily
    }

    MouseArea {
        anchors.fill: parent

        onPressed: {
            // 按下时切换颜色,并把事件发给外部。
            root.color = root.pressedColor
            root.pressed()
        }

        onReleased: {
            // 释放时恢复颜色。
            root.color = root.normalColor
            root.released()
        }

        onClicked: {
            // 点击事件交给外部处理。
            root.clicked()
        }
    }
}

关键点

QuickButton 把外观属性和交互信号都封装好了,外部只需要改属性和处理 onClicked

七、Demo 4:使用 QuickButton 实现计数器

qml 复制代码
import QtQuick
import QtQuick.Controls

Window {
    width: 640
    height: 420
    visible: true
    title: "QuickButton Practice"

    property int count: 0

    Label {
        id: countLabel
        anchors.centerIn: parent
        text: count
        font.pixelSize: 64
        font.bold: true
    }

    Row {
        spacing: 20
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.bottom: parent.bottom
        anchors.bottomMargin: 40

        QuickButton {
            buttonText: "减一"
            normalColor: "#eb5757"
            pressedColor: "#b84040"
            fontPixelSize: 24

            onClicked: {
                count -= 1
            }
        }

        QuickButton {
            buttonText: "清零"
            normalColor: "#4f4f4f"
            pressedColor: "#222222"
            fontPixelSize: 24

            onClicked: {
                count = 0
            }
        }

        QuickButton {
            buttonText: "加一"
            normalColor: "#27ae60"
            pressedColor: "#1f8a4c"
            fontPixelSize: 24

            onClicked: {
                count += 1
            }
        }
    }
}

复习提醒

这里的 countLabel.text: count 是属性绑定。只要 count 变化,界面就会自动刷新。

八、Loader 动态加载界面

1. Loader 是什么

Loader 用于动态加载 QML 组件,常用在界面切换。

核心属性:

qml 复制代码
source: "Home.qml"

source 改变时,Loader 会卸载旧界面并加载新界面。

常见用途:

  • 首页切换到设置页。
  • 设置页返回首页。
  • 点击应用图标进入详情页。
  • 根据状态动态加载不同页面。

九、Demo 5:Loader 页面切换完整示例

1. Main.qml

qml 复制代码
import QtQuick

Window {
    width: 420
    height: 720
    visible: true
    title: "Loader Practice"

    Loader {
        id: pageLoader
        anchors.fill: parent
        source: "Home.qml"
    }

    Connections {
        // Loader 加载出来的对象,可以通过 pageLoader.item 访问。
        target: pageLoader.item

        function onOpenPage(pageName) {
            if (pageName === "settings") {
                pageLoader.source = "SettingsPage.qml"
            } else if (pageName === "network") {
                pageLoader.source = "NetworkPage.qml"
            } else if (pageName === "home") {
                pageLoader.source = "Home.qml"
            }
        }
    }
}

2. Home.qml

qml 复制代码
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root
    color: "#f1f5f9"

    signal openPage(string pageName)

    Label {
        anchors.top: parent.top
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.topMargin: 80
        text: "Home"
        font.pixelSize: 42
        font.bold: true
    }

    Column {
        spacing: 20
        anchors.centerIn: parent

        QuickButton {
            buttonText: "设置"
            normalColor: "#2f80ed"
            onClicked: root.openPage("settings")
        }

        QuickButton {
            buttonText: "网络"
            normalColor: "#27ae60"
            pressedColor: "#1f8a4c"
            onClicked: root.openPage("network")
        }
    }
}

3. SettingsPage.qml

qml 复制代码
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root
    color: "#e8f0ff"

    signal openPage(string pageName)

    Label {
        anchors.centerIn: parent
        text: "Settings Page"
        font.pixelSize: 36
    }

    QuickButton {
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.margins: 24
        buttonText: "返回"
        normalColor: "#4f4f4f"
        pressedColor: "#222222"

        onClicked: root.openPage("home")
    }
}

4. NetworkPage.qml

qml 复制代码
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root
    color: "#e9fff2"

    signal openPage(string pageName)

    Label {
        anchors.centerIn: parent
        text: "Network Page"
        font.pixelSize: 36
    }

    QuickButton {
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.margins: 24
        buttonText: "返回"
        normalColor: "#4f4f4f"
        pressedColor: "#222222"

        onClicked: root.openPage("home")
    }
}

Loader 的关键记忆

Loader 管加载,子页面管发信号,主页面管切换。

也就是:

text 复制代码
子页面点击按钮 -> emit signal -> Main.qml Connections 接收 -> 修改 Loader.source

十、HarmonyUI 综合示例

1. 综合示例用到了什么

HarmonyUI 示例的重点不是完全复刻鸿蒙系统,而是把前面知识串起来:

  • Image 做背景和图标。
  • Label 显示时间、日期、文字。
  • Timer 每秒更新时间。
  • Grid 排列应用图标。
  • QuickButton 封装应用入口。
  • SwipeArea 处理上下滑动。
  • Loader 切换页面。
  • Connections 接收页面信号。

2. 项目建议文件结构

text 复制代码
HarmonyUI/
├─ Main.qml
├─ Home.qml
├─ Wechat.qml
├─ QuickButton.qml
├─ SwipeArea.qml
└─ images/
   ├─ background.jpg
   ├─ wechat.png
   ├─ settings.png
   └─ browser.png

如果暂时没有图片,也可以先用纯色矩形代替,先把逻辑跑通。

十一、Demo 6:简化版 HarmonyUI 主入口

1. Main.qml

qml 复制代码
import QtQuick

Window {
    width: 480
    height: 800
    visible: true
    title: "HarmonyUI Demo"

    Loader {
        id: pageLoader
        anchors.fill: parent
        source: "Home.qml"
    }

    Connections {
        target: pageLoader.item

        function onOpenPage(pageName) {
            if (pageName === "wechat") {
                pageLoader.source = "Wechat.qml"
            } else if (pageName === "home") {
                pageLoader.source = "Home.qml"
            }
        }
    }
}

2. Home.qml

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

Rectangle {
    id: root
    color: "#dce9f7"

    signal openPage(string pageName)

    property string currentTime: ""
    property string currentDate: ""

    function updateDateTime() {
        var now = new Date()
        currentTime = Qt.formatTime(now, "hh:mm")
        currentDate = Qt.formatDate(now, "yyyy年MM月dd日")
    }

    Component.onCompleted: updateDateTime()

    Timer {
        interval: 1000
        repeat: true
        running: true
        onTriggered: root.updateDateTime()
    }

    // 顶部状态栏。
    Row {
        anchors.top: parent.top
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.margins: 14
        spacing: 12

        Label {
            text: "5G"
            font.pixelSize: 16
            font.bold: true
        }

        Label {
            text: "WiFi"
            font.pixelSize: 16
        }

        Item {
            // 占位 Item,把电量文字推到右侧。
            width: 1
            height: 1
            Layout.fillWidth: true
        }

        Label {
            text: "100%"
            font.pixelSize: 16
        }
    }

    // 时间日期区域。
    Column {
        anchors.top: parent.top
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.topMargin: 90
        spacing: 8

        Label {
            text: root.currentTime
            font.pixelSize: 56
            font.bold: true
            anchors.horizontalCenter: parent.horizontalCenter
        }

        Label {
            text: root.currentDate
            font.pixelSize: 18
            color: "#334155"
            anchors.horizontalCenter: parent.horizontalCenter
        }

        Label {
            text: "晴 26°C"
            font.pixelSize: 18
            color: "#334155"
            anchors.horizontalCenter: parent.horizontalCenter
        }
    }

    // APP 图标区域。
    Grid {
        id: appGrid
        columns: 4
        rowSpacing: 26
        columnSpacing: 28
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: parent.top
        anchors.topMargin: 280

        QuickButton {
            width: 76
            height: 76
            radius: 22
            buttonText: "微信"
            fontPixelSize: 16
            normalColor: "#22c55e"
            pressedColor: "#16a34a"
            onClicked: root.openPage("wechat")
        }

        QuickButton {
            width: 76
            height: 76
            radius: 22
            buttonText: "设置"
            fontPixelSize: 16
            normalColor: "#64748b"
            pressedColor: "#475569"
        }

        QuickButton {
            width: 76
            height: 76
            radius: 22
            buttonText: "浏览"
            fontPixelSize: 16
            normalColor: "#0ea5e9"
            pressedColor: "#0284c7"
        }

        QuickButton {
            width: 76
            height: 76
            radius: 22
            buttonText: "相册"
            fontPixelSize: 16
            normalColor: "#f97316"
            pressedColor: "#ea580c"
        }
    }

    Rectangle {
        id: controlPanel
        width: parent.width
        height: 280
        y: -height
        color: "#111827"
        radius: 24

        Behavior on y {
            NumberAnimation {
                duration: 220
                easing.type: Easing.OutCubic
            }
        }

        Label {
            anchors.centerIn: parent
            text: "控制中心"
            color: "white"
            font.pixelSize: 32
            font.bold: true
        }
    }

    SwipeArea {
        anchors.fill: parent
        threshold: 70

        onSwipe: function(direction) {
            if (direction === "down") {
                controlPanel.y = 0
            } else if (direction === "up") {
                controlPanel.y = -controlPanel.height
            }
        }
    }
}

3. Wechat.qml

qml 复制代码
import QtQuick
import QtQuick.Controls

Rectangle {
    id: root
    color: "#f8fafc"

    signal openPage(string pageName)

    Label {
        anchors.top: parent.top
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.topMargin: 110
        text: "微信登录"
        font.pixelSize: 42
        font.bold: true
    }

    Column {
        anchors.centerIn: parent
        spacing: 18

        Rectangle {
            width: 300
            height: 52
            radius: 8
            color: "white"
            border.color: "#cbd5e1"

            Label {
                anchors.centerIn: parent
                text: "请输入账号"
                color: "#94a3b8"
                font.pixelSize: 18
            }
        }

        Rectangle {
            width: 300
            height: 52
            radius: 8
            color: "white"
            border.color: "#cbd5e1"

            Label {
                anchors.centerIn: parent
                text: "请输入密码"
                color: "#94a3b8"
                font.pixelSize: 18
            }
        }

        QuickButton {
            width: 300
            height: 52
            buttonText: "登录"
            normalColor: "#22c55e"
            pressedColor: "#16a34a"
        }
    }

    QuickButton {
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.margins: 24
        width: 92
        height: 44
        buttonText: "返回"
        normalColor: "#475569"
        pressedColor: "#334155"
        fontPixelSize: 18

        onClicked: root.openPage("home")
    }
}

十二、常见问题整理

1. 为什么自定义组件里的信号外部收不到

检查组件里是否定义了信号:

qml 复制代码
signal openPage(string pageName)

然后确认外部是否用 Connections 正确连接:

qml 复制代码
Connections {
    target: pageLoader.item

    function onOpenPage(pageName) {
        console.log(pageName)
    }
}

信号名是 openPage,处理函数就要写成 onOpenPage

2. 为什么 Loader 切换页面没有反应

常见原因:

  1. source 文件名写错。
  2. QML 文件没有加入 CMakeLists.txtQML_FILES
  3. 子页面没有发信号。
  4. Connections.target 没有指向 pageLoader.item

3. 为什么 SwipeArea 滑动太灵敏

调大阈值:

qml 复制代码
threshold: 80

阈值越大,需要滑动更远才触发,误触更少。

4. 为什么 QuickButton 点击后颜色不恢复

检查 onReleased 里有没有恢复颜色:

qml 复制代码
onReleased: {
    root.color = root.normalColor
}

如果鼠标按下后移出按钮再释放,可能需要进一步处理 onCanceled

十三、复习路线

建议按这个顺序复习:

  1. 先敲 QuickButton.qml,理解属性封装和信号暴露。
  2. 再敲 SwipeArea.qml,理解按下、移动、释放的事件流程。
  3. 然后敲 Loader 三页面切换示例,理解 source + Connections
  4. 最后敲简化版 HarmonyUI,把前面组件组合起来。

这节课最重要的一句话是:Qt Quick 的基础控件只是积木,真正做项目时要学会把积木封装成自己的组件。