making an image the full size of a scrollview - swift

I have set an image as a background in my scrollView so i can zoom in a pan around it, however, when the image is completely zoomed out it creates white borders to the left and right of the image which i can only assume is the scrollView margins.
Any way to get rid of these so the image fills the screen?
EDIT: here is a picture to show what i mean, the white bars along the sides of the grey background are the edges of the scrollview, i want the grey background to fill the screen completely apart from the iAd bar.
http://i.stack.imgur.com/qX0Kk.png
Many thanks!
// 1
let image = UIImage(named: "Photo1")!
imageView = UIImageView(image: image)
imageView.frame = CGRect(origin: CGPointMake(0, 0), size:image.size)
// 2
scrollView.addSubview(imageView)
scrollView.contentSize = image.size
// 3
var doubleTapRecognizer = UITapGestureRecognizer(target: self, action: "scrollViewDoubleTapped:")
doubleTapRecognizer.numberOfTapsRequired = 2
doubleTapRecognizer.numberOfTouchesRequired = 1
scrollView.addGestureRecognizer(doubleTapRecognizer)
// 4
let scrollViewFrame = scrollView.frame
let scaleWidth = scrollViewFrame.size.width / scrollView.contentSize.width
let scaleHeight = scrollViewFrame.size.height / scrollView.contentSize.height
let minScale = min(scaleWidth, scaleHeight);
scrollView.minimumZoomScale = minScale;
// 5
scrollView.maximumZoomScale = 1.0
scrollView.zoomScale = minScale;
// 6
centerScrollViewContents() }
func centerScrollViewContents() {
let boundsSize = scrollView.bounds.size
var contentsFrame = imageView.frame
if contentsFrame.size.width < boundsSize.width {
contentsFrame.origin.x = (boundsSize.width - contentsFrame.size.width) / 2.0
} else {
contentsFrame.origin.x = 0.0
}
if contentsFrame.size.height < boundsSize.height {
contentsFrame.origin.y = (boundsSize.height - contentsFrame.size.height) / 2.0
} else {
contentsFrame.origin.y = 0.0
}
imageView.frame = contentsFrame
}
func scrollViewDoubleTapped(recognizer: UITapGestureRecognizer) {
// 1
let pointInView = recognizer.locationInView(imageView)
// 2
var newZoomScale = scrollView.zoomScale * 1.5
newZoomScale = min(newZoomScale, scrollView.maximumZoomScale)
// 3
let scrollViewSize = scrollView.bounds.size
let w = scrollViewSize.width / newZoomScale
let h = scrollViewSize.height / newZoomScale
let x = pointInView.x - (w / 2.0)
let y = pointInView.y - (h / 2.0)
let rectToZoomTo = CGRectMake(x, y, w, h);
// 4
scrollView.zoomToRect(rectToZoomTo, animated: true)
}
func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
return imageView
}
func scrollViewDidZoom(scrollView: UIScrollView) {
centerScrollViewContents()
}

You are setting the min zoomscale to be the min of width and height scales. This is generally desired, as it allows you to see the entire image (AspectFit). It sounds like you want AspectFill though, to do that, you would need to use the max of width and height scales.
let minScale = max(scaleWidth, scaleHeight);

Related

Cropping is not working perfectly as per the frame drawn

