'Drag-to-resize NSView (or other object)

I'm trying to build an app that will allow the user to specify multiple areas of an image using rectangular bounding boxes that they can resize.

So far, I've got an NSScrollView that contains an NSImageView so the user can zoom in on the image and scroll around as they desire. My current thinking is that I can use NSViews as a way to provide a bounding box that the user can position and resize to cover the desired area, convert the NSView frames into percentages of the image size, and then store those values for later use.

There's an addAreaToImage method that adds an NSView to the NSScrollView at the center of wherever the user is currently looking. What I want is for the user to then be able to click and drag on the corners of the area to resize/move it wherever they want it to be. Sort of a live bounding box, if you will.

After reading through the documentation, most of the things related to dragging are about making the NSView a place to drag something else (like an image) or resizing due to the superview being resized, neither of which are what I'm looking to do.

My fear is that the answer to this problem (or the set of answers that would lead to me being able to roll my own solution) are so basic that no one ever thinks about them, which the last few days of Googling have pretty much confirmed for me.

I'm coming from iOS development, so this isn't completely new territory, but NSView and UIView seem to have enough differences to confuse me thoroughly so far.



Solution 1:[1]

Yes, you will need to implement it yourself but it isn't overly complicated.

First, you need to make some decisions about how you want your area views to behave and look like. Do you need just resize or be able to drag (move) the views as well? How are they drawn when they are passive/dragged/resized/highlighted. Do you want to have a resize and drag cursors? What is the behavior of the resizing, just drag a corner or all the borders? What's the drag border width?

You then subclass the NSView that you are using as your area views. Give it some private members to indicate its states (like isDragged, isResized etc).

Implement drawRect: to draw the view. Taking into account it various states (e.g you probably want to visualize when it is being dragged or resized, draw a transparent overlay, etc).

