From c8be92c4ca12b53cec27c39977d2139bea3f0fe7 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 25 Jun 2026 15:06:28 +0800
Subject: [PATCH] Add remove tag shred preview
---
src/Apptag/AppGridCollectionView.swift | 11 ++
src/CHANGELOG.md | 4 +
src/TODO.md | 5 +
src/Apptag/AppDragCoordinator.swift | 175 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 194 insertions(+), 1 deletions(-)
diff --git a/src/Apptag/AppDragCoordinator.swift b/src/Apptag/AppDragCoordinator.swift
index 264706d..c7abcc8 100644
--- a/src/Apptag/AppDragCoordinator.swift
+++ b/src/Apptag/AppDragCoordinator.swift
@@ -21,6 +21,9 @@
private var dragLayer: CALayer?
private var normalDragImage: CGImage?
private var copyDragImage: CGImage?
+ private var shredPreviewActive = false
+ private var shredSliceLayers: [CALayer] = []
+ private var shredGuideLayers: [CALayer] = []
private var currentCopyMode = false
private var dragWindow: NSWindow?
private var dragImageSize: NSSize = .zero
@@ -131,6 +134,7 @@
dragLayer.position = contentPoint
CATransaction.commit()
updateHoverTarget(at: screenPoint)
+ updateRemoveTagPreview(at: screenPoint)
hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
return
}
@@ -145,6 +149,7 @@
)
dragWindow.setFrameOrigin(origin)
updateHoverTarget(at: screenPoint)
+ updateRemoveTagPreview(at: screenPoint)
hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
}
@@ -190,11 +195,17 @@
currentCopyMode = copy
CATransaction.begin()
CATransaction.setDisableActions(true)
- dragLayer?.contents = copy ? copyDragImage : normalDragImage
+ if shredPreviewActive {
+ let image = currentDragImage
+ shredSliceLayers.forEach { $0.contents = image }
+ } else {
+ dragLayer?.contents = currentDragImage
+ }
CATransaction.commit()
}
private func endDragVisuals() {
+ stopShredPreview()
hoveredTarget?.appDragHoverChanged(active: false)
hoveredTarget = nil
dragLayer?.removeFromSuperlayer()
@@ -210,6 +221,10 @@
dragImageSize = .zero
}
+ private var currentDragImage: CGImage? {
+ currentCopyMode ? copyDragImage : normalDragImage
+ }
+
private func pruneDeadTargets() {
targets = targets.filter { $0.value.view != nil }
emptyTargets = emptyTargets.filter { $0.value.view != nil }
@@ -223,6 +238,157 @@
hoveredTarget?.appDragHoverChanged(active: false)
nextTarget?.appDragHoverChanged(active: true)
hoveredTarget = nextTarget
+ }
+
+ private func updateRemoveTagPreview(at screenPoint: NSPoint) {
+ guard hasActiveDrag else {
+ setShredPreviewActive(false)
+ return
+ }
+ setShredPreviewActive(removeTagPreviewTarget(at: screenPoint) != nil)
+ }
+
+ private func removeTagPreviewTarget(at screenPoint: NSPoint) -> AppEmptyDropReceivingView? {
+ guard dropTarget(at: screenPoint) == nil else { return nil }
+ let parts = activePayload.components(separatedBy: "\n")
+ guard let path = parts.first, !path.isEmpty else { return nil }
+ let source = parts.dropFirst().first ?? ""
+
+ return emptyTargets.values
+ .compactMap { target -> (AppEmptyDropReceivingView, CGFloat)? in
+ guard let view = target.view,
+ let frame = view.screenFrame(),
+ frame.contains(screenPoint),
+ view.canPreviewEmptyDrop(
+ path: path,
+ source: source,
+ screenPoint: screenPoint,
+ copy: currentCopyMode
+ )
+ else { return nil }
+ return (view, frame.width * frame.height)
+ }
+ .sorted { $0.1 < $1.1 }
+ .first?.0
+ }
+
+ private func setShredPreviewActive(_ active: Bool) {
+ guard active != shredPreviewActive else { return }
+ if active {
+ startShredPreview()
+ } else {
+ stopShredPreview()
+ }
+ }
+
+ private func startShredPreview() {
+ guard let dragLayer,
+ let image = currentDragImage,
+ !shredPreviewActive,
+ dragImageSize.width > 0,
+ dragImageSize.height > 0
+ else { return }
+
+ shredPreviewActive = true
+ dragLayer.contents = nil
+ shredSliceLayers.removeAll()
+ shredGuideLayers.removeAll()
+
+ let sliceCount = 7
+ let sliceWidth = dragImageSize.width / CGFloat(sliceCount)
+ for index in 0..<sliceCount {
+ let x = CGFloat(index) * sliceWidth
+ let width = index == sliceCount - 1
+ ? dragImageSize.width - x
+ : ceil(sliceWidth)
+ let slice = CALayer()
+ slice.frame = CGRect(x: x, y: 0, width: width, height: dragImageSize.height)
+ slice.contents = image
+ slice.contentsGravity = .resize
+ slice.contentsScale = NSScreen.main?.backingScaleFactor ?? 2
+ slice.contentsRect = CGRect(
+ x: CGFloat(index) / CGFloat(sliceCount),
+ y: 0,
+ width: 1 / CGFloat(sliceCount),
+ height: 1
+ )
+ slice.actions = [
+ "position": NSNull(),
+ "opacity": NSNull(),
+ "transform": NSNull()
+ ]
+ dragLayer.addSublayer(slice)
+ addShredAnimation(to: slice, index: index)
+ shredSliceLayers.append(slice)
+
+ if index > 0 {
+ let guide = CALayer()
+ guide.frame = CGRect(
+ x: x - 0.5,
+ y: dragImageSize.height * 0.12,
+ width: 1,
+ height: dragImageSize.height * 0.76
+ )
+ guide.backgroundColor = NSColor.white.withAlphaComponent(0.42).cgColor
+ guide.opacity = 0
+ guide.actions = [
+ "opacity": NSNull(),
+ "position": NSNull()
+ ]
+ dragLayer.addSublayer(guide)
+ addShredGuideAnimation(to: guide, index: index)
+ shredGuideLayers.append(guide)
+ }
+ }
+ }
+
+ private func stopShredPreview() {
+ guard shredPreviewActive || !shredSliceLayers.isEmpty || !shredGuideLayers.isEmpty else { return }
+ shredPreviewActive = false
+ shredSliceLayers.forEach {
+ $0.removeAllAnimations()
+ $0.removeFromSuperlayer()
+ }
+ shredGuideLayers.forEach {
+ $0.removeAllAnimations()
+ $0.removeFromSuperlayer()
+ }
+ shredSliceLayers.removeAll()
+ shredGuideLayers.removeAll()
+ dragLayer?.contents = currentDragImage
+ }
+
+ private func addShredAnimation(to layer: CALayer, index: Int) {
+ let direction: CGFloat = index.isMultiple(of: 2) ? 1 : -1
+ let travel = min(4.5, max(2.0, dragImageSize.height * 0.06))
+ let drift = min(2.4, max(1.0, dragImageSize.width * 0.025)) * direction
+
+ let y = CAKeyframeAnimation(keyPath: "transform.translation.y")
+ y.values = [0, -travel, travel * 0.28, -travel * 0.52, 0]
+
+ let x = CAKeyframeAnimation(keyPath: "transform.translation.x")
+ x.values = [0, drift, -drift * 0.45, drift * 0.25, 0]
+
+ let opacity = CAKeyframeAnimation(keyPath: "opacity")
+ opacity.values = [1, 0.82, 0.96, 0.88, 1]
+
+ let group = CAAnimationGroup()
+ group.animations = [x, y, opacity]
+ group.duration = 0.46
+ group.repeatCount = .greatestFiniteMagnitude
+ group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ group.beginTime = CACurrentMediaTime() + Double(index) * 0.018
+ layer.add(group, forKey: "removeTagShredPreview")
+ }
+
+ private func addShredGuideAnimation(to layer: CALayer, index: Int) {
+ let opacity = CAKeyframeAnimation(keyPath: "opacity")
+ opacity.values = [0, 0.42, 0.18, 0.35, 0]
+ opacity.duration = 0.46
+ opacity.repeatCount = .greatestFiniteMagnitude
+ opacity.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ opacity.beginTime = CACurrentMediaTime() + Double(index) * 0.018
+ layer.add(opacity, forKey: "removeTagShredGuide")
}
private func dropTarget(at screenPoint: NSPoint) -> AppDropTargetReceivingView? {
@@ -298,6 +464,7 @@
protocol AppEmptyDropReceivingView: AnyObject {
func screenFrame() -> NSRect?
+ func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool
func performEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool)
}
@@ -325,6 +492,12 @@
}
}
+extension AppEmptyDropReceivingView {
+ func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool {
+ true
+ }
+}
+
struct AppDropTargetView: NSViewRepresentable {
let targetTag: String
let onDropApp: (String, String, Bool) -> Void
diff --git a/src/Apptag/AppGridCollectionView.swift b/src/Apptag/AppGridCollectionView.swift
index 13fe190..7ee1432 100644
--- a/src/Apptag/AppGridCollectionView.swift
+++ b/src/Apptag/AppGridCollectionView.swift
@@ -564,6 +564,17 @@
}
}
+ func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool {
+ guard let coordinator,
+ coordinator.displayStyle != .flat
+ else { return false }
+ return !coordinator.shouldCancelEmptyDropForActiveReorder(
+ path: path,
+ screenPoint: screenPoint,
+ copy: copy
+ )
+ }
+
private func setup() {
wantsLayer = true
layer?.backgroundColor = NSColor.clear.cgColor
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 1a2befb..456de27 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,9 @@
# TagLauncher Changelog
+## [Unreleased]
+
+- App Grid 解除标签拖拽新增原生 Core Animation 预览反馈:当应用图标拖到容器外/容器之间空白区域时,拖拽浮层会进入克制的碎纸切片动效;容器内部空白不触发该预览,实际解除标签仍沿用原有放手后确认流程
+
## [8.0.2] — 2026-06-24
- 重设计 App Grid 底部使用技巧:改为原生 AppKit 分区式教学横幅,底部横向占满可用宽度,左侧为技巧编号与标题,中央为两行动作说明,右侧为上一条/下一条控制与页点
diff --git a/src/TODO.md b/src/TODO.md
index 0990c1c..5ad408e 100644
--- a/src/TODO.md
+++ b/src/TODO.md
@@ -30,6 +30,11 @@
## Done
+- [2026-06-25] App Grid 解除标签碎纸预览动效。
+ - 结果: 拖动应用到容器外/容器之间空白区域时,拖拽浮层进入原生 Core Animation 碎纸切片预览;离开该区域恢复正常。
+ - 范围: 只改拖拽浮层视觉反馈和空白 drop 预览资格判断;不改标签数据、不改容器内空白语义、不改放手后的解除标签确认流程。
+ - 验证: `Scripts/macos14_availability_typecheck_qa.sh`、`APP_BUILD=20260625.1500 bash src/build.sh`、`Scripts/macos14_build_metadata_qa.sh src/build/TagLauncher.app` 和 `git diff --check` 通过。
+
- [2026-06-11] 7.9.0 容器内 App 手动拖拽排序。
- 结果: 同一容器内拖动 App 可改变显示顺序;排序保存为分类与布局方案的一部分,刷新、重启、语言切换、导入导出、恢复上一方案和 SmartStart 替换按统一规则保留、迁移或清理。
- 范围: 新增稳定 `containerAppOrder`、容器 ID、排序持久化 helper、分类方案 fingerprint、App Grid 同容器插入位置提示和 `ContentView` 保存回调;跨容器移动、Option 复制、拖空白移除、Apple 内置保护、Quick Search 排名均不改。
--
Gitblit v1.9.3