I am trying to crop a selected portion of NSImage which is fitted as per ProportionallyUpOrDown(AspectFill) Mode.
I am drawing a frame using mouse dragged event like this:
class CropImageView: NSImageView {
var startPoint: NSPoint!
var shapeLayer: CAShapeLayer!
var flagCheck = false
var finalPoint: NSPoint!
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
}
override var image: NSImage? {
set {
self.layer = CALayer()
self.layer?.contentsGravity = kCAGravityResizeAspectFill
self.layer?.contents = newValue
self.wantsLayer = true
super.image = newValue
}
get {
return super.image
}
}
override func mouseDown(with event: NSEvent) {
self.startPoint = self.convert(event.locationInWindow, from: nil)
if self.shapeLayer != nil {
self.shapeLayer.removeFromSuperlayer()
self.shapeLayer = nil
}
self.flagCheck = true
var pixelColor: NSColor = NSReadPixel(startPoint) ?? NSColor()
shapeLayer = CAShapeLayer()
shapeLayer.lineWidth = 1.0
shapeLayer.fillColor = NSColor.clear.cgColor
if pixelColor == NSColor.black {
pixelColor = NSColor.color_white
} else {
pixelColor = NSColor.black
}
shapeLayer.strokeColor = pixelColor.cgColor
shapeLayer.lineDashPattern = [1]
self.layer?.addSublayer(shapeLayer)
var dashAnimation = CABasicAnimation()
dashAnimation = CABasicAnimation(keyPath: "lineDashPhase")
dashAnimation.duration = 0.75
dashAnimation.fromValue = 0.0
dashAnimation.toValue = 15.0
dashAnimation.repeatCount = 0.0
shapeLayer.add(dashAnimation, forKey: "linePhase")
}
override func mouseDragged(with event: NSEvent) {
let point: NSPoint = self.convert(event.locationInWindow, from: nil)
var newPoint: CGPoint = self.startPoint
let xDiff = point.x - self.startPoint.x
let yDiff = point.y - self.startPoint.y
let dist = min(abs(xDiff), abs(yDiff))
newPoint.x += xDiff > 0 ? dist : -dist
newPoint.y += yDiff > 0 ? dist : -dist
let path = CGMutablePath()
path.move(to: self.startPoint)
path.addLine(to: NSPoint(x: self.startPoint.x, y: newPoint.y))
path.addLine(to: newPoint)
path.addLine(to: NSPoint(x: newPoint.x, y: self.startPoint.y))
path.closeSubpath()
self.shapeLayer.path = path
}
override func mouseUp(with event: NSEvent) {
self.finalPoint = self.convert(event.locationInWindow, from: nil)
}
}
and selected this area as shown in picture using black dotted line:
My Cropping Code logic is this:
// resize Image Methods
extension CropProfileView {
func resizeImage(image: NSImage) -> Data {
var scalingFactor: CGFloat = 0.0
if image.size.width >= image.size.height {
scalingFactor = image.size.width/cropImgView.size.width
} else {
scalingFactor = image.size.height/cropImgView.size.height
}
let width = (self.cropImgView.finalPoint.x - self.cropImgView.startPoint.x) * scalingFactor
let height = (self.cropImgView.startPoint.y - self.cropImgView.finalPoint.y) * scalingFactor
let xPos = ((image.size.width/2) - (cropImgView.bounds.midX - self.cropImgView.startPoint.x) * scalingFactor)
let yPos = ((image.size.height/2) - (cropImgView.bounds.midY - (cropImgView.size.height - self.cropImgView.startPoint.y)) * scalingFactor)
var croppedRect: NSRect = NSRect(x: xPos, y: yPos, width: width, height: height)
let imageRef = image.cgImage(forProposedRect: &croppedRect, context: nil, hints: nil)
guard let croppedImage = imageRef?.cropping(to: croppedRect) else {return Data()}
let imageWithNewSize = NSImage(cgImage: croppedImage, size: NSSize(width: width, height: height))
guard let data = imageWithNewSize.tiffRepresentation,
let rep = NSBitmapImageRep(data: data),
let imgData = rep.representation(using: .png, properties: [.compressionFactor: NSNumber(floatLiteral: 0.25)]) else {
return imageWithNewSize.tiffRepresentation ?? Data()
}
return imgData
}
}
With this cropping logic i am getting this output:
I think as image is AspectFill thats why its not getting cropped in perfect size as per selected frame. Here if you look at output: xpositon & width & heights are not perfect. Or probably i am not calculating these co-ordinates properly. Let me know the faults probably i am calculating someting wrong.
Note: the CropImageView class in the question is a subclass of NSImageView but the view is layer-hosting and the image is drawn by the layer, not by NSImageView. imageScaling is not used.
When deciding which scaling factor to use you have to take the size of the image view into account. If the image size is width:120, height:100 and the image view size is width:120, height 80 then image.size.width >= image.size.height is true and image.size.width/cropImgView.size.width is 1 but the image is scaled because image.size.height/cropImgView.size.height is 1.25. Calculate the horizontal and vertical scaling factors and use the largest.
See How to crop a UIImageView to a new UIImage in 'aspect fill' mode?
Here's the calculation of croppedRect assuming cropImgView.size returns self.layer!.bounds.size.
var scalingWidthFactor: CGFloat = image.size.width/cropImgView.size.width
var scalingHeightFactor: CGFloat = image.size.height/cropImgView.size.height
var xOffset: CGFloat = 0
var yOffset: CGFloat = 0
switch cropImgView.layer?.contentsGravity {
case CALayerContentsGravity.resize: break
case CALayerContentsGravity.resizeAspect:
if scalingWidthFactor > scalingHeightFactor {
scalingHeightFactor = scalingWidthFactor
yOffset = (cropImgView.size.height - (image.size.height / scalingHeightFactor)) / 2
}
else {
scalingWidthFactor = scalingHeightFactor
xOffset = (cropImgView.size.width - (image.size.width / scalingWidthFactor)) / 2
}
case CALayerContentsGravity.resizeAspectFill:
if scalingWidthFactor < scalingHeightFactor {
scalingHeightFactor = scalingWidthFactor
yOffset = (cropImgView.size.height - (image.size.height / scalingHeightFactor)) / 2
}
else {
scalingWidthFactor = scalingHeightFactor
xOffset = (cropImgView.size.width - (image.size.width / scalingWidthFactor)) / 2
}
default:
print("contentsGravity \(String(describing: cropImgView.layer?.contentsGravity)) is not supported")
return nil
}
let width = (self.cropImgView.finalPoint.x - self.cropImgView.startPoint.x) * scalingWidthFactor
let height = (self.cropImgView.startPoint.y - self.cropImgView.finalPoint.y) * scalingHeightFactor
let xPos = (self.cropImgView.startPoint.x - xOffset) * scalingWidthFactor
let yPos = (cropImgView.size.height - self.cropImgView.startPoint.y - yOffset) * scalingHeightFactor
var croppedRect: NSRect = NSRect(x: xPos, y: yPos, width: width, height: height)
Bugfix: cropImgView.finalPoint should be the corner of the selection, not the location of mouseUp. In CropImageView set self.finalPoint = newPoint in mouseDragged instead of mouseUp.

