5
\$\begingroup\$

My Cocoa event code always tends to descend into madness, I need to advice on how to reduce complexity. I've spend a few hours putting this example together.

CanvasView is a layer hosting subclass of NSView which uses Cocoa's eventing handling (-mouseDown:, -mouseDragged:, -mouseUp:) to enable clicking and dragging of sublayers. I have intentionally tried to mimicked the Finder's behaviour when clicking, shift-clicking and dragging files/layers.

  1. Mouse-down in the view without hitting any layers resets the selection.
  2. Mouse-down on an unselected layer changes selection to that layer alone.
  3. Shift + mouse-down on an unselected layer adds it to the current selection
  4. Shift + mouse-up on a selected layer removes it from the current selection
  5. Mouse-drag drags the current selection.

This seems very simple! However, the code below is horrible (at least to me). You could imagine how in real code this would be complicated by interactions between MVC and end up as completely unreadable and unmaintainable trash.

What's the best way for tackling problems like this and keeping a lid on the level of state and complexity?

Project with UI available on Github

import Cocoa

class CanvasView : NSView {

    /* State for selected items */
    var selectedLayers : Set<CALayer> = Set()
    var layerSelectedOnMouseDown : CALayer?
    var didDrag : Bool = false
    var didAddNewLayerOnMouseDown = false


    /** Stores the layer in cache and update the layer properties so that it draws a border */
    func addLayerToSelectedCache( layer : CALayer) {
        self.selectedLayers.insert(layer)
        layer.borderWidth = 4.0
        layer.borderColor = NSColor.alternateSelectedControlColor().CGColor
    }

    func removeLayerFromSelectedCache(layer : CALayer) {
        layer.borderWidth = 0.0
        layer.borderColor = nil
        self.selectedLayers.remove(layer)
    }

    func removeAllLayersFromSelectedCache() {
        for layer in selectedLayers {
            if let shape = layer as? CAShapeLayer {
                removeLayerFromSelectedCache(shape)
            }
        }
    }

    override func drawRect(dirtyRect: NSRect) {
        super.drawRect(dirtyRect)
        // Drawing code here.
    }


    override func mouseDown(theEvent: NSEvent) {

        // Reset state
        didDrag = false
        didAddNewLayerOnMouseDown = false
        layerSelectedOnMouseDown = nil

        // Convert to layer coordinate system
        let viewPoint = self.convertPoint(theEvent.locationInWindow, fromView: nil)
        let rootLayerPoint = self.convertPointToLayer(viewPoint)
        if let rootLayer = self.layer {

            let hitLayer = rootLayer.hitTest(rootLayerPoint)
            if hitLayer != rootLayer {

                // Store hit layer
                layerSelectedOnMouseDown = hitLayer
                let hitPointInHitLayerFrame = rootLayer.convertPoint(rootLayerPoint, toLayer: hitLayer)
                let ax = hitPointInHitLayerFrame.x / NSWidth(hitLayer.bounds)
                let ay = hitPointInHitLayerFrame.y / NSHeight(hitLayer.bounds)

                // Pause animations
                CATransaction.begin()
                CATransaction.setDisableActions(true)

                // When setting the anchor point we also need to set the position
                hitLayer.anchorPoint = CGPoint(x:ax, y:ay)
                let newSuperLayerPosition = rootLayer.convertPoint(rootLayerPoint, toLayer:hitLayer.superlayer)
                hitLayer.position = newSuperLayerPosition

                CATransaction.commit()

                // Perform actions that need to occur at mouseDown time
                let shiftIsPressed = !((theEvent.modifierFlags & NSEventModifierFlags.ShiftKeyMask).rawValue == 0)
                let cacheContainsHitLayer = selectedLayers.contains(hitLayer)
                if !cacheContainsHitLayer {

                    if !shiftIsPressed {
                        // De-select all layers and select the clicked layer
                        // which is outside the current selection group.
                        for layer in selectedLayers {
                            if let shape = layer as? CAShapeLayer {
                                removeLayerFromSelectedCache(shape)
                            }
                        }
                        addLayerToSelectedCache(hitLayer)
                        didAddNewLayerOnMouseDown = true
                    } else {
                        addLayerToSelectedCache(hitLayer)
                        didAddNewLayerOnMouseDown = true
                    }
                }
            }
        }

        // If nothing hit the reset all state
        if layerSelectedOnMouseDown ==  nil {
            removeAllLayersFromSelectedCache()
        }
    }

    override func mouseDragged(theEvent: NSEvent) {

        // Update state
        didDrag = true

        // If we have something to drag, drag it!
        if let layerSelectedOnMouseDown = layerSelectedOnMouseDown, rootLayer = self.layer {

            let viewPoint = self.convertPoint(theEvent.locationInWindow, fromView: nil)
            let rootLayerPoint = self.convertPointToLayer(viewPoint)
            let newSuperLayerPosition = rootLayer.convertPoint(rootLayerPoint, toLayer:layerSelectedOnMouseDown.superlayer)
            let offset = CGPoint(x: newSuperLayerPosition.x - layerSelectedOnMouseDown.position.x,
                                 y: newSuperLayerPosition.y - layerSelectedOnMouseDown.position.y)

            CATransaction.begin()
            CATransaction.setDisableActions(true)

                // Update the position of the layer being dragged position of 
                // any layers that are selected because of a previous drag selection box
                layerSelectedOnMouseDown.position = newSuperLayerPosition
                for layer in selectedLayers.subtract(Set([layerSelectedOnMouseDown])) {
                    layer.position = CGPoint(x: layer.position.x + offset.x, y: layer.position.y + offset.y)
                }

            CATransaction.commit()

        }
    }

    override func mouseUp(theEvent: NSEvent) {

        // Clear all selection because the background was clicked during this event
        if let layerSelectedOnMouseDown = layerSelectedOnMouseDown {

            let shiftIsPressed = !((theEvent.modifierFlags & NSEventModifierFlags.ShiftKeyMask).rawValue == 0)
            let cacheContainsHitLayer = selectedLayers.contains(layerSelectedOnMouseDown)

            if shiftIsPressed && !didDrag && !didAddNewLayerOnMouseDown {

                // Toggle layer in and out of selected set
                if cacheContainsHitLayer {
                    removeLayerFromSelectedCache(layerSelectedOnMouseDown)
                } else {
                    addLayerToSelectedCache(layerSelectedOnMouseDown)
                }
            }

        }
    }
}
\$\endgroup\$
  • \$\begingroup\$ I'm confused by what you mean by this: You could imagine how in real code this would be complicated by interactions between MVC and end up as completely unreadable and unmaintainable trash. Care to clarify? Do you intend to add more code to this file? \$\endgroup\$ – nhgrif Apr 6 '15 at 21:42
  • \$\begingroup\$ Also, what version of Swift/Xcode is this? Project shows Use of undeclared type 'Set' for me in Xcode 6.2, along with other errors. \$\endgroup\$ – nhgrif Apr 6 '15 at 21:44
  • \$\begingroup\$ It's beta 6.3 Swift. Just strikes me as already being overly complicated. This is an example. In production code there are more things to think about .e.g. The event handling code might already be doing additional things. \$\endgroup\$ – boyfarrell Apr 6 '15 at 21:48
  • \$\begingroup\$ But wouldn't you be subclassing this? Is the complication concern related to a performance concern? Or just a readability concern? \$\endgroup\$ – nhgrif Apr 6 '15 at 21:49
  • \$\begingroup\$ Readability and managing state. It doesn't seem to follow any clear patterns is one thing I would like to improve. \$\endgroup\$ – boyfarrell Apr 6 '15 at 21:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.