/* * SPDX-FileCopyrightText: 2019 Marco Martin * * SPDX-License-Identifier: LGPL-2.0-or-later */ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts import QtQuick.Templates as T import org.kde.kirigami.platform as Platform import org.kde.kirigami.primitives as Primitives import org.kde.kirigami.controls as KC import "private" /*! \qmltype SwipeListItem \inqmlmodule org.kde.kirigami An item delegate intended to support extra actions obtainable by uncovering them by dragging away the item with the handle. This acts as a container for normal list items. Example usage: \code ListView { model: myModel delegate: SwipeListItem { QQC2.Label { text: model.text } actions: [ Action { icon.name: "document-decrypt" onTriggered: print("Action 1 clicked") }, Action { icon.name: model.action2Icon onTriggered: //do something } ] } } \endcode */ QQC2.SwipeDelegate { id: listItem //BEGIN properties /*! \qmlproperty bool SwipeListItem::supportsMouseEvents \brief This property sets whether the item should emit signals related to mouse interaction. \default true \deprecated Use hoverEnabled instead. */ property alias supportsMouseEvents: listItem.hoverEnabled /*! \qmlproperty bool SwipeListItem::containsMouse \brief This property tells whether the cursor is currently hovering over the item. On mobile touch devices, this will be true only when pressed. \sa ItemDelegate::hovered \deprecated use the hovered property instead. */ readonly property alias containsMouse: listItem.hovered /*! \brief This property sets whether instances of this list item will alternate between two colors, helping readability. It is suggested to use this only when implementing a view with multiple columns. \default false */ property bool alternatingBackground: false /*! \brief This property sets whether this item is a section delegate. Setting this to true will make the list item look like a "title" for items under it. \default false \sa ListSectionHeader */ property bool sectionDelegate: false /*! \brief This property sets whether the separator is visible. The separator is a line between this and the item under it. \default false */ property bool separatorVisible: false /*! \brief This property holds the background color of the list item. It is advised to use the default value. \default Kirigami.Theme.backgroundColor */ property color backgroundColor: Platform.Theme.backgroundColor /*! \brief This property holds the background color to be used when background alternating is enabled. It is advised to use the default value. \default: Kirigami.Theme.alternateBackgroundColor */ property color alternateBackgroundColor: Platform.Theme.alternateBackgroundColor /*! \brief This property holds the color of the background when the item is pressed or selected. It is advised to use the default value. \default Kirigami.Theme.highlightColor */ property color activeBackgroundColor: Platform.Theme.highlightColor /*! \brief This property holds the color of the text in the item. It is advised to use the default value. \default Theme.textColor If custom text elements are inserted in a SwipeListItem, their color will have to be manually set with this property. */ property color textColor: Platform.Theme.textColor /*! \brief This property holds the color of the text when the item is pressed or selected. It is advised to use the default value. default: Kirigami.Theme.highlightedTextColor If custom text elements are inserted in a SwipeListItem, their color property will have to be manually bound with this property */ property color activeTextColor: Platform.Theme.highlightedTextColor /*! \brief This property tells whether actions are visible and interactive. True if it's possible to see and interact with the item's actions. Actions become hidden while editing of an item, for example. */ readonly property bool actionsVisible: actionsLayout.hasVisibleActions /*! \brief This property sets whether actions behind this SwipeListItem will always be visible. default: true in desktop and tablet mode */ property bool alwaysVisibleActions: !Platform.Settings.isMobile /*! \brief This property holds actions of the list item. At most 4 actions can be revealed when sliding away the list item; others will be shown in the overflow menu. */ property list actions /*! \qmlproperty real SwipeListItem::overlayWidth \brief This property holds the width of the overlay. The value can represent the width of the handle component or the action layout. */ readonly property alias overlayWidth: overlayLoader.width //END properties property ListView listView; LayoutMirroring.childrenInherit: true hoverEnabled: true implicitHeight: Math.max(actionsLayout.implicitHeight, implicitContentHeight) + topPadding + bottomPadding implicitWidth: Math.max(actionsLayout.implicitWidth, implicitContentWidth) + leftPadding + rightPadding width: parent ? parent.width : implicitWidth Keys.onTabPressed: (event) => { if (actionsLayout.hasVisibleActions) { actionsLayout.children[0].tabbedFromDelegate = true actionsLayout.children[0].forceActiveFocus(Qt.TabFocusReason) } else { event.accepted = false } } Keys.onPressed: (event) => { if ((actionsLayout.hasVisibleActions && activeFocus && event.key == Qt.Key_Right && Application.layoutDirection == Qt.LeftToRight) || (actionsLayout.hasVisibleActions && activeFocus && event.key == Qt.Key_Left && Application.layoutDirection == Qt.RightToLeft)) { for (var target = 0; target < actionsRep.count; target ++) { if (actionsLayout.children[target].visible) { break } } if (target < actionsRep.count) { actionsLayout.children[target].forceActiveFocus(Qt.TabFocusReason) event.accepted = true } } } QtObject { id: internal property Flickable view: listItem.ListView.view || (listItem.parent ? (listItem.parent.ListView.view || (listItem.parent instanceof Flickable ? listItem.parent : null)) : null) as Flickable function viewHasPropertySwipeFilter(): bool { return view && view.parent && view.parent.parent && "_swipeFilter" in view.parent.parent; } readonly property QtObject swipeFilterItem: (viewHasPropertySwipeFilter() && view.parent.parent._swipeFilter) ? view.parent.parent._swipeFilter : null readonly property bool edgeEnabled: swipeFilterItem ? swipeFilterItem.currentItem === listItem || swipeFilterItem.currentItem === listItem.parent : false // install the SwipeItemEventFilter onViewChanged: { if (listItem.alwaysVisibleActions || !Platform.Settings.tabletMode) { return; } if (viewHasPropertySwipeFilter() && Platform.Settings.tabletMode && !internal.view.parent.parent._swipeFilter) { const component = Qt.createComponent(Qt.resolvedUrl("../private/SwipeItemEventFilter.qml")); internal.view.parent.parent._swipeFilter = component.createObject(internal.view.parent.parent); component.destroy(); } } } Connections { target: Platform.Settings function onTabletModeChanged() { if (!internal.viewHasPropertySwipeFilter()) { return; } if (Platform.Settings.tabletMode) { if (!internal.swipeFilterItem) { const component = Qt.createComponent(Qt.resolvedUrl("../private/SwipeItemEventFilter.qml")); listItem.ListView.view.parent.parent._swipeFilter = component.createObject(listItem.ListView.view.parent.parent); component.destroy(); } } else { if (listItem.ListView.view.parent.parent._swipeFilter) { listItem.ListView.view.parent.parent._swipeFilter.destroy(); slideAnim.to = 0; slideAnim.restart(); } } } } //BEGIN items Loader { id: overlayLoader readonly property int paddingOffset: (visible ? width : 0) + Platform.Units.smallSpacing readonly property var theAlias: anchors states: State { name: "reanchored" AnchorChanges { target: overlayLoader anchors.right: listItem.mirrored ? undefined : listItem.contentItem.right anchors.left: listItem.mirrored ? listItem.contentItem.left : undefined anchors.top: parent.top anchors.bottom: parent.bottom } PropertyChanges { overlayLoader.anchors.topMargin: listItem.topPadding overlayLoader.anchors.bottomMargin: listItem.bottomPadding } } Component.onCompleted: overlayLoader.state = "reanchored" LayoutMirroring.enabled: false parent: listItem z: listItem.contentItem ? listItem.contentItem.z + 1 : 0 width: item ? item.implicitWidth : actionsLayout.implicitWidth active: !listItem.alwaysVisibleActions && Platform.Settings.tabletMode visible: listItem.actionsVisible && opacity > 0 asynchronous: true sourceComponent: handleComponent opacity: listItem.alwaysVisibleActions || Platform.Settings.tabletMode || listItem.hovered ? 1 : 0 Behavior on opacity { OpacityAnimator { id: opacityAnim duration: Platform.Units.veryShortDuration easing.type: Easing.InOutQuad } } } Component { id: handleComponent MouseArea { id: dragButton implicitWidth: Platform.Units.iconSizes.smallMedium preventStealing: true readonly property real openPosition: (listItem.width - width - listItem.leftPadding * 2)/listItem.width property real startX: 0 property real lastPosition: 0 property bool openIntention onPressed: mouse => { startX = mapToItem(listItem, 0, 0).x; } onClicked: mouse => { if (Math.abs(mapToItem(listItem, 0, 0).x - startX) > Application.styleHints.startDragDistance) { return; } if (listItem.mirrored) { if (listItem.swipe.position < 0.5) { slideAnim.to = openPosition } else { slideAnim.to = 0 } } else { if (listItem.swipe.position > -0.5) { slideAnim.to = -openPosition } else { slideAnim.to = 0 } } slideAnim.restart(); } onPositionChanged: mouse => { const pos = mapToItem(listItem, mouse.x, mouse.y); if (listItem.mirrored) { listItem.swipe.position = Math.max(0, Math.min(openPosition, (pos.x / listItem.width))); openIntention = listItem.swipe.position > lastPosition; } else { listItem.swipe.position = Math.min(0, Math.max(-openPosition, (pos.x / (listItem.width - listItem.rightPadding) - 1))); openIntention = listItem.swipe.position < lastPosition; } lastPosition = listItem.swipe.position; } onReleased: mouse => { if (listItem.mirrored) { if (openIntention) { slideAnim.to = openPosition } else { slideAnim.to = 0 } } else { if (openIntention) { slideAnim.to = -openPosition } else { slideAnim.to = 0 } } slideAnim.restart(); } Primitives.Icon { id: handleIcon anchors.fill: parent selected: listItem.checked || (listItem.down && !listItem.checked && !listItem.sectionDelegate) source: (listItem.mirrored ? (listItem.background.x < listItem.background.width/2 ? "overflow-menu-right" : "overflow-menu-left") : (listItem.background.x < -listItem.background.width/2 ? "overflow-menu-right" : "overflow-menu-left")) } Connections { id: swipeFilterConnection target: internal.edgeEnabled ? internal.swipeFilterItem : null function onPeekChanged() { if (!listItem.actionsVisible) { return; } if (listItem.mirrored) { listItem.swipe.position = Math.max(0, Math.min(dragButton.openPosition, internal.swipeFilterItem.peek)); dragButton.openIntention = listItem.swipe.position > dragButton.lastPosition; } else { listItem.swipe.position = Math.min(0, Math.max(-dragButton.openPosition, -internal.swipeFilterItem.peek)); dragButton.openIntention = listItem.swipe.position < dragButton.lastPosition; } dragButton.lastPosition = listItem.swipe.position; } function onPressed(mouse) { if (internal.edgeEnabled) { dragButton.pressed(mouse); } } function onClicked(mouse) { if (Math.abs(listItem.background.x) < Platform.Units.gridUnit && internal.edgeEnabled) { dragButton.clicked(mouse); } } function onReleased(mouse) { if (internal.edgeEnabled) { dragButton.released(mouse); } } function onCurrentItemChanged() { if (!internal.edgeEnabled) { slideAnim.to = 0; slideAnim.restart(); } } } } } // TODO: expose in API? Component { id: actionsBackgroundDelegate Item { anchors.fill: parent z: 1 readonly property Item contentItem: swipeBackground Rectangle { id: swipeBackground anchors { top: parent.top bottom: parent.bottom } clip: true color: parent.pressed ? Qt.darker(Platform.Theme.backgroundColor, 1.1) : Qt.darker(Platform.Theme.backgroundColor, 1.05) x: listItem.mirrored ? listItem.background.x - width : (listItem.background.x + listItem.background.width) width: listItem.mirrored ? parent.width - (parent.width - x) : parent.width - x TapHandler { onTapped: listItem.swipe.close() } EdgeShadow { edge: Qt.TopEdge visible: background.x != 0 anchors { right: parent.right left: parent.left top: parent.top } } EdgeShadow { edge: listItem.mirrored ? Qt.RightEdge : Qt.LeftEdge visible: background.x != 0 anchors { top: parent.top bottom: parent.bottom } } } visible: listItem.swipe.position != 0 } } RowLayout { id: actionsLayout LayoutMirroring.enabled: listItem.mirrored anchors { right: parent.right top: parent.top bottom: parent.bottom } visible: parent !== listItem parent: !listItem.alwaysVisibleActions && Platform.Settings.tabletMode ? listItem.swipe.leftItem?.contentItem || listItem.swipe.rightItem?.contentItem || listItem : overlayLoader property bool hasVisibleActions: false property int indexInListView: (typeof index !== 'undefined' && index !== null) ? index : -1 // might not be set if using required properties function updateVisibleActions(definitelyVisible: bool) { hasVisibleActions = definitelyVisible || listItem.actions.some(isActionVisible); } function isActionVisible(action: T.Action): bool { return (action as KC.Action)?.visible ?? true; } Repeater { id: actionsRep model: listItem.actions delegate: QQC2.ToolButton { required property T.Action modelData required property int index property bool tabbedFromDelegate: false action: modelData display: T.AbstractButton.IconOnly visible: { let newVisible = actionsLayout.isActionVisible(action) actionsLayout.updateVisibleActions(newVisible) return newVisible } Component.onCompleted: actionsLayout.updateVisibleActions(visible); Component.onDestruction: actionsLayout.updateVisibleActions(visible); QQC2.ToolTip.delay: Platform.Units.toolTipDelay QQC2.ToolTip.visible: (Platform.Settings.tabletMode ? pressed : hovered) && QQC2.ToolTip.text.length > 0 QQC2.ToolTip.text: (action as KC.Action)?.tooltip ?? action?.text ?? "" onClicked: { slideAnim.to = 0; slideAnim.restart(); } Keys.onBacktabPressed: (event) => { if (tabbedFromDelegate) { listItem.forceActiveFocus(Qt.BacktabFocusReason) } else { event.accepted = false } } Keys.onPressed: (event) => { if ((Application.layoutDirection == Qt.LeftToRight && event.key == Qt.Key_Left) || (Application.layoutDirection == Qt.RightToLeft && event.key == Qt.Key_Right)) { for (var target = index -1; target>=0; target--) { if (target == -1 || actionsLayout.children[target].visible) { break } } if (target == -1) { listItem.forceActiveFocus(Qt.BacktabFocusReason) } else { actionsLayout.children[target].tabbedFromDelegate = tabbedFromDelegate actionsLayout.children[target].forceActiveFocus(Qt.TabFocusReason) } event.accepted = true } else if ((Application.layoutDirection == Qt.LeftToRight && event.key == Qt.Key_Right) || (Application.layoutDirection == Qt.RightToLeft && event.key == Qt.Key_Left)) { var found=false for (var target = index +1; target { if (listItem.listView && actionsLayout.indexInListView >= 0) { listItem.listView.currentIndex = actionsLayout.indexInListView } event.accepted = false // pass to ListView } Keys.onDownPressed: (event) => { if (listItem.listView && actionsLayout.indexInListView >= 0) { listItem.listView.currentIndex = actionsLayout.indexInListView } event.accepted = false // pass to ListView } onActiveFocusChanged: { if (focus && listItem.listView) { listItem.listView.positionViewAtIndex(actionsLayout.indexInListView, ListView.Contain) } else if (!focus) { tabbedFromDelegate = false } } Accessible.name: text Accessible.description: (action as KC.Action)?.tooltip ?? "" } } } swipe { enabled: false right: listItem.alwaysVisibleActions || listItem.mirrored || !Platform.Settings.tabletMode ? null : actionsBackgroundDelegate left: listItem.alwaysVisibleActions || listItem.mirrored && Platform.Settings.tabletMode ? actionsBackgroundDelegate : null } NumberAnimation { id: slideAnim duration: Platform.Units.longDuration easing.type: Easing.InOutQuad target: listItem.swipe property: "position" from: listItem.swipe.position } //END items Component.onCompleted: { listView: { for (var targetItem = listItem; (targetItem.ListView.view === null); targetItem = targetItem.parent) { } listView = targetItem.ListView.view } } }