CABasicAnimation to emulate a 'pulse' effect animation on a non-circle shape

I am using CBasicAnimation to create a pulsating effect on a button.
The effect pulses out the shape of a UIView, with border only.
While the animation works properly, I am not getting the desired effect using CABasicAnimation(keyPath: "transform.scale").
I am using an animation group with 3 animations: borderWidth, transform.scale and opacity.
class Pulsing: CALayer {
var animationGroup = CAAnimationGroup()
var initialPulseScale:Float = 1
var nextPulseAfter:TimeInterval = 0
var animationDuration:TimeInterval = 1.5
var numberOfPulses:Float = Float.infinity
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
init (numberOfPulses:Float = Float.infinity, position:CGPoint, pulseFromView:UIView, rounded: CGFloat) {
super.init()
self.borderColor = UIColor.black.cgColor
self.contentsScale = UIScreen.main.scale
self.opacity = 1
self.numberOfPulses = numberOfPulses
self.position = position
self.bounds = CGRect(x: 0, y: 0, width: pulseFromView.frame.width, height: pulseFromView.frame.height)
self.cornerRadius = rounded
DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
self.setupAnimationGroup(view: pulseFromView)
DispatchQueue.main.async {
self.add(self.animationGroup, forKey: "pulse")
}
}
}
func borderWidthAnimation() -> CABasicAnimation {
let widthAnimation = CABasicAnimation(keyPath: "borderWidth")
widthAnimation.fromValue = 2
widthAnimation.toValue = 0.5
widthAnimation.duration = animationDuration
return widthAnimation
}
func createScaleAnimation (view:UIView) -> CABasicAnimation {
let scale = CABasicAnimation(keyPath: "transform.scale")
DispatchQueue.main.async {
scale.fromValue = view.layer.value(forKeyPath: "transform.scale")
}
scale.toValue = NSNumber(value: 1.1)
scale.duration = 1.0
scale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
return scale
}
func createOpacityAnimation() -> CABasicAnimation {
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.duration = animationDuration
opacityAnimation.fromValue = 1
opacityAnimation.toValue = 0
opacityAnimation.fillMode = .removed
return opacityAnimation
}
func setupAnimationGroup(view:UIView) {
self.animationGroup = CAAnimationGroup()
self.animationGroup.duration = animationDuration + nextPulseAfter
self.animationGroup.repeatCount = numberOfPulses
self.animationGroup.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.default)
self.animationGroup.animations = [createScaleAnimation(view: view), borderWidthAnimation(), createOpacityAnimation()]
}
}
class ViewController: UIViewController {
#IBOutlet weak var pulsingView: UIView!
let roundd:CGFloat = 20
override func viewDidLoad() {
super.viewDidLoad()
pulsingView.layer.cornerRadius = roundd
let pulse = Pulsing(
numberOfPulses: .greatestFiniteMagnitude,
position: CGPoint(x: pulsingView.frame.width/2,
y: pulsingView.frame.height/2)
, pulseFromView: pulsingView, rounded: roundd)
pulse.zPosition = -10
self.pulsingView.layer.insertSublayer(pulse, at: 0)
}
}
My problem is transform.scale is maintaining the aspect ratio of the UIView it's pulsating from during the animation.
How can I make the pulse grow so there's uniform spacing on both the height and the width? See screenshot.
Scaling the width and height by the same factor is going to result in unequal spacing around the edges. You need to increase the layer's width and height by the same value. This is an addition operation, not multiplication. Now, for this pulsating effect you need to animate the layer's bounds.
If you want the spacing between the edges to be dynamic, then pick a scale factor and apply it to a single dimension. Whether you choose the width or the the height doesn't matter so long as it's only applied to one. Let's say you choose the width to grow by a factor of 1.1. Compute your target width, then compute the delta.
let scaleFactor: CGFloat = 1.1
let targetWidth = view.bounds.size.width * scaleFactor
let delta = targetWidth - view.bounds.size.width
Once you have your delta, apply it to the layer's bounds in the x and the y dimension. Take advantage of the insetBy(dx:) method to compute the resulting rectangle.
let targetBounds = self.bounds.insetBy(dx: -delta / 2, dy: -delta / 2)
For clarity's sake, I've renamed your createScaleAnimation(view:) method to createExpansionAnimation(view:). Tying it all together we have:
func createExpansionAnimation(view: UIView) -> CABasicAnimation {
let anim = CABasicAnimation(keyPath: "bounds")
DispatchQueue.main.async {
let scaleFactor: CGFloat = 1.1
let targetWidth = view.bounds.size.width * scaleFactor
let delta = targetWidth - view.bounds.size.width
let targetBounds = self.bounds.insetBy(dx: -delta / 2, dy: -delta / 2)
anim.duration = 1.0
anim.fromValue = NSValue(cgRect: self.bounds)
anim.toValue = NSValue(cgRect: targetBounds)
}
return anim
}

