rdk
rdk

Reputation: 109

iOS Swift Get Image Coordinates in UIImage With GestureRecognizer

I've got a simple app that displays an image inside a ImageView which is inside a scrollview which in turn is in a stack view along with some buttons. I've set up the imageview/scrollview to be able to pinch/zoom.

Now, I've added a TapGesture Recognizer to detect touchs and I then grab the x,y coordinates in the image. Based previous StackOverflow questions/answers, I translate the coordinates in the gesture recognizer back to the original image. Here is my gesture callback.

@IBAction func didTapImage(tapGestureRecognizer: UITapGestureRecognizer)
{
    guard let image = imageView.image else {
        return
    }
            
    let touchPoint: CGPoint = tapGestureRecognizer.location(in: imageView)
    
    print("image clicked: x: \(touchPoint.x) y: \(touchPoint.y)")
    print("image size is \(image.size)")
    print("frame size is \(imageView.frame.size)")
    
    // touch pont relative to imageView then translate to the image coordinates
    let x_prop = touchPoint.x / imageView.frame.size.width
    let y_prop = touchPoint.y / imageView.frame.size.height
    
    let new_x = x_prop * image.size.width
    let new_y = y_prop * image.size.height
    print("x_prop: \(x_prop), y_prop: \(y_prop)")
    print("new_x: \(new_x) new_y: \(new_y)")
}

What I'm seeing is that close to the center of the image, the coordinates seem pretty accurate. When I go to click at roughly 0,0, I'm finding the X is really distorted and Y seems accurate.

I've set the image, imageview, and scrollview to be scaleAspectFit.

Any ideas why the x,y coordinates in the gesture call back are distorted? (Below is the code I use to assemble the main view)

Bobby

        //scrollView.frame = view.bounds
        scrollView.zoomScale = 1.0
        scrollView.maximumZoomScale = 10.0
        scrollView.minimumZoomScale = 0.5
        scrollView.delegate = self
        scrollView.isUserInteractionEnabled = true
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.contentMode = .scaleAspectFit
        
        // set up image view for scale aspect fit, allow user interaction (for clicking)
        // and add the gesture for detecting touch
        imageView.contentMode = .scaleAspectFit
        imageView.isUserInteractionEnabled = true
        let singleTap = UITapGestureRecognizer(target: self,action:#selector(didTapImage))
        imageView.addGestureRecognizer(singleTap)
        imageView.image = theImage

        // stackview setup
        stackView.frame = view.bounds
        stackView.axis = .vertical
        stackView.distribution = .fillProportionally
        //stackView.distribution = .fillEqually
        //stackView.distribution = .equalSpacing
        stackView.spacing = 5
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.contentMode = .scaleAspectFit

        stackView.addArrangedSubview(selectButton)
        stackView.addArrangedSubview(resetButton)
        stackView.addArrangedSubview(imageView)
        stackView.addArrangedSubview(textView)
        
        contentView.contentMode = .scaleAspectFit
        contentView.addSubview(stackView)
        scrollView.addSubview(contentView)
        view.addSubview(scrollView)

        contentView.contentMode = .scaleAspectFit
        contentView.addSubview(stackView)
        scrollView.addSubview(contentView)
        view.addSubview(scrollView)
        
        // set up layout constraints
        // set constraints
        selectButton.heightAnchor.constraint(equalToConstant: 0.1*view.frame.size.height).isActive = true
        resetButton.heightAnchor.constraint(equalToConstant: 0.1*view.frame.size.height).isActive = true
        textView.heightAnchor.constraint(equalToConstant: 0.40*view.frame.size.height).isActive = true
        imageView.heightAnchor.constraint(equalToConstant: 0.40*view.frame.size.height).isActive = true

        scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
        scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
        
        contentView.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor).isActive = true
        contentView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
        contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
        contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
        contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
        
        stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
        stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
        stackView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
        stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
