Ariver
2026-06-25 c8be92c4ca12b53cec27c39977d2139bea3f0fe7
Add remove tag shred preview
4 files modified
195 ■■■■■ changed files
src/Apptag/AppDragCoordinator.swift 175 ●●●●● patch | view | raw | blame | history
src/Apptag/AppGridCollectionView.swift 11 ●●●●● patch | view | raw | blame | history
src/CHANGELOG.md 4 ●●●● patch | view | raw | blame | history
src/TODO.md 5 ●●●●● patch | view | raw | blame | history
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
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
src/CHANGELOG.md
@@ -1,5 +1,9 @@
# TagLauncher Changelog
## [Unreleased]
- App Grid 解除标签拖拽新增原生 Core Animation 预览反馈:当应用图标拖到容器外/容器之间空白区域时,拖拽浮层会进入克制的碎纸切片动效;容器内部空白不触发该预览,实际解除标签仍沿用原有放手后确认流程
## [8.0.2] — 2026-06-24
- 重设计 App Grid 底部使用技巧:改为原生 AppKit 分区式教学横幅,底部横向占满可用宽度,左侧为技巧编号与标题,中央为两行动作说明,右侧为上一条/下一条控制与页点
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 排名均不改。