Next you want to handle mouse events by implementing mouseDown:, mouseDragged:, mouseUp: and maybe mouseMoved:. Here your resize/drag logic will be placed. Check where the user initially clicked in mouseDown: and decide what operations are possible from that point setting the relevant states. Follow up in mouseDragged: to perform the operation (by setting the view's frame origin and size accordingly). Finalize the operation in mouseUp: (validate, set states, invoke done logic, register undo operation)

When dealing with points and rects, don't forget about the coordinate system. You will need to translate them to/from views and base system. NSView has all the methods needed for this.

You need to call setNeedsDisplay: or setNeedsDisplayInRect: each time you want the view to redraw itself to reflect the changes in size and position.

You may also want to use Tracking Areas for areas in your view that need a different cursor (e.g. resize cursor on the corner).

When dragging/resizing don't forget to implement logic for responding to user dragging the mouse out of the parent's view bounds.

By the way, why are you adding your views to the scrollview? I think they are better placed as subviews of the imageview (if possible) or clipview so they can be scrolled.

Solution 2:[2]

I was also in similar situation and here is my solution to resize NSView from corners.I think you can modify same according to your requirements.

import Cocoa
import Foundation

enum CornerPosition {
    case topLeft, topRight, bottomRight, bottomLeft, none
}

class DraggableResizableView1: NSView {

private let resizableArea: CGFloat = 5
private var cursorPosition: CornerPosition = .none

override init(frame frameRect: NSRect) {
    super.init(frame: frameRect)
    self.frame = self.frame.insetBy(dx: -2, dy: -2);
    self.wantsLayer = true
    self.layer?.backgroundColor = NSColor.yellow.cgColor
    self.layer?.borderWidth = 2
    self.layer?.borderColor = NSColor.blue.cgColor
}

override func updateTrackingAreas() {
    super.updateTrackingAreas()
    trackingAreas.forEach { area in
        removeTrackingArea(area)
    }
    addTrackingRect(bounds)
}

required init?(coder decoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

override func mouseExited(with event: NSEvent) {
    super.mouseExited(with: event)
    NSCursor.arrow.set()
}

override func mouseDown(with event: NSEvent) {
    super.mouseDown(with: event)
    let locationInView = convert(event.locationInWindow, from: nil)
    cursorPosition = cursorCornerPosition(locationInView)
}

override func mouseUp(with event: NSEvent) {
    super.mouseUp(with: event)
    cursorPosition = .none
}

override func mouseMoved(with event: NSEvent) {
    super.mouseMoved(with: event)
    let locationInView = convert(event.locationInWindow, from: nil)
    cursorCornerPosition(locationInView)
}

override func mouseDragged(with event: NSEvent) {
    super.mouseDragged(with: event)
        
    let deltaX = event.deltaX
    let deltaY = event.deltaY
    guard let superView = superview else { return }
    
    switch cursorPosition {
    case .topLeft:
        if frame.size.width - deltaX > superview!.frame.width/5 && frame.size.width - deltaX < superview!.frame.width/2 &&  frame.origin.x + deltaX >= superView.frame.minX && (superView.frame.height - (frame.size.width-deltaX)*9/16) > frame.minY {
            frame.origin.x    += deltaX
            frame.origin.y = frame.origin.y
            frame.size.width  -= deltaX
            frame.size.height =  frame.size.width*9/16
        }
    case .bottomLeft:
        if frame.size.width - deltaX > superview!.frame.width/5 && frame.size.width - deltaX < superview!.frame.width/2 && frame.origin.x + deltaX > 0 && frame.origin.x + deltaX >= superView.frame.minX && frame.origin.y + deltaX*9/16 >  superView.frame.minY {
            frame.origin.x    += deltaX
            frame.origin.y    += deltaX*9/16
            frame.size.width  -= deltaX
            frame.size.height =  frame.size.width*9/16
        }
    case .topRight:
        if frame.size.width + deltaX > superview!.frame.width/5 && frame.size.width + deltaX < superview!.frame.width/2 && (superView.frame.height - (frame.size.width+deltaX)*9/16) > frame.minY  && (superView.frame.width - (frame.size.width+deltaX)) > frame.minX  {
            frame.origin.x = frame.origin.x
            frame.origin.y = frame.origin.y
            frame.size.width  += deltaX
            frame.size.height =  frame.size.width*9/16
        }
    case  .bottomRight:
        if frame.size.width + deltaX > superview!.frame.width/5 && frame.size.width + deltaX < superview!.frame.width/2 && (superView.frame.width - (frame.size.width+deltaX)) > frame.minX && frame.origin.y - deltaX*9/16 > superView.frame.minY {
            frame.origin.x = frame.origin.x
            frame.origin.y -= deltaX*9/16
            frame.size.width  += deltaX
            frame.size.height =  frame.size.width*9/16
        }
    case .none:
        frame.origin.x    += deltaX
        frame.origin.y    -= deltaY
    }
    repositionView()


}

@discardableResult
func cursorCornerPosition(_ locationInView: CGPoint) -> CornerPosition {
    
    if (locationInView.y < resizableArea && bounds.width-locationInView.x < resizableArea) || (locationInView.x < resizableArea && bounds.height-locationInView.y < resizableArea) {
        NSCursor(image: NSImage(byReferencingFile: "/System/Library/Frameworks/WebKit.framework/Versions/Current/Frameworks/WebCore.framework/Resources/northWestSouthEastResizeCursor.png")!, hotSpot: NSPoint(x: 8, y: 8)).set()
        if locationInView.y < resizableArea && bounds.width-locationInView.x < resizableArea {
            return .bottomRight
        } else {
            return .topLeft
        }
    } else if (bounds.height-locationInView.y < resizableArea && bounds.width-locationInView.x < resizableArea) || (locationInView.x < resizableArea && locationInView.y < resizableArea) {
        NSCursor(image: NSImage(byReferencingFile: "/System/Library/Frameworks/WebKit.framework/Versions/A/Frameworks/WebCore.framework/Versions/A/Resources/northEastSouthWestResizeCursor.png")!, hotSpot: NSPoint(x: 8, y: 8)).set()
        if bounds.height-locationInView.y < resizableArea && bounds.width-locationInView.x < resizableArea {
            return .topRight
        } else {
            return .bottomLeft
        }
    }
    else {
        NSCursor.openHand.set()
        return .none
    }
}

private func repositionView() {
    if frame.minX < 0 {
        frame.origin.x    = 0
    }
    if frame.minY < 0 {
        frame.origin.y    = 0
    }
    guard let superView = superview else { return }
    if frame.maxX > superView.frame.maxX {
        frame.origin.x    = superView.frame.maxX - frame.size.width
    }
    if frame.maxY > superView.frame.maxY {
        frame.origin.y    = superView.frame.maxY - frame.size.height
    }
}
}


extension NSView {
    
 func addTrackingRect(_ rect: NSRect) {
        addTrackingArea(NSTrackingArea(
            rect: rect,
            options: [
                .mouseMoved,
                .mouseEnteredAndExited,
                .activeAlways],
            owner: self))
    }
}

Solution 3:[3]

Adding else directions(top, right, left, bottom) of resizing to Akhil Shrivastav posted code. And resizing not in a fixed ratio.

import Cocoa

enum CornerBorderPosition {
    case topLeft, topRight, bottomRight, bottomLeft
    case top, left, right, bottom
    case none
}

class DraggableResizableView: NSView {
    
    private let resizableArea: CGFloat = 5
    
    private var cursorPosition: CornerBorderPosition = .none {
        didSet {
            switch self.cursorPosition {
            case .bottomRight, .topLeft:
                NSCursor(image:
                            NSImage(byReferencingFile: "/System/Library/Frameworks/WebKit.framework/Versions/Current/Frameworks/WebCore.framework/Resources/northWestSouthEastResizeCursor.png")!,
                         hotSpot: NSPoint(x: 8, y: 8)).set()
            case .bottomLeft, .topRight:
                NSCursor(image:
                            NSImage(byReferencingFile: "/System/Library/Frameworks/WebKit.framework/Versions/A/Frameworks/WebCore.framework/Versions/A/Resources/northEastSouthWestResizeCursor.png")!,
                         hotSpot: NSPoint(x: 8, y: 8)).set()
            case .top, .bottom:
                NSCursor.resizeUpDown.set()
            case .left, .right:
                NSCursor.resizeLeftRight.set()
            case .none:
                NSCursor.openHand.set()
            }
        }
    }
    
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        
        self.frame = self.frame.insetBy(dx: -2, dy: -2)
        
        self.wantsLayer = true
        
        self.layer?.backgroundColor = NSColor.yellow.cgColor
    }
    
    required init?(coder decoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        
        trackingAreas.forEach({ removeTrackingArea($0) })

        addTrackingArea(NSTrackingArea(rect: self.bounds,
                                       options: [.mouseMoved,
                                                 .mouseEnteredAndExited,
                                                 .activeAlways],
                                       owner: self))
    }

    override func mouseExited(with event: NSEvent) {
        NSCursor.arrow.set()
    }
    
    override func mouseDown(with event: NSEvent) {
        
        let locationInView = convert(event.locationInWindow, from: nil)
        
        self.cursorPosition = self.cursorCornerBorderPosition(locationInView)
        
    }
    
    override func mouseUp(with event: NSEvent) {
        
        self.cursorPosition = .none
        
    }
    
    override func mouseMoved(with event: NSEvent) {
        
        let locationInView = convert(event.locationInWindow, from: nil)
        
        self.cursorPosition = self.cursorCornerBorderPosition(locationInView)
    }
    
    override func mouseDragged(with event: NSEvent) {
        
        guard let superView = superview else { return }
        
        let deltaX = event.deltaX
        let deltaY = event.deltaY
        
        switch cursorPosition {
        case .topLeft:
            if superView.frame.width / 3 ..< superView.frame.width ~= self.frame.size.width - deltaX,
               superView.frame.height / 3 ..< superView.frame.height ~= self.frame.size.height - deltaY,
               self.frame.origin.x + deltaX >= superView.frame.minX,
               self.frame.origin.y + self.frame.height - deltaY <= superView.frame.maxY {
                                
                self.frame.size.width -= deltaX
                
                self.frame.size.height -= deltaY
                
                self.frame.origin.x += deltaX
                
            }
        case .bottomLeft:
            if superView.frame.width / 3 ..< superView.frame.width ~= self.frame.size.width - deltaX,
               superView.frame.height / 3 ..< superView.frame.height ~= self.frame.size.height + deltaY,
               self.frame.origin.x + deltaX >= superView.frame.minX,
               self.frame.origin.y - deltaY >= superView.frame.minY {
                
                self.frame.origin.x += deltaX
                
                self.frame.origin.y -= deltaY
                
                self.frame.size.width -= deltaX
                
                self.frame.size.height += deltaY
                
            }
        case .topRight:
            if superView.frame.width / 3 ..< superView.frame.width ~= self.frame.size.width + deltaX,
               superView.frame.height / 3 ..< superView.frame.height ~= self.frame.size.height - deltaY,
               self.frame.origin.x + self.frame.width + deltaX <= superView.frame.maxX,
               self.frame.origin.y + self.frame.height - deltaY <= superView.frame.maxY {
                
                self.frame.size.width += deltaX
                
                self.frame.size.height -= deltaY
                
            }
        case  .bottomRight:
            if superView.frame.width / 3 ..< superView.frame.width ~= self.frame.size.width + deltaX,
               superView.frame.height / 3 ..< superView.frame.height ~= self.frame.size.height + deltaY,
               self.frame.origin.x + self.frame.width + deltaX <= superView.frame.maxX,
               self.frame.origin.y - deltaY >= superView.frame.minY {
                
                self.frame.origin.y -= deltaY
                
                self.frame.size.width += deltaX
                
                self.frame.size.height += deltaY
                
            }
        case .top:
            if superView.frame.height / 3 ..< superView.frame.height ~= self.frame.size.height - deltaY,
               self.frame.origin.y + self.frame.height - deltaY <= superView.frame.maxY {
                self.frame.size.height -= deltaY
            }
        case .bottom:
            if superView.frame.height / 3 ..< superView.frame.height ~= self.frame.size.height + deltaY,
               self.frame.origin.y - deltaY >= superView.frame.minY {
                self.frame.size.height += deltaY
                self.frame.origin.y -= deltaY
            }
        case .left:
            if superView.frame.width / 3 ..< superView.frame.width ~= self.frame.size.width - deltaX,
               self.frame.origin.x + deltaX >= superView.frame.minX {
                self.frame.size.width -= deltaX
                self.frame.origin.x += deltaX
            }
        case .right:
            if superView.frame.width / 3 ..< superView.frame.width ~= self.frame.size.width + deltaX,
               self.frame.origin.x + self.frame.size.width + deltaX <= superView.frame.maxX {
                self.frame.size.width += deltaX
            }
        case .none:
            self.frame.origin.x += deltaX
            self.frame.origin.y -= deltaY
        }
        
        self.repositionView()
        
    }
    
    @discardableResult
    func cursorCornerBorderPosition(_ locationInView: CGPoint) -> CornerBorderPosition {
        
        if locationInView.x < resizableArea,
           locationInView.y < resizableArea {
            return .bottomLeft
        }
        if self.bounds.width - locationInView.x < resizableArea,
           locationInView.y < resizableArea {
            return .bottomRight
        }
        if locationInView.x < resizableArea,
           self.bounds.height - locationInView.y < resizableArea {
            return .topLeft
        }
        if self.bounds.height - locationInView.y < resizableArea,
           self.bounds.width - locationInView.x < resizableArea {
            return .topRight
        }
        if locationInView.x < resizableArea {
            return .left
        }
        if self.bounds.width - locationInView.x < resizableArea {
            return .right
        }
        if locationInView.y < resizableArea {
            return .bottom
        }
        if self.bounds.height - locationInView.y < resizableArea {
            return .top
        }
        
        return .none
    }
    
    private func repositionView() {
        
        guard let superView = superview else { return }
        
        if self.frame.minX < superView.frame.minX {
            self.frame.origin.x = superView.frame.minX
        }
        if self.frame.minY < superView.frame.minY {
            self.frame.origin.y = superView.frame.minY
        }
        
        if self.frame.maxX > superView.frame.maxX {
            self.frame.origin.x = superView.frame.maxX - self.frame.size.width
        }
        if self.frame.maxY > superView.frame.maxY {
            self.frame.origin.y = superView.frame.maxY - self.frame.size.height
        }
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 danielv
Solution 2 Akhil Shrivastav
Solution 3 ShaoJen Chen