macoscocoansviewnsstackview

Drag views in NSStackView to rearrange the sequence


Is it possible to change the order of views in NSStackView by dragging the subviews, just like we do it in NSTableView ?


Solution

  • Here's an implementation of an NSStackView subclass whose contents can be reordered via dragging:

    //
    //  DraggingStackView.swift
    //  Analysis
    //
    //  Created by Mark Onyschuk on 2017-02-02.
    //  Copyright © 2017 Mark Onyschuk. All rights reserved.
    //
    import Cocoa
    
    class DraggingStackView: NSStackView {
    
        var isEnabled = true
    
        // MARK: -
        // MARK: Update Function
        var update: (NSStackView, Array<NSView>)->Void = { stack, views in
            stack.views.forEach {
                stack.removeView($0)
            }
    
            views.forEach {
                stack.addView($0, in: .leading)
    
                switch stack.orientation {
                case .horizontal:
                    $0.topAnchor.constraint(equalTo: stack.topAnchor).isActive = true
                    $0.bottomAnchor.constraint(equalTo: stack.bottomAnchor).isActive = true
    
                case .vertical:
                    $0.leadingAnchor.constraint(equalTo: stack.leadingAnchor).isActive = true
                    $0.trailingAnchor.constraint(equalTo: stack.trailingAnchor).isActive = true
                }
            }
        }
    
    
        // MARK: -
        // MARK: Event Handling
        override func mouseDragged(with event: NSEvent) {
            if isEnabled {
                let location = convert(event.locationInWindow, from: nil)
                if let dragged = views.first(where: { $0.hitTest(location) != nil }) {
                    reorder(view: dragged, event: event)
                }
            } else {
                super.mouseDragged(with: event)
            }
    
        }
    
        private func reorder(view: NSView, event: NSEvent) {
            guard let layer = self.layer else { return }
            guard let cached = try? self.cacheViews() else { return }
    
            let container = CALayer()
            container.frame = layer.bounds
            container.zPosition = 1
            container.backgroundColor = NSColor.underPageBackgroundColor.cgColor
    
            cached
                .filter  { $0.view !== view }
                .forEach { container.addSublayer($0) }
    
            layer.addSublayer(container)
            defer { container.removeFromSuperlayer() }
    
            let dragged = cached.first(where: { $0.view === view })!
    
            dragged.zPosition = 2
            layer.addSublayer(dragged)
            defer { dragged.removeFromSuperlayer() }
    
            let d0 = view.frame.origin
            let p0 = convert(event.locationInWindow, from: nil)
    
            window!.trackEvents(matching: [.leftMouseDragged, .leftMouseUp], timeout: 1e6, mode: .eventTrackingRunLoopMode) { event, stop in
                if event.type == .leftMouseDragged {
                    let p1 = self.convert(event.locationInWindow, from: nil)
    
                    let dx = (self.orientation == .horizontal) ? p1.x - p0.x : 0
                    let dy = (self.orientation == .vertical)   ? p1.y - p0.y : 0
    
                    CATransaction.begin()
                    CATransaction.setDisableActions(true)
                    dragged.frame.origin.x = d0.x + dx
                    dragged.frame.origin.y = d0.y + dy
                    CATransaction.commit()
    
                    let reordered = self.views.map {
                        (view: $0,
                         position: $0 !== view
                            ? NSPoint(x: $0.frame.midX, y: $0.frame.midY)
                            : NSPoint(x: dragged.frame.midX, y: dragged.frame.midY))
                        }
                        .sorted {
                            switch self.orientation {
                            case .vertical: return $0.position.y < $1.position.y
                            case .horizontal: return $0.position.x < $1.position.x
                            }
                        }
                        .map { $0.view }
    
                    let nextIndex = reordered.index(of: view)!
                    let prevIndex = self.views.index(of: view)!
    
                    if nextIndex != prevIndex {
                        self.update(self, reordered)
                        self.layoutSubtreeIfNeeded()
    
                        CATransaction.begin()
                        CATransaction.setAnimationDuration(0.15)
                        CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut))
    
                        for layer in cached {
                            layer.position = NSPoint(x: layer.view.frame.midX, y: layer.view.frame.midY)
                        }
    
                        CATransaction.commit()
                    }
    
                } else {
                    view.mouseUp(with: event)                    
                    stop.pointee = true
                }
            }
        }
    
        // MARK: -
        // MARK: View Caching
        private class CachedViewLayer: CALayer {
            let view: NSView!
    
            enum CacheError: Error {
                case bitmapCreationFailed
            }
    
            override init(layer: Any) {
                self.view = (layer as! CachedViewLayer).view
                super.init(layer: layer)
            }
    
            init(view: NSView) throws {
                self.view = view
    
                super.init()
    
                guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { throw CacheError.bitmapCreationFailed }
                view.cacheDisplay(in: view.bounds, to: bitmap)
    
                frame = view.frame
                contents = bitmap.cgImage
            }
    
            required init?(coder aDecoder: NSCoder) {
                fatalError("init(coder:) has not been implemented")
            }
        }
    
        private func cacheViews() throws -> [CachedViewLayer] {
            return try views.map { try cacheView(view: $0) }
        }
    
        private func cacheView(view: NSView) throws -> CachedViewLayer {
            return try CachedViewLayer(view: view)
        }
    }
    

    The code requires your stack to be layer backed, and uses sublayers to simulate and animate its content views during drag handling. Dragging is detected by an override of mouseDragged(with:) so will not be initiated if the stack's contents consume this event.