From 85ee81cfa34e35992cb14f00a4ea1ae0a2168983 Mon Sep 17 00:00:00 2001 From: "B.Melnik" Date: Wed, 28 Jul 2021 14:08:56 +0300 Subject: [PATCH] feat(StatusChatList): Add drag and drop support of list items This implements drag and drop capabilities of chat items within a `StatusChatList`. The commit introduces a `DelegateModal` to visually reorder chat items when they're being dragged and dropped onto a `DropArea`. To persist the new order of chat items, various signals have been introduced to chat list related components: ```qml StatusChatList { onChatItemReordered: function (id, from, to) { // ... } } StatusChatListAndCategories { onChatItemReordered: function (categoryId, chatId, from, to) { // ... } } ``` There's no such API on the `StatusChatListCategory` type because that one already exposes its underlying `StatusChatList` via `chatList`, which makes the signal available. Dragging and dropping chat items is disabled by default and needs to be turned on using the `draggableItems` property: ```qml StatusChatList { draggableItems: true ... } ``` --- ui/StatusQ/sandbox/DemoApp.qml | 1 + ui/StatusQ/sandbox/Models.qml | 10 + .../src/StatusQ/Components/StatusChatList.qml | 246 +++++++++++++----- .../StatusChatListAndCategories.qml | 9 +- .../StatusQ/Components/StatusChatListItem.qml | 84 +++--- 5 files changed, 254 insertions(+), 96 deletions(-) diff --git a/ui/StatusQ/sandbox/DemoApp.qml b/ui/StatusQ/sandbox/DemoApp.qml index 3b9345d327..8345f9b518 100644 --- a/ui/StatusQ/sandbox/DemoApp.qml +++ b/ui/StatusQ/sandbox/DemoApp.qml @@ -467,6 +467,7 @@ Rectangle { width: leftPanel.width height: implicitHeight > (leftPanel.height - 64) ? implicitHeight + 8 : leftPanel.height - 64 + draggableItems: true chatList.model: models.demoCommunityChatListItems categoryList.model: models.demoCommunityCategoryItems diff --git a/ui/StatusQ/sandbox/Models.qml b/ui/StatusQ/sandbox/Models.qml index ecf8e91107..0010fc0b59 100644 --- a/ui/StatusQ/sandbox/Models.qml +++ b/ui/StatusQ/sandbox/Models.qml @@ -13,6 +13,7 @@ QtObject { unreadMessagesCount: 0 mentionsCount: 0 color: "blue" + position: 0 } ListElement { chatId: "1" @@ -22,6 +23,7 @@ QtObject { color: "red" unreadMessagesCount: 1 mentionsCount: 1 + position: 1 } ListElement { chatId: "2" @@ -32,6 +34,7 @@ QtObject { identicon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAAlklEQVR4nOzW0QmDQBAG4SSkl7SUQlJGCrElq9F3QdjjVhh/5nv3cFhY9vUIYQiNITSG0Bh CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2ImYgiNITTlTdG1nUZ5a92VITQxITFiJmIIjSE0htAYQrMHAAD//+wwFVpz+yqXAAAAAElFTkSuQmCC" unreadMessagesCount: 0 + position: 2 } ListElement { chatId: "3" @@ -40,6 +43,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I muted: false color: "purple" unreadMessagesCount: 0 + position: 3 } ListElement { chatId: "4" @@ -48,6 +52,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I muted: true color: "Orange" unreadMessagesCount: 0 + position: 4 } ListElement { chatId: "5" @@ -56,6 +61,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I muted: false color: "green" unreadMessagesCount: 0 + position: 5 } } @@ -68,6 +74,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I muted: false unreadMessagesCount: 0 color: "orange" + position: 0 } ListElement { chatId: "1" @@ -77,6 +84,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I unreadMessagesCount: 0 color: "orange" categoryId: "public" + position: 0 } ListElement { chatId: "2" @@ -86,6 +94,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I unreadMessagesCount: 0 color: "orange" categoryId: "public" + position: 1 } ListElement { chatId: "3" @@ -95,6 +104,7 @@ CExPynn1gWf9bx498P7/nzPcxEzGExhBdJGYihtAYQlO+tUZvqrPbqeudo5iJGEJjCE15a3VtodH3q2I unreadMessagesCount: 0 color: "orange" categoryId: "dev" + position: 0 } } diff --git a/ui/StatusQ/src/StatusQ/Components/StatusChatList.qml b/ui/StatusQ/src/StatusQ/Components/StatusChatList.qml index c147ed4beb..eba488732e 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusChatList.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusChatList.qml @@ -1,4 +1,6 @@ import QtQuick 2.13 +import QtQml.Models 2.14 +import QtQuick.Controls 2.13 as QC import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 @@ -13,7 +15,8 @@ Column { property string categoryId: "" property string selectedChatId: "" - property alias chatListItems: statusChatListItems + property alias chatListItems: delegateModel + property bool draggableItems: false property Component popupMenu @@ -23,6 +26,19 @@ Column { signal chatItemSelected(string id) signal chatItemUnmuted(string id) + signal chatItemReordered(string id, int from, int to) + + function getAbsolutePosition(node) { + var returnPos = {}; + returnPos.x = 0; + returnPos.y = 0; + if (node !== undefined && node !== null) { + var parentValue = getAbsolutePosition(node.parent); + returnPos.x = parentValue.x + node.x; + returnPos.y = parentValue.y + node.y; + } + return returnPos; + } onPopupMenuChanged: { if (!!popupMenu) { @@ -30,75 +46,189 @@ Column { } } - Repeater { - id: statusChatListItems - delegate: StatusChatListItem { + DelegateModel { + id: delegateModel - id: statusChatListItem + delegate: Item { + id: draggable + width: statusChatListItem.width + height: statusChatListItem.height - property string profileImage: "" + property alias chatListItem: statusChatListItem - Component.onCompleted: { - if (typeof statusChatList.profileImageFn === "function") { - profileImage = statusChatList.profileImageFn(model.chatId || model.id) || "" - } - } - - chatId: model.chatId || model.id - name: !!statusChatList.chatNameFn ? statusChatList.chatNameFn(model) : model.name - type: model.chatType - muted: !!model.muted - hasUnreadMessages: !!model.hasUnreadMessages || model.unviewedMessagesCount > 0 - hasMention: model.mentionsCount > 0 - badge.value: model.chatType === StatusChatListItem.Type.OneToOneChat ? - model.unviewedMessagesCount || 0 : - model.mentionsCount || 0 - selected: (model.chatId || model.id) === statusChatList.selectedChatId - - icon.color: model.color || "" - image.isIdenticon: !!!profileImage && !!!model.identityImage && !!model.identicon - image.source: profileImage || model.identityImage || model.identicon || "" - - onClicked: { - if (mouse.button === Qt.RightButton && !!statusChatList.popupMenu) { - statusChatListItem.highlighted = true - - let originalOpenHandler = popupMenuSlot.item.openHandler - let originalCloseHandler = popupMenuSlot.item.closeHandler - - popupMenuSlot.item.openHandler = function () { - if (!!originalOpenHandler) { - originalOpenHandler((model.chatId || model.id)) - } - } - - popupMenuSlot.item.closeHandler = function () { - if (statusChatListItem) { - statusChatListItem.highlighted = false - } - if (!!originalCloseHandler) { - originalCloseHandler() - } - } - - popupMenuSlot.item.popup(mouse.x + 4, statusChatListItem.y + mouse.y + 6) - popupMenuSlot.item.openHandler = originalOpenHandler - return - } - if (!statusChatListItem.selected) { - statusChatList.chatItemSelected(model.chatId || model.id) - } - } - onUnmute: statusChatList.chatItemUnmuted(model.chatId || model.id) visible: { if (!!statusChatList.filterFn) { return statusChatList.filterFn(model, statusChatList.categoryId) } return true } + + MouseArea { + id: dragSensor + + anchors.fill: parent + cursorShape: active ? Qt.ClosedHandCursor : Qt.PointingHandCursor + hoverEnabled: true + pressAndHoldInterval: 150 + enabled: statusChatList.draggableItems + + property bool active: false + property real startY: 0 + property real startX: 0 + + drag.target: draggedListItemLoader.item + drag.threshold: 0.1 + drag.filterChildren: true + + onPressed: { + startY = mouseY + startX = mouseX + } + onPressAndHold: active = true + onReleased: { + if (active) { + statusChatList.chatItemReordered(statusChatListItem.chatId, statusChatListItem.originalOrder, statusChatListItem.originalOrder) + } + active = false + } + onMouseYChanged: { + if ((Math.abs(startY - mouseY) > 1) && pressed) { + active = true + } + } + onMouseXChanged: { + if ((Math.abs(startX - mouseX) > 1) && pressed) { + active = true + } + } + + StatusChatListItem { + + id: statusChatListItem + + property string profileImage: "" + + opacity: dragSensor.active ? 0.0 : 1.0 + Component.onCompleted: { + if (typeof statusChatList.profileImageFn === "function") { + profileImage = statusChatList.profileImageFn(model.chatId || model.id) || "" + } + } + originalOrder: model.position + chatId: model.chatId || model.id + categoryId: model.categoryId || "" + name: !!statusChatList.chatNameFn ? statusChatList.chatNameFn(model) : model.name + type: model.chatType + muted: !!model.muted + hasUnreadMessages: !!model.hasUnreadMessages || model.unviewedMessagesCount > 0 + hasMention: model.mentionsCount > 0 + badge.value: model.chatType === StatusChatListItem.Type.OneToOneChat ? + model.unviewedMessagesCount || 0 : + model.mentionsCount || 0 + selected: (model.chatId || model.id) === statusChatList.selectedChatId + + icon.color: model.color || "" + image.isIdenticon: !!!profileImage && !!!model.identityImage && !!model.identicon + image.source: profileImage || model.identityImage || model.identicon || "" + + sensor.cursorShape: dragSensor.cursorShape + onClicked: { + if (mouse.button === Qt.RightButton && !!statusChatList.popupMenu) { + statusChatListItem.highlighted = true + + let originalOpenHandler = popupMenuSlot.item.openHandler + let originalCloseHandler = popupMenuSlot.item.closeHandler + + popupMenuSlot.item.openHandler = function () { + if (!!originalOpenHandler) { + originalOpenHandler((model.chatId || model.id)) + } + } + + popupMenuSlot.item.closeHandler = function () { + if (statusChatListItem) { + statusChatListItem.highlighted = false + } + if (!!originalCloseHandler) { + originalCloseHandler() + } + } + + popupMenuSlot.item.popup(mouse.x + 4, statusChatListItem.y + mouse.y + 6) + popupMenuSlot.item.openHandler = originalOpenHandler + return + } + if (!statusChatListItem.selected) { + statusChatList.chatItemSelected(model.chatId || model.id) + } + } + onUnmute: statusChatList.chatItemUnmuted(model.chatId || model.id) + } + } + + DropArea { + id: dropArea + width: dragSensor.active ? 0 : parent.width + height: dragSensor.active ? 0 : parent.height + keys: ["chat-item-category-" + statusChatListItem.categoryId] + + onEntered: reorderDelay.start() + onDropped: statusChatList.chatItemReordered(statusChatListItem.chatId, drag.source.originalOrder, statusChatListItem.DelegateModel.itemsIndex) + + Timer { + id: reorderDelay + interval: 100 + repeat: false + onTriggered: { + if (dropArea.containsDrag) { + dropArea.drag.source.chatListItem.originalOrder = statusChatListItem.originalOrder + delegateModel.items.move(dropArea.drag.source.DelegateModel.itemsIndex, draggable.DelegateModel.itemsIndex) + } + } + } + } + + Loader { + id: draggedListItemLoader + active: dragSensor.active + sourceComponent: StatusChatListItem { + property var globalPosition: statusChatList.getAbsolutePosition(draggable) + parent: QC.Overlay.overlay + sensor.cursorShape: dragSensor.cursorShape + Drag.active: dragSensor.active + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + Drag.keys: ["chat-item-category-" + categoryId] + Drag.source: draggable + + Component.onCompleted: { + x = globalPosition.x + y = globalPosition.y + } + chatId: draggable.chatListItem.chatId + categoryId: draggable.chatListItem.categoryId + name: draggable.chatListItem.name + type: draggable.chatListItem.type + muted: draggable.chatListItem.muted + dragged: true + hasUnreadMessages: draggable.chatListItem.hasUnreadMessages + hasMention: draggable.chatListItem.hasMention + badge.value: draggable.chatListItem.badge.value + selected: draggable.chatListItem.selected + + icon.color: draggable.chatListItem.icon.color + image.isIdenticon: draggable.chatListItem.image.isIdenticon + image.source: draggable.chatListItem.image.source + } + } } } + Repeater { + id: statusChatListItems + model: delegateModel + } + Loader { id: popupMenuSlot active: !!statusChatList.popupMenu diff --git a/ui/StatusQ/src/StatusQ/Components/StatusChatListAndCategories.qml b/ui/StatusQ/src/StatusQ/Components/StatusChatListAndCategories.qml index e48664d6df..ef32db5362 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusChatListAndCategories.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusChatListAndCategories.qml @@ -16,6 +16,7 @@ Item { property alias chatList: statusChatList.chatListItems property alias categoryList: statusChatListCategories property alias sensor: sensor + property bool draggableItems: false property Component categoryPopupMenu property Component chatListPopupMenu @@ -23,6 +24,7 @@ Item { signal chatItemSelected(string id) signal chatItemUnmuted(string id) + signal chatItemReordered(string categoryId, string chatId, int from, int to) signal categoryAddButtonClicked(string id) onPopupMenuChanged: { @@ -54,14 +56,17 @@ Item { StatusChatList { id: statusChatList anchors.horizontalCenter: parent.horizontalCenter - visible: !!chatListItems.model && chatListItems.count > 0 + visible: chatListItems.count > 0 selectedChatId: statusChatListAndCategories.selectedChatId onChatItemSelected: statusChatListAndCategories.chatItemSelected(id) onChatItemUnmuted: statusChatListAndCategories.chatItemUnmuted(id) + onChatItemReordered: statusChatListAndCategories.chatItemReordered(categoryId, id, from, to) + draggableItems: statusChatListAndCategories.draggableItems filterFn: function (model) { return !!!model.categoryId } popupMenu: statusChatListAndCategories.chatListPopupMenu + } Repeater { @@ -78,6 +83,8 @@ Item { chatList.selectedChatId: statusChatListAndCategories.selectedChatId chatList.onChatItemSelected: statusChatListAndCategories.chatItemSelected(id) chatList.onChatItemUnmuted: statusChatListAndCategories.chatItemUnmuted(id) + chatList.onChatItemReordered: statusChatListAndCategories.chatItemReordered(model.categoryId, id, from, to) + chatList.draggableItems: statusChatListAndCategories.draggableItems popupMenu: statusChatListAndCategories.categoryPopupMenu chatListPopupMenu: statusChatListAndCategories.chatListPopupMenu diff --git a/ui/StatusQ/src/StatusQ/Components/StatusChatListItem.qml b/ui/StatusQ/src/StatusQ/Components/StatusChatListItem.qml index 8bdf9dec0f..3d946a1dc2 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusChatListItem.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusChatListItem.qml @@ -1,4 +1,8 @@ import QtQuick 2.13 +import QtQml.Models 2.13 +import QtQuick.Controls 2.13 as QC +import QtGraphicalEffects 1.13 + import StatusQ.Core 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Components 0.1 @@ -7,7 +11,10 @@ import StatusQ.Controls 0.1 Rectangle { id: statusChatListItem + objectName: "chatItem" + property int originalOrder: -1 property string chatId: "" + property string categoryId: "" property string name: "" property alias badge: statusBadge property bool hasUnreadMessages: false @@ -20,6 +27,8 @@ Rectangle { property int type: StatusChatListItem.Type.PublicChat property bool highlighted: false property bool selected: false + property bool dragged: false + property alias sensor: sensor signal clicked(var mouse) signal unmute() @@ -46,11 +55,13 @@ Rectangle { return sensor.containsMouse || highlighted ? Theme.palette.statusChatListItem.hoverBackgroundColor : Theme.palette.baseColor4 } + opacity: dragged ? 0.7 : 1 + MouseArea { id: sensor anchors.fill: parent - cursorShape: Qt.PointingHandCursor + cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton hoverEnabled: true @@ -63,7 +74,7 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter sourceComponent: !!statusChatListItem.image.source.toString() ? - statusRoundedImageCmp : statusLetterIdenticonCmp + statusRoundedImageCmp : statusLetterIdenticonCmp } Component { @@ -89,8 +100,8 @@ Rectangle { image.source: statusChatListItem.image.source showLoadingIndicator: true color: statusChatListItem.image.isIdenticon ? - Theme.palette.statusRoundedImage.backgroundColor : - "transparent" + Theme.palette.statusRoundedImage.backgroundColor : + "transparent" border.width: statusChatListItem.image.isIdenticon ? 1 : 0 border.color: Theme.palette.directColor7 } @@ -114,27 +125,27 @@ Rectangle { if (statusChatListItem.muted && !sensor.containsMouse && !statusChatListItem.highlighted) { return 0.4 } - return statusChatListItem.hasMention || - statusChatListItem.hasUnreadMessages || - statusChatListItem.selected || - statusChatListItem.highlighted || - statusBadge.visible || - sensor.containsMouse ? 1.0 : 0.7 + return statusChatListItem.hasMention || + statusChatListItem.hasUnreadMessages || + statusChatListItem.selected || + statusChatListItem.highlighted || + statusBadge.visible || + sensor.containsMouse ? 1.0 : 0.7 } icon: { switch (statusChatListItem.type) { - case StatusChatListItem.Type.PublicCat: - return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white" - break; - case StatusChatListItem.Type.GroupChat: - return Theme.palette.name == "light" ? "tiny/group" : "tiny/group-white" - break; - case StatusChatListItem.Type.CommunityChat: - return Theme.palette.name == "light" ? "tiny/channel" : "tiny/channel-white" - break; - default: - return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white" + case StatusChatListItem.Type.PublicCat: + return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white" + break; + case StatusChatListItem.Type.GroupChat: + return Theme.palette.name == "light" ? "tiny/group" : "tiny/group-white" + break; + case StatusChatListItem.Type.CommunityChat: + return Theme.palette.name == "light" ? "tiny/channel" : "tiny/channel-white" + break; + default: + return Theme.palette.name == "light" ? "tiny/public-chat" : "tiny/public-chat-white" } } } @@ -144,30 +155,30 @@ Rectangle { anchors.left: statusIcon.visible ? statusIcon.right : identicon.right anchors.leftMargin: statusIcon.visible ? 1 : 8 anchors.right: mutedIcon.visible ? mutedIcon.left : - statusBadge.visible ? statusBadge.left : parent.right + statusBadge.visible ? statusBadge.left : parent.right anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter - text: statusChatListItem.type === StatusChatListItem.Type.PublicChat && - !statusChatListItem.name.startsWith("#") ? - "#" + statusChatListItem.name : - statusChatListItem.name + text: (statusChatListItem.type === StatusChatListItem.Type.PublicChat && + !statusChatListItem.name.startsWith("#") ? + "#" + statusChatListItem.name : + statusChatListItem.name) elide: Text.ElideRight color: { if (statusChatListItem.muted && !sensor.containsMouse && !statusChatListItem.highlighted) { return Theme.palette.directColor5 } - return statusChatListItem.hasMention || - statusChatListItem.hasUnreadMessages || - statusChatListItem.selected || - statusChatListItem.highlighted || - sensor.containsMouse || - statusBadge.visible ? Theme.palette.directColor1 : Theme.palette.directColor4 + return statusChatListItem.hasMention || + statusChatListItem.hasUnreadMessages || + statusChatListItem.selected || + statusChatListItem.highlighted || + sensor.containsMouse || + statusBadge.visible ? Theme.palette.directColor1 : Theme.palette.directColor4 } font.weight: !statusChatListItem.muted && - (statusChatListItem.hasMention || - statusChatListItem.hasUnreadMessages || - statusBadge.visible) ? Font.Bold : Font.Medium + (statusChatListItem.hasMention || + statusChatListItem.hasUnreadMessages || + statusBadge.visible) ? Font.Bold : Font.Medium font.pixelSize: 15 } @@ -184,7 +195,7 @@ Rectangle { MouseArea { id: mutedIconSensor hoverEnabled: true - cursorShape: Qt.PointingHandCursor + cursorShape: Qt.PointingHandCursor anchors.fill: parent onClicked: statusChatListItem.unmute() } @@ -207,6 +218,5 @@ Rectangle { border.color: color visible: statusBadge.value > 0 } - } }