Im not able to give a new corner radius to my view

I'm creating some circles in a view, and I want to resize them to their previous position if they are somewhere near the next circle when I resize them with gestures on sender.state == .ended, they resize but it doesn't change the corner radius I want o give them on resize.
let circle = UIView(frame: CGRect(x: 0.0, y: 0.0, width: a, height: a))
circle.center = self.view.center
circle.layer.cornerRadius = CGFloat(cornerRad)
circle.backgroundColor = UIColor.black.withAlphaComponent(0.0)
circle.layer.borderWidth = CGFloat(b)
circle.layer.borderColor = UIColor.red.cgColor
circle.clipsToBounds = true
let pinchCircle = UIPinchGestureRecognizer(target: self, action: #selector(changeSize))
//let longPressCircle = UILongPressGestureRecognizer(target: self, action: #selector(erraseCircle))
circle.addGestureRecognizer(pinchCircle)
//circle.addGestureRecognizer(longPressCircle)
self.view.addSubview(circle)
arrayShapes.append(circle)
if let view = sender.view {
view.transform = CGAffineTransform(scaleX: sender.scale, y: sender.scale)
let maxScale: CGFloat = 300 //Anything
let minScale: CGFloat = 0.5 //Anything
let scale = view.frame.size.width
if scale > maxScale {
view.transform = CGAffineTransform(scaleX: maxScale / scale, y: maxScale / scale)
arrFin[pos] = Double(view.frame.size.width)
}
else if scale < minScale {
view.transform = CGAffineTransform(scaleX: minScale / scale, y: minScale / scale)
arrFin[pos] = Double(view.frame.size.width)
} else {
arrFin[pos] = Double(view.frame.size.width)
}
for (i, _) in arrayShapes.enumerated() {
if arrayShapes[i].frame.width < view.frame.size.width{
self.view.sendSubview(toBack: view)
}
}
if sender.state == .ended {
for (z, _) in arrFin.enumerated() where z != pos{
let sum = arrFin[pos] - arrFin[z]
let res = arrFin[z] - arrFin[pos]
if arrFin[pos] == arrFin[z] || sum <= 20 || res >= 20{
arrFin[pos] = arrPos[pos]
sender.view?.layer.masksToBounds = true
sender.view?.layer.cornerRadius = CGFloat(arrPos[pos]) / 2
sender.view?.frame.size.width = CGFloat(arrPos[pos])
sender.view?.frame.size.height = CGFloat(arrPos[pos])
sender.view?.center = self.view.center
print(arrPos[pos])
break
}
}
}
The solution was using sender.view?.transform = CGAffineTransform.identity on the for loop instead of trying to force the size.
The problem was, that i was trying to resize to previous values but my scaling was messing those numbers up.

UIScrollView Zooming & contentInset

Simliar to iOS Photos App where the user is zooming in and out of an image by pinching:
UIView > UIScrollView > UIImageView > UIImage
Initially, I had the issue of zooming below scale 1: image being off centered. I got it fixed by doing this:
func scrollViewDidZoom(scrollView: UIScrollView) {
let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) * 0.5, 0)
let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) * 0.5, 0)
scrollView.contentInset = UIEdgeInsetsMake(offsetY, offsetX, 0, 0)
}
This works well when zooming out.
UIImage content mode is aspectFit
Issue
When I ZOOM IN, when zoomScale is above 1, scroll view insets need to hug the surroundings of the UIImage that the scroll view contains. This takes away the dead-space that was surrounding the UIImage. IE, Photos app when zooming-in by pinching or double tapping.
Tried
func scrollViewDidZoom(scrollView: UIScrollView) {
if scrollView.zoomScale > 1 {
let imageScale = (self.imageView.bounds.width/self.imageView.image!.size.width)
let imageWidth = self.imageView.image!.size.width * imageScale
let imageHeight = self.imageView.image!.size.height * imageScale
scrollView.contentInset = UIEdgeInsetsMake(((scrollView.frame.height - imageHeight) * 0.5), (scrollView.frame.width - imageWidth) * 0.5 , 0, 0)
print (scrollView.contentInset.top)
}
else {
let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) * 0.5, 0)
let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) * 0.5, 0)
scrollView.contentInset = UIEdgeInsetsMake(offsetY, offsetX, 0, 0)
}
}
Above addition seems to vary the inset amount still.
Update (images added)
First image shows the default layout. Rest shows when zoomed in.....
Your approach looks correct. You need to update your code as below.
func scrollViewDidZoom(scrollView: UIScrollView) {
if scrollView.zoomScale > 1 {
if let image = imageView.image {
let ratioW = imageView.frame.width / image.size.width
let ratioH = imageView.frame.height / image.size.height
let ratio = ratioW < ratioH ? ratioW:ratioH
let newWidth = image.size.width*ratio
let newHeight = image.size.height*ratio
let left = 0.5 * (newWidth * scrollView.zoomScale > imageView.frame.width ? (newWidth - imageView.frame.width) : (scrollView.frame.width - scrollView.contentSize.width))
let top = 0.5 * (newHeight * scrollView.zoomScale > imageView.frame.height ? (newHeight - imageView.frame.height) : (scrollView.frame.height - scrollView.contentSize.height))
scrollView.contentInset = UIEdgeInsetsMake(top, left, top, left)
}
} else {
scrollView.contentInset = UIEdgeInsetsZero
}
}
Swift 5
func scrollViewDidZoom(scrollView: UIScrollView) {
if scrollView.zoomScale > 1 {
if let image = imageView.image {
let ratioW = imageView.frame.width / image.size.width
let ratioH = imageView.frame.height / image.size.height
let ratio = ratioW < ratioH ? ratioW:ratioH
let newWidth = image.size.width*ratio
let newHeight = image.size.height*ratio
let left = 0.5 * (newWidth * scrollView.zoomScale > imageView.frame.width ? (newWidth - imageView.frame.width) : (scrollView.frame.width - scrollView.contentSize.width))
let top = 0.5 * (newHeight * scrollView.zoomScale > imageView.frame.height ? (newHeight - imageView.frame.height) : (scrollView.frame.height - scrollView.contentSize.height))
scrollView.contentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left)
}
} else {
scrollView.contentInset = .zero
}
}
Swift 5
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if scrollView.zoomScale > 1 {
if let image = imageView.image {
let ratioW = imageView.frame.width / image.size.width
let ratioH = imageView.frame.height / image.size.height
let ratio = ratioW < ratioH ? ratioW : ratioH
let newWidth = image.size.width * ratio
let newHeight = image.size.height * ratio
let left = 0.5 * (newWidth * scrollView.zoomScale > imageView.frame.width ? (newWidth - imageView.frame.width) : (scrollView.frame.width - scrollView.contentSize.width))
let top = 0.5 * (newHeight * scrollView.zoomScale > imageView.frame.height ? (newHeight - imageView.frame.height) : (scrollView.frame.height - scrollView.contentSize.height))
scrollView.contentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left)
}
} else {
scrollView.contentInset = UIEdgeInsets.zero
}
}
for swift 4
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if scrollView.zoomScale > 1 {
if let image = self.imageViewZoom.image {
let ratioW = self.imageViewZoom.frame.width / image.size.width
let ratioH = self.imageViewZoom.frame.height / image.size.height
let ratio = ratioW < ratioH ? ratioW:ratioH
let newWidth = image.size.width*ratio
let newHeight = image.size.height*ratio
let left = 0.5 * (newWidth * scrollView.zoomScale > self.imageViewZoom.frame.width ? (newWidth - self.imageViewZoom.frame.width) : (scrollView.frame.width - scrollView.contentSize.width))
let top = 0.5 * (newHeight * scrollView.zoomScale > self.imageViewZoom.frame.height ? (newHeight - self.imageViewZoom.frame.height) : (scrollView.frame.height - scrollView.contentSize.height))
scrollView.contentInset = UIEdgeInsetsMake(top, left, top, left)
}
} else {
scrollView.contentInset = UIEdgeInsets.zero
}
}

