Reputation: 109
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
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:
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):
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:
If we tap the right eye, the tap in the image view will be at 125, 50
points:
and, if we try to draw at that point, it will be here on the original image:
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:
and we don't have to do any other calculationa when zooming in a scroll view:
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