'''
Code to draw crosshair on image:

'''
func markImage(x_prop: CGFloat, y_prop: CGFloat)
    {
        guard let image = imageView.image else {
            return
        }
        let imageSize = image.size
        let scale: CGFloat = 0
        let length: CGFloat = max(imageSize.width/48, imageSize.height/48)
        let gap: CGFloat = length / 1.5
        var actualX = imageSize.width * x_prop
        var actualY = imageSize.height * y_prop
        
        //actualX = actualX.rounded()
        //actualY = actualY.rounded()
        
        print("markImage at \(actualX) \(actualY)")
        
        UIGraphicsBeginImageContextWithOptions(imageSize, false, scale)
        
        imageView.image!.draw(at: CGPoint.zero)
        var uiColor = hexToUIColor(rgbVal: 0xFE00DD)
        uiColor.setFill()

        // horizontal lines in target
        var rectangle = CGRect(x: actualX - length - gap, y: actualY-gap/2,
                               width: length, height: gap)
        UIRectFill(rectangle)
        
        rectangle = CGRect(x: actualX + gap, y: actualY-gap/2,
                           width: length, height: gap)
        UIRectFill(rectangle)
        
        // vertical lines in target
        rectangle = CGRect(x: actualX-gap/2, y: actualY - length - gap,
                           width: gap, height: length)
        UIRectFill(rectangle)
        
        rectangle = CGRect(x: actualX-gap/2, y: actualY + gap,
                           width: gap, height: length)
        UIRectFill(rectangle)
        
        // for testing, draw a white rectangle 20x20 centered at actual x,y
        uiColor = hexToUIColor(rgbVal: 0xFFFFFF)
        uiColor.setFill()
        rectangle = CGRect(x: actualX-10, y: actualY-10, width: 20, height: 20)
        UIRectFill(rectangle)
        
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        imageView.image = newImage
        
    }