Keep zoomable image in center of UIScrollView

In my iPhone app, I need to provide the user with an ability to zoom/pan a large-ish image on the screen. This is quite simple: I use UIScrollView, set max/min scale factors and zooming/panning works as expected. Here's where things get interesting. The image is a dynamic one, received from a server. It can have any dimensions. When the image first loads, it's scaled down (if needed) to fit completely into the UIScrollView and is centered in the scroll view - the screenshot is below:
Because the proportions of the image are different from those of the scroll view, there's white space added above and below the image so that the image is centered. However when I start zooming the image, the actual image becomes large enough to fill the whole of the scrollview viewport, therefore white paddings at top/bottom are not needed anymore, however they remain there, as can be seen from this screenshot:
I believe this is due to the fact that the UIImageView containing the image is automatically sized to fill the whole of UIScrollView and when zoomed, it just grows proportionally. It has scale mode set to Aspect Fit. UIScrollView's delegate viewForZoomingInScrollView simply returns the image view.
I attempted to recalculate and re-set UIScrollView, contentSize and image view's size in scrollViewDidEndZooming method:
CGSize imgViewSize = imageView.frame.size;
CGSize imageSize = imageView.image.size;
CGSize realImgSize;
if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
realImgSize = CGSizeMake(imgViewSize.width, imgViewSize.width / imageSize.width * imageSize.height);
}
else {
realImgSize = CGSizeMake(imgViewSize.height / imageSize.height * imageSize.width, imgViewSize.height);
}
scrollView.contentSize = realImgSize;
CGRect fr = CGRectMake(0, 0, 0, 0);
fr.size = realImgSize;
imageView.frame = fr;
However this was only making things worse (with bounds still being there but panning not working in the vertical direction).
Is there any way to automatically reduce that whitespace as it becomes unneeded and then increment again during zoom-in? I suspect the work will need to be done in scrollViewDidEndZooming, but I'm not too sure what that code needs to be.
Awesome!
Thanks for the code :)
Just thought I'd add to this as I changed it slightly to improve the behaviour.
// make the change during scrollViewDidScroll instead of didEndScrolling...
-(void)scrollViewDidZoom:(UIScrollView *)scrollView
{
CGSize imgViewSize = self.imageView.frame.size;
CGSize imageSize = self.imageView.image.size;
CGSize realImgSize;
if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
realImgSize = CGSizeMake(imgViewSize.width, imgViewSize.width / imageSize.width * imageSize.height);
}
else {
realImgSize = CGSizeMake(imgViewSize.height / imageSize.height * imageSize.width, imgViewSize.height);
}
CGRect fr = CGRectMake(0, 0, 0, 0);
fr.size = realImgSize;
self.imageView.frame = fr;
CGSize scrSize = scrollView.frame.size;
float offx = (scrSize.width > realImgSize.width ? (scrSize.width - realImgSize.width) / 2 : 0);
float offy = (scrSize.height > realImgSize.height ? (scrSize.height - realImgSize.height) / 2 : 0);
// don't animate the change.
scrollView.contentInset = UIEdgeInsetsMake(offy, offx, offy, offx);
}
Here's my solution that works universally with any tab bar or navigation bar combination or w/o both, translucent or not.
- (void)scrollViewDidZoom:(UIScrollView *)scrollView {
// The scroll view has zoomed, so you need to re-center the contents
CGSize scrollViewSize = [self scrollViewVisibleSize];
// First assume that image center coincides with the contents box center.
// This is correct when the image is bigger than scrollView due to zoom
CGPoint imageCenter = CGPointMake(self.scrollView.contentSize.width/2.0,
self.scrollView.contentSize.height/2.0);
CGPoint scrollViewCenter = [self scrollViewCenter];
//if image is smaller than the scrollView visible size - fix the image center accordingly
if (self.scrollView.contentSize.width < scrollViewSize.width) {
imageCenter.x = scrollViewCenter.x;
}
if (self.scrollView.contentSize.height < scrollViewSize.height) {
imageCenter.y = scrollViewCenter.y;
}
self.imageView.center = imageCenter;
}
//return the scroll view center
- (CGPoint)scrollViewCenter {
CGSize scrollViewSize = [self scrollViewVisibleSize];
return CGPointMake(scrollViewSize.width/2.0, scrollViewSize.height/2.0);
}
// Return scrollview size without the area overlapping with tab and nav bar.
- (CGSize) scrollViewVisibleSize {
UIEdgeInsets contentInset = self.scrollView.contentInset;
CGSize scrollViewSize = CGRectStandardize(self.scrollView.bounds).size;
CGFloat width = scrollViewSize.width - contentInset.left - contentInset.right;
CGFloat height = scrollViewSize.height - contentInset.top - contentInset.bottom;
return CGSizeMake(width, height);
}
Swift 5:
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
centerScrollViewContents()
}
private var scrollViewVisibleSize: CGSize {
let contentInset = scrollView.contentInset
let scrollViewSize = scrollView.bounds.standardized.size
let width = scrollViewSize.width - contentInset.left - contentInset.right
let height = scrollViewSize.height - contentInset.top - contentInset.bottom
return CGSize(width:width, height:height)
}
private var scrollViewCenter: CGPoint {
let scrollViewSize = self.scrollViewVisibleSize()
return CGPoint(x: scrollViewSize.width / 2.0,
y: scrollViewSize.height / 2.0)
}
private func centerScrollViewContents() {
guard let image = imageView.image else {
return
}
let imgViewSize = imageView.frame.size
let imageSize = image.size
var realImgSize: CGSize
if imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height {
realImgSize = CGSize(width: imgViewSize.width,height: imgViewSize.width / imageSize.width * imageSize.height)
} else {
realImgSize = CGSize(width: imgViewSize.height / imageSize.height * imageSize.width, height: imgViewSize.height)
}
var frame = CGRect.zero
frame.size = realImgSize
imageView.frame = frame
let screenSize = scrollView.frame.size
let offx = screenSize.width > realImgSize.width ? (screenSize.width - realImgSize.width) / 2 : 0
let offy = screenSize.height > realImgSize.height ? (screenSize.height - realImgSize.height) / 2 : 0
scrollView.contentInset = UIEdgeInsets(top: offy,
left: offx,
bottom: offy,
right: offx)
// The scroll view has zoomed, so you need to re-center the contents
let scrollViewSize = scrollViewVisibleSize
// First assume that image center coincides with the contents box center.
// This is correct when the image is bigger than scrollView due to zoom
var imageCenter = CGPoint(x: scrollView.contentSize.width / 2.0,
y: scrollView.contentSize.height / 2.0)
let center = scrollViewCenter
//if image is smaller than the scrollView visible size - fix the image center accordingly
if scrollView.contentSize.width < scrollViewSize.width {
imageCenter.x = center.x
}
if scrollView.contentSize.height < scrollViewSize.height {
imageCenter.y = center.y
}
imageView.center = imageCenter
}
Why it's better than anything else I could find on SO so far:
It doesn't read or modify the UIView frame property of the image view since a zoomed image view has a transform applied to it. See here what Apple says on how to move or adjust a view size when a non identity transform is applied.
Starting iOS 7 where translucency for bars was introduced the system will auto adjust the scroll view size, scroll content insets and scroll indicators offsets. Thus you should not modify these in your code as well.
FYI:
There're check boxes for toggling this behavior (which is set by default) in the Xcode interface builder. You can find it in the view controller attributes:
The full view controller's source code is published here.
Also you can download the whole Xcode project to see the scroll view constraints setup and play around with 3 different presets in the storyboard by moving the initial controller pointer to any the following paths:
View with both translucent tab and nav bars.
View with both opaque tab and nav bars.
View with no bars at all.
Every option works correctly with the same VC implementation.
I think I got it. The solution is to use the scrollViewDidEndZooming method of the delegate and in that method set contentInset based on the size of the image. Here's what the method looks like:
- (void)scrollViewDidEndZooming:(UIScrollView *)aScrollView withView:(UIView *)view atScale:(float)scale {
CGSize imgViewSize = imageView.frame.size;
CGSize imageSize = imageView.image.size;
CGSize realImgSize;
if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
realImgSize = CGSizeMake(imgViewSize.width, imgViewSize.width / imageSize.width * imageSize.height);
}
else {
realImgSize = CGSizeMake(imgViewSize.height / imageSize.height * imageSize.width, imgViewSize.height);
}
CGRect fr = CGRectMake(0, 0, 0, 0);
fr.size = realImgSize;
imageView.frame = fr;
CGSize scrSize = scrollView.frame.size;
float offx = (scrSize.width > realImgSize.width ? (scrSize.width - realImgSize.width) / 2 : 0);
float offy = (scrSize.height > realImgSize.height ? (scrSize.height - realImgSize.height) / 2 : 0);
[UIView beginAnimations:nil context:nil];
[UIView setAnimationDuration:0.25];
scrollView.contentInset = UIEdgeInsetsMake(offy, offx, offy, offx);
[UIView commitAnimations];
}
Note that I'm using animation on setting the inset, otherwise the image jumps inside the scrollview when the insets are added. With animation it slides to the center. I'm using UIView beginAnimation and commitAnimation instead of animation block, because I need to have the app run on iphone 3.
Here is the swift 3 version of Genk's Answer
func scrollViewDidZoom(_ scrollView: UIScrollView){
let imgViewSize:CGSize! = self.imageView.frame.size;
let imageSize:CGSize! = self.imageView.image?.size;
var realImgSize : CGSize;
if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
realImgSize = CGSize(width: imgViewSize.width,height: imgViewSize.width / imageSize.width * imageSize.height);
}
else {
realImgSize = CGSize(width: imgViewSize.height / imageSize.height * imageSize.width, height: imgViewSize.height);
}
var fr:CGRect = CGRect.zero
fr.size = realImgSize;
self.imageView.frame = fr;
let scrSize:CGSize = scrollView.frame.size;
let offx:CGFloat = (scrSize.width > realImgSize.width ? (scrSize.width - realImgSize.width) / 2 : 0);
let offy:CGFloat = (scrSize.height > realImgSize.height ? (scrSize.height - realImgSize.height) / 2 : 0);
scrollView.contentInset = UIEdgeInsetsMake(offy, offx, offy, offx);
// The scroll view has zoomed, so you need to re-center the contents
let scrollViewSize:CGSize = self.scrollViewVisibleSize();
// First assume that image center coincides with the contents box center.
// This is correct when the image is bigger than scrollView due to zoom
var imageCenter:CGPoint = CGPoint(x: self.scrollView.contentSize.width/2.0, y:
self.scrollView.contentSize.height/2.0);
let scrollViewCenter:CGPoint = self.scrollViewCenter()
//if image is smaller than the scrollView visible size - fix the image center accordingly
if (self.scrollView.contentSize.width < scrollViewSize.width) {
imageCenter.x = scrollViewCenter.x;
}
if (self.scrollView.contentSize.height < scrollViewSize.height) {
imageCenter.y = scrollViewCenter.y;
}
self.imageView.center = imageCenter;
}
//return the scroll view center
func scrollViewCenter() -> CGPoint {
let scrollViewSize:CGSize = self.scrollViewVisibleSize()
return CGPoint(x: scrollViewSize.width/2.0, y: scrollViewSize.height/2.0);
}
// Return scrollview size without the area overlapping with tab and nav bar.
func scrollViewVisibleSize() -> CGSize{
let contentInset:UIEdgeInsets = self.scrollView.contentInset;
let scrollViewSize:CGSize = self.scrollView.bounds.standardized.size;
let width:CGFloat = scrollViewSize.width - contentInset.left - contentInset.right;
let height:CGFloat = scrollViewSize.height - contentInset.top - contentInset.bottom;
return CGSize(width:width, height:height);
}
Here is an extension tested on Swift 3.1. Just create a separate *.swift file and paste the code below:
import UIKit
extension UIScrollView {
func applyZoomToImageView() {
guard let imageView = delegate?.viewForZooming?(in: self) as? UIImageView else { return }
guard let image = imageView.image else { return }
guard imageView.frame.size.valid && image.size.valid else { return }
let size = image.size ~> imageView.frame.size
imageView.frame.size = size
self.contentInset = UIEdgeInsets(
x: self.frame.size.width ~> size.width,
y: self.frame.size.height ~> size.height
)
imageView.center = self.contentCenter
if self.contentSize.width < self.visibleSize.width {
imageView.center.x = self.visibleSize.center.x
}
if self.contentSize.height < self.visibleSize.height {
imageView.center.y = self.visibleSize.center.y
}
}
private var contentCenter: CGPoint {
return CGPoint(x: contentSize.width / 2, y: contentSize.height / 2)
}
private var visibleSize: CGSize {
let size: CGSize = bounds.standardized.size
return CGSize(
width: size.width - contentInset.left - contentInset.right,
height: size.height - contentInset.top - contentInset.bottom
)
}
}
fileprivate extension CGFloat {
static func ~>(lhs: CGFloat, rhs: CGFloat) -> CGFloat {
return lhs > rhs ? (lhs - rhs) / 2 : 0.0
}
}
fileprivate extension UIEdgeInsets {
init(x: CGFloat, y: CGFloat) {
self.bottom = y
self.left = x
self.right = x
self.top = y
}
}
fileprivate extension CGSize {
var valid: Bool {
return width > 0 && height > 0
}
var center: CGPoint {
return CGPoint(x: width / 2, y: height / 2)
}
static func ~>(lhs: CGSize, rhs: CGSize) -> CGSize {
switch lhs > rhs {
case true:
return CGSize(width: rhs.width, height: rhs.width / lhs.width * lhs.height)
default:
return CGSize(width: rhs.height / lhs.height * lhs.width, height: rhs.height)
}
}
static func >(lhs: CGSize, rhs: CGSize) -> Bool {
return lhs.width / lhs.height > rhs.width / rhs.height
}
}
The way to use:
extension YOUR_SCROLL_VIEW_DELEGATE: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return YOUR_IMAGE_VIEW
}
func scrollViewDidZoom(_ scrollView: UIScrollView){
scrollView.applyZoomToImageView()
}
}
Analogous to different answers based on setting contentInset, but shorter. Remember about setting scrollView.delegate.
func scrollViewDidZoom(_ scrollView: UIScrollView) {
let offsetX = max((scrollView.bounds.size.width - scrollView.contentSize.width) * 0.5, 0.0)
let offsetY = max((scrollView.bounds.size.height - scrollView.contentSize.height) * 0.5, 0.0)
scrollView.contentInset = UIEdgeInsets(top: offsetY, left: offsetX, bottom: offsetY, right: offsetX);
}
If you want to take a look on a few different strategies, here is a place worth to look: github & post.