'''

Upvotes: 1

Views: 700

Answers (1)

DonMag
DonMag

Reputation: 77423

The reason your calculated coordinates are off is because you're not accounting for the image view's .aspectFit content mode.

Take a look at this example...

Here is a self-portrait I drew:

enter image description here

It's pixel dimensions are 300 x 600.

Suppose I want cross-hairs at the center of the right eye - that is located at 100, 100 (in pixels):

enter image description here

When working with tap locations and view sizes, we have to work in points. So, if we have a 300 x 300 image view, with .contentMode = .aspectFit, it will look like this:

enter image description here

If we tap the right eye, the tap in the image view will be at 125, 50 points:

enter image description here

and, if we try to draw at that point, it will be here on the original image:

enter image description here

Which is, obviously, not where we want it.

So, first we need to calculate the image rectangle, relative to the imageView's bounds.

We can do that with a func like this:

func aspectFitRect(aspectRatio: CGSize, insideRect: CGRect) -> CGRect {
    
    var fitWidth: CGFloat = insideRect.width
    var fitHeight: CGFloat = insideRect.height
    
    let maxW: CGFloat = fitWidth / aspectRatio.width
    let maxH: CGFloat = fitHeight / aspectRatio.height
    
    if( maxH < maxW ) {
        fitWidth = fitHeight / aspectRatio.height * aspectRatio.width;
    }
    else if( maxW < maxH ) {
        fitHeight = fitWidth / aspectRatio.width * aspectRatio.height;
    }
    
    let r: CGRect = .init(x: (insideRect.width - fitWidth) * 0.5, y: (insideRect.height - fitHeight) * 0.5, width: fitWidth, height: fitHeight)
    
    return r
    
}

and call:

let imageRect: CGRect = aspectFitRect(aspectRatio: img.size, insideRect: imageView.bounds)

with the resulting rect being: x: 75, y: 0, w: 150, h: 300

Or, much easier, import AVFoundation and then:

let imageRect: CGRect = AVMakeRect(aspectRatio: img.size, insideRect: imageView.bounds)

Now we can subtract that rect's origin from the tapped point:

// assuming
tap = CGPoint(x: 125, y: 50)
tap.x -= imageRect.origin.x
tap.y -= imageRect.origin.y

// tap now equals x: 50, y: 50

Then we need to scale that point to match the scaled-size of the image:

let wScale: CGFloat = img.size.width / imageRect.size.width
let hScale: CGFloat = img.size.height / imageRect.size.height

tap.x *= wScale
tap.y *= hScale

// tap now equals x: 100, y: 100

Note that, depending on how we're drawing on (modifying) the actual bitmap image, we may also need to take into account the img.scale ... for example, I might have @2x and @3x images, so the actual pixel dimensions might be 300 x 600 (@2x) and 450 x 900 (@3x).

Since you say you will NOT be saving the image with the cross-hairs drawn on it, let me offer a much simpler approach that will avoid (almost) all of that.

Let's write a UIImageView subclass, with a CAShapeLayer for the cross-hairs:

class CrossHairsImageView: UIImageView {

    public var armLength: CGFloat = 10.0 { didSet { setNeedsLayout() } }
    public var lineWidth: CGFloat = 2.0 { didSet { crossHairsLayer.lineWidth = lineWidth } }
    public var color: UIColor = .white { didSet { crossHairsLayer.strokeColor = color.cgColor } }
    
    private let crossHairsLayer = CAShapeLayer()

    private var tapPoints: [CGPoint] = []
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    override init(image: UIImage?) {
        super.init(image: image)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        crossHairsLayer.strokeColor = color.cgColor
        crossHairsLayer.fillColor = nil
        crossHairsLayer.lineWidth = lineWidth
        layer.addSublayer(crossHairsLayer)
        
        let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
        addGestureRecognizer(t)
        
        self.isUserInteractionEnabled = true
    }
    
    @objc func gotTap(_ sender: UITapGestureRecognizer) {

        guard let img = self.image else { return }
        
        let tapPoint: CGPoint = sender.location(in: self)
        
        let imageRect: CGRect = AVMakeRect(aspectRatio: img.size, insideRect: self.bounds)

        // Since we don't want cross-hairs on the image view --
        //  only on the area the image takes -- check that the tapped point is
        //  inside the image rect
        if imageRect.contains(tapPoint) {
            tapPoints.append(tapPoint)
            setNeedsLayout()
        }
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let bez = UIBezierPath()
        tapPoints.forEach { pt in
            bez.move(to: .init(x: pt.x - armLength, y: pt.y))
            bez.addLine(to: .init(x: pt.x + armLength, y: pt.y))
            bez.move(to: .init(x: pt.x, y: pt.y - armLength))
            bez.addLine(to: .init(x: pt.x, y: pt.y + armLength))
        }

        crossHairsLayer.path = bez.cgPath
    }
    
    // reset() clears all cross-hairs
    public func reset() {
        tapPoints = []
        setNeedsLayout()
    }
    
}

Now we can tap away on the image view and get this:

enter image description here

and we don't have to do any other calculationa when zooming in a scroll view:

enter image description here

Here's an example controller, with two buttons, the custom image view, and a text view ... in a vertical stack view in a "content" view in a scroll view:

class ViewController: UIViewController {
    
    let scrollView = UIScrollView()
    let contentView = UIView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemYellow
        
        scrollView.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
        
        guard let img = UIImage(named: "figure300x600")
        else {
            fatalError("Could not load testImage!")
        }
        
        let imageView = CrossHairsImageView(image: img)
        imageView.contentMode = .scaleAspectFit
        imageView.backgroundColor = UIColor(white: 0.6, alpha: 1.0)
        
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 5
        
        let btn1 = UIButton()
        btn1.backgroundColor = .red
        btn1.setTitle("Button 1", for: [])
        
        let btn2 = UIButton()
        btn2.backgroundColor = .blue
        btn2.setTitle("Button 2", for: [])
        
        let textView = UITextView()
        textView.backgroundColor = .yellow
        textView.text = "This is the text view."
        
        stackView.addArrangedSubview(btn1)
        stackView.addArrangedSubview(btn2)
        stackView.addArrangedSubview(imageView)
        stackView.addArrangedSubview(textView)
        
        [stackView, contentView, scrollView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }
        
        contentView.addSubview(stackView)
        scrollView.addSubview(contentView)
        view.addSubview(scrollView)
        
        let g = view.safeAreaLayoutGuide
        let cg = scrollView.contentLayoutGuide
        let fg = scrollView.frameLayoutGuide
        
        NSLayoutConstraint.activate([
            
            // let's inset the scroll view by 20-points on all 4 sides
            //  so we can clearly see the scroll zoom
            scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
            
            // constrain content view to scroll view's Content Layout Guide
            contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
            contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
            contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
            contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
            
            // constrain image view to content view
            stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0.0),
            stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 0.0),
            stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0.0),
            stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0.0),

            // let's make the contentView the same size as the scroll view
            //  using the Frame Layout Guide
            contentView.widthAnchor.constraint(equalTo: fg.widthAnchor, multiplier: 1.0),
            contentView.heightAnchor.constraint(equalTo: fg.heightAnchor, multiplier: 1.0),
            
            btn1.heightAnchor.constraint(equalTo: stackView.heightAnchor, multiplier: 0.1),
            btn2.heightAnchor.constraint(equalTo: stackView.heightAnchor, multiplier: 0.1),
            
            imageView.heightAnchor.constraint(equalTo: stackView.heightAnchor, multiplier: 0.4),
            
            // no height on the textView -- let it fill the remainder
            //  of the stackView height
            
        ])
        
        scrollView.delegate = self
        
        // set your min/max zoom scales as desired
        scrollView.minimumZoomScale = 0.5
        scrollView.maximumZoomScale = 10.0
        
    }
    
}

extension ViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return contentView
    }
}

Upvotes: 3

Related Questions