Keep zoomable image in center of UIScrollView - iphone

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.

Related

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
}
}

making an image the full size of a scrollview

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);

Animating UILabel Font Size Change

I am currently making an application that uses a custom View Controller container. Multiple views are on the screen at one time and when one is tapped, the selected view controller animates to full screen. In doing so, the selected view controllers subviews scale as well (frame, font size, etc.) Though, UILabel's font property is not animatable leading to issues. I have tried multiple solutions but all flat out suck.
The solutions I have tried are:
Take a screenshot of the larger view and animating the change (similar to how Flipboard does)
Animate by using the transform property
Zooming out a UIScrollView and zooming it in when brought to full screen.
Setting adjustsFontSizeToFitWidth to YES and setting the fontSize prior to animation
One has been the best solution so far but I am not satisfied with it.
I'm looking for other suggestions if anyone has any or a UILabel substitue that animates smoothly using [UIView animate..].
Here is a good example that is similar to what I would like my UILabel to do:
http://www.cocoawithlove.com/2010/09/zoomingviewcontroller-to-animate-uiview.html
EDIT: This code works
// Load View
self.label = [[UILabel alloc] init];
self.label.text = #"TEXT";
self.label.font = [UIFont boldSystemFontOfSize:20.0];
self.label.backgroundColor = [UIColor clearColor];
[self.label sizeToFit];
[self.view addSubview:self.label];
// Animation
self.label.font = [UIFont boldSystemFontOfSize:80.0];
self.label.transform = CGAffineTransformScale(self.label.transform, .25, .25);
[self.label sizeToFit];
[UIView animateWithDuration:1.0 animations:^{
self.label.transform = CGAffineTransformScale(self.label.transform, 4.0, 4.0);
self.label.center = self.view.center;
} completion:^(BOOL finished) {
self.label.font = [UIFont boldSystemFontOfSize:80.0];
self.label.transform = CGAffineTransformScale(self.label.transform, 1.0, 1.0);
[self.label sizeToFit];
}];
You can change the size and font of your UILabel with animation like below .. here I just put the example of how to change the font of UILabel with transform Animation ..
yourLabel.font = [UIFont boldSystemFontOfSize:35]; // set font size which you want instead of 35
yourLabel.transform = CGAffineTransformScale(yourLabel.transform, 0.35, 0.35);
[UIView animateWithDuration:1.0 animations:^{
yourLabel.transform = CGAffineTransformScale(yourLabel.transform, 5, 5);
}];
For 2017 onwards....
Swift 3.0, 4.0
UIView.animate(withDuration: 0.5) {
label.transform = CGAffineTransform(scaleX: 1.1, y: 1.1) //Scale label area
}
Critical:
The critical point to avoid blurring is you must begin with the biggest size, and shrink it. Then expand to "1" when needed.
For quick "pops" (like a highlight animation) it's OK to expand beyond 1 but if you are transitioning between two sizes, make the larger size the "correct" normal one.
I've created UILabel extension in Swift.
import UIKit
extension UILabel {
func animate(font: UIFont, duration: TimeInterval) {
// let oldFrame = frame
let labelScale = self.font.pointSize / font.pointSize
self.font = font
let oldTransform = transform
transform = transform.scaledBy(x: labelScale, y: labelScale)
// let newOrigin = frame.origin
// frame.origin = oldFrame.origin // only for left aligned text
// frame.origin = CGPoint(x: oldFrame.origin.x + oldFrame.width - frame.width, y: oldFrame.origin.y) // only for right aligned text
setNeedsUpdateConstraints()
UIView.animate(withDuration: duration) {
//L self.frame.origin = newOrigin
self.transform = oldTransform
self.layoutIfNeeded()
}
}
}
Uncomment lines if the label text is left or right aligned.
You could also use CATextLayer which has fontSize as an animatable property.
let startFontSize: CGFloat = 20
let endFontSize: CGFloat = 80
let textLayer = CATextLayer()
textLayer.string = "yourText"
textLayer.font = yourLabel.font.fontName as CFTypeRef?
textLayer.fontSize = startFontSize
textLayer.foregroundColor = UIColor.black.cgColor
textLayer.contentsScale = UIScreen.main.scale //for some reason CATextLayer by default only works for 1x screen resolution and needs this line to work properly on 2x, 3x, etc. ...
textLayer.frame = parentView.bounds
parentView.layer.addSublayer(textLayer)
//animation:
let duration: TimeInterval = 1
textLayer.fontSize = endFontSize //because upon completion of the animation CABasicAnimation resets the animated CALayer to its original state (as opposed to changing its properties to the end state of the animation), setting fontSize to endFontSize right BEFORE the animation starts ensures the fontSize doesn't jump back right after the animation.
let fontSizeAnimation = CABasicAnimation(keyPath: "fontSize")
fontSizeAnimation.fromValue = startFontSize
fontSizeAnimation.toValue = endFontSize
fontSizeAnimation.duration = duration
fontSizeAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
textLayer.add(fontSizeAnimation, forKey: nil)
I used it in my project: https://github.com/yinanq/AngelListJobs
This animation keeps the font top left aligned (unlike CGAffineTransformScale scaling the label from center), pro or con depending on your needs. A disadvantage of CATextLayer is that CALayers don't work with autolayout constraint animation (which I happened to need and solved it by making a UIView containing just the CATextLayer and animating its constraints).
For those not looking for a transform, but actual value change:
UIView.transition(with: label, duration: 0.25, options: .transitionCrossDissolve, animations: {
self.label.font = UIFont.systemFont(ofSize: 15)
}) { isFinished in }
For someone who wants to adjust direction of animation
I have created an extension for UILabel to animate font size change
extension UILabel {
func animate(fontSize: CGFloat, duration: TimeInterval) {
let startTransform = transform
let oldFrame = frame
var newFrame = oldFrame
let scaleRatio = fontSize / font.pointSize
newFrame.size.width *= scaleRatio
newFrame.size.height *= scaleRatio
newFrame.origin.x = oldFrame.origin.x - (newFrame.size.width - oldFrame.size.width) * 0.5
newFrame.origin.y = oldFrame.origin.y - (newFrame.size.height - oldFrame.size.height) * 0.5
frame = newFrame
font = font.withSize(fontSize)
transform = CGAffineTransform.init(scaleX: 1 / scaleRatio, y: 1 / scaleRatio);
layoutIfNeeded()
UIView.animate(withDuration: duration, animations: {
self.transform = startTransform
newFrame = self.frame
}) { (Bool) in
self.frame = newFrame
}
}
If you want to adjust direction of animation, use below method and put a suitable anchor point.
SWIFT
struct LabelAnimateAnchorPoint {
// You can add more suitable archon point for your needs
static let leadingCenterY = CGPoint.init(x: 0, y: 0.5)
static let trailingCenterY = CGPoint.init(x: 1, y: 0.5)
static let centerXCenterY = CGPoint.init(x: 0.5, y: 0.5)
static let leadingTop = CGPoint.init(x: 0, y: 0)
}
extension UILabel {
func animate(fontSize: CGFloat, duration: TimeInterval, animateAnchorPoint: CGPoint) {
let startTransform = transform
let oldFrame = frame
var newFrame = oldFrame
let archorPoint = layer.anchorPoint
let scaleRatio = fontSize / font.pointSize
layer.anchorPoint = animateAnchorPoint
newFrame.size.width *= scaleRatio
newFrame.size.height *= scaleRatio
newFrame.origin.x = oldFrame.origin.x - (newFrame.size.width - oldFrame.size.width) * animateAnchorPoint.x
newFrame.origin.y = oldFrame.origin.y - (newFrame.size.height - oldFrame.size.height) * animateAnchorPoint.y
frame = newFrame
font = font.withSize(fontSize)
transform = CGAffineTransform.init(scaleX: 1 / scaleRatio, y: 1 / scaleRatio);
layoutIfNeeded()
UIView.animate(withDuration: duration, animations: {
self.transform = startTransform
newFrame = self.frame
}) { (Bool) in
self.layer.anchorPoint = archorPoint
self.frame = newFrame
}
}
}
OBJECTIVE-C
// You can add more suitable archon point for your needs
#define kLeadingCenterYAnchorPoint CGPointMake(0.f, .5f)
#define kTrailingCenterYAnchorPoint CGPointMake(1.f, .5f)
#define kCenterXCenterYAnchorPoint CGPointMake(.5f, .5f)
#define kLeadingTopAnchorPoint CGPointMake(0.f, 0.f)
#implementation UILabel (FontSizeAnimating)
- (void)animateWithFontSize:(CGFloat)fontSize duration:(NSTimeInterval)duration animateAnchorPoint:(CGPoint)animateAnchorPoint {
CGAffineTransform startTransform = self.transform;
CGRect oldFrame = self.frame;
__block CGRect newFrame = oldFrame;
CGPoint archorPoint = self.layer.anchorPoint;
CGFloat scaleRatio = fontSize / self.font.pointSize;
self.layer.anchorPoint = animateAnchorPoint;
newFrame.size.width *= scaleRatio;
newFrame.size.height *= scaleRatio;
newFrame.origin.x = oldFrame.origin.x - (newFrame.size.width - oldFrame.size.width) * animateAnchorPoint.x;
newFrame.origin.y = oldFrame.origin.y - (newFrame.size.height - oldFrame.size.height) * animateAnchorPoint.y;
self.frame = newFrame;
self.font = [self.font fontWithSize:fontSize];
self.transform = CGAffineTransformScale(self.transform, 1.f / scaleRatio, 1.f / scaleRatio);
[self layoutIfNeeded];
[UIView animateWithDuration:duration animations:^{
self.transform = startTransform;
newFrame = self.frame;
} completion:^(BOOL finished) {
self.layer.anchorPoint = archorPoint;
self.frame = newFrame;
}];
}
#end
For example, to animate changing label font size to 30, duration 1s from center and scale bigger. Simply call
SWIFT
YOUR_LABEL.animate(fontSize: 30, duration: 1, animateAnchorPoint: LabelAnimateAnchorPoint.centerXCenterY)
OBJECTIVE-C
[YOUR_LABEL animateWithFontSize:30
duration:1
animateAnchorPoint:kCenterXCenterYAnchorPoint];
Swift 3.0 & Swift 4.0
UIView.animate(withDuration: 0.5, delay: 0.1, options: .curveLinear, animations: {
label.transform = label.transform.scaledBy(x:4,y:4) //Change x,y to get your desired effect.
} ) { (completed) in
//Animation Completed
}
I found each of the suggestions here inadequate for these reasons:
They don't actually change the font size.
They don't play well with frame sizing & auto layout.
Their interface is non-trivial and/or doesn't play nice inside animation blocks.
In order to retain all of these features & still get a smooth animation transition I've combined the transform approach and the font approach.
The interface is simple. Just update the fontSize property and you'll update the font's size. Do this inside an animation block and it'll animate.
#interface UILabel(MPFontSize)
#property(nonatomic) CGFloat fontSize;
#end
As for the implementation, there's the simple way, and there's the better way.
Simple:
#implementation UILabel(MPFontSize)
- (void)setFontSize:(CGFloat)fontSize {
CGAffineTransform originalTransform = self.transform;
UIFont *targetFont = [self.font fontWithSize:fontSize];
[UIView animateWithDuration:0 delay:0 options:0 animations:^{
self.transform = CGAffineTransformScale( originalTransform,
fontSize / self.fontSize, fontSize / self.fontSize );
} completion:^(BOOL finished) {
self.transform = originalTransform;
if (finished)
self.font = targetFont;
}];
}
- (CGFloat)fontSize {
return self.font.pointSize;
};
#end
Now, the problem with this is that the layout can stutter upon completion, because the view's frame is sized based on the original font all the way until the animation completion, at which point the frame updates to accommodate the target font without animation.
Fixing this problem is a little harder because we need to override intrinsicContentSize. You can do this either by subclassing UILabel or by swizzling the method. I personally swizzle the method, because it lets me keep a generic fontSize property available to all UILabels, but that depends on some library code I can't share here. Here is how you would go about this using subclassing.
Interface:
#interface AnimatableLabel : UILabel
#property(nonatomic) CGFloat fontSize;
#end
Implementation:
#interface AnimatableLabel()
#property(nonatomic) UIFont *targetFont;
#property(nonatomic) UIFont *originalFont;
#end
#implementation AnimatableLabel
- (void)setFontSize:(CGFloat)fontSize {
CGAffineTransform originalTransform = self.transform;
self.originalFont = self.font;
self.targetFont = [self.font fontWithSize:fontSize];
[self invalidateIntrinsicContentSize];
[UIView animateWithDuration:0 delay:0 options:0 animations:^{
self.transform = CGAffineTransformScale( originalTransform,
fontSize / self.fontSize, fontSize / self.fontSize );
} completion:^(BOOL finished) {
self.transform = originalTransform;
if (self.targetFont) {
if (finished)
self.font = self.targetFont;
self.targetFont = self.originalFont = nil;
[self invalidateIntrinsicContentSize];
}
}];
}
- (CGFloat)fontSize {
return self.font.pointSize;
};
- (CGSize)intrinsicContentSize {
#try {
if (self.targetFont)
self.font = self.targetFont;
return self.intrinsicContentSize;
}
#finally {
if (self.originalFont)
self.font = self.originalFont;
}
}
#end
If you want to animate the text size from another anchor point, here is the Swift 5 solution:
How to apply:
yourLabel.setAnimatedFont(.systemFont(ofSize: 48), duration: 0.2, anchorPointX: 0, anchorPointY: 1)
Extensions:
extension UILabel {
/// Animate font size from a given anchor point of the label.
/// - Parameters:
/// - duration: Animation measured in seconds
/// - anchorPointX: 0 = left, 0.5 = center, 1 = right
/// - anchorPointY: 0 = top, 0.5 = center, 1 = bottom
func setAnimatedFont(_ font: UIFont, duration: TimeInterval, anchorPointX: CGFloat, anchorPointY: CGFloat) {
guard let oldFont = self.font else { return }
setAnchorPoint(CGPoint(x: anchorPointX, y: anchorPointY))
self.font = font
let scaleFactor = oldFont.pointSize / font.pointSize
let oldTransform = transform
transform = transform.scaledBy(x: scaleFactor, y: scaleFactor)
setNeedsUpdateConstraints()
UIView.animate(withDuration: duration) {
self.transform = oldTransform
self.layoutIfNeeded()
}
}
}
extension UIView {
/// Change the anchor point without moving the view's position.
/// - Parameters:
/// - point: The layer's bounds rectangle.
func setAnchorPoint(_ point: CGPoint) {
let oldOrigin = frame.origin
layer.anchorPoint = point
let newOrigin = frame.origin
let translation = CGPoint(x: newOrigin.x - oldOrigin.x, y: newOrigin.y - oldOrigin.y)
translatesAutoresizingMaskIntoConstraints = true
center = CGPoint(x: center.x - translation.x, y: center.y - translation.y)
}
}

targetContentOffsetForProposedContentOffset:withScrollingVelocity without subclassing UICollectionViewFlowLayout

I've got a very simple collectionView in my app (just a single row of square thumbnail images).
I'd like to intercept the scrolling so that the offset always leaves a full image at the left side. At the moment it scrolls to wherever and will leave cut off images.
Anyway, I know I need to use the function
- (CGPoint)targetContentOffsetForProposedContentOffset:withScrollingVelocity
to do this but I'm just using a standard UICollectionViewFlowLayout. I'm not subclassing it.
Is there any way of intercepting this without subclassing UICollectionViewFlowLayout?
Thanks
OK, answer is no, there is no way to do this without subclassing UICollectionViewFlowLayout.
However, subclassing it is incredibly easy for anyone who is reading this in the future.
First I set up the subclass call MyCollectionViewFlowLayout and then in interface builder I changed the collection view layout to Custom and selected my flow layout subclass.
Because you're doing it this way you can't specify items sizes, etc... in IB so in MyCollectionViewFlowLayout.m I have this...
- (void)awakeFromNib
{
self.itemSize = CGSizeMake(75.0, 75.0);
self.minimumInteritemSpacing = 10.0;
self.minimumLineSpacing = 10.0;
self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
self.sectionInset = UIEdgeInsetsMake(10.0, 10.0, 10.0, 10.0);
}
This sets up all the sizes for me and the scroll direction.
Then ...
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
{
CGFloat offsetAdjustment = MAXFLOAT;
CGFloat horizontalOffset = proposedContentOffset.x + 5;
CGRect targetRect = CGRectMake(proposedContentOffset.x, 0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height);
NSArray *array = [super layoutAttributesForElementsInRect:targetRect];
for (UICollectionViewLayoutAttributes *layoutAttributes in array) {
CGFloat itemOffset = layoutAttributes.frame.origin.x;
if (ABS(itemOffset - horizontalOffset) < ABS(offsetAdjustment)) {
offsetAdjustment = itemOffset - horizontalOffset;
}
}
return CGPointMake(proposedContentOffset.x + offsetAdjustment, proposedContentOffset.y);
}
This ensures that the scrolling ends with a margin of 5.0 on the left hand edge.
That's all I needed to do. I didn't need to set the flow layout in code at all.
Dan's solution is flawed. It does not handle user flicking well. The cases when user flicks fast and scroll did not move so much, have animation glitches.
My proposed alternative implementation has the same pagination as proposed before, but handles user flicking between pages.
#pragma mark - Pagination
- (CGFloat)pageWidth {
return self.itemSize.width + self.minimumLineSpacing;
}
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
{
CGFloat rawPageValue = self.collectionView.contentOffset.x / self.pageWidth;
CGFloat currentPage = (velocity.x > 0.0) ? floor(rawPageValue) : ceil(rawPageValue);
CGFloat nextPage = (velocity.x > 0.0) ? ceil(rawPageValue) : floor(rawPageValue);
BOOL pannedLessThanAPage = fabs(1 + currentPage - rawPageValue) > 0.5;
BOOL flicked = fabs(velocity.x) > [self flickVelocity];
if (pannedLessThanAPage && flicked) {
proposedContentOffset.x = nextPage * self.pageWidth;
} else {
proposedContentOffset.x = round(rawPageValue) * self.pageWidth;
}
return proposedContentOffset;
}
- (CGFloat)flickVelocity {
return 0.3;
}
Swift version of the accepted answer.
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offsetAdjustment = CGFloat.greatestFiniteMagnitude
let horizontalOffset = proposedContentOffset.x
let targetRect = CGRect(origin: CGPoint(x: proposedContentOffset.x, y: 0), size: self.collectionView!.bounds.size)
for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
let itemOffset = layoutAttributes.frame.origin.x
if (abs(itemOffset - horizontalOffset) < abs(offsetAdjustment)) {
offsetAdjustment = itemOffset - horizontalOffset
}
}
return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
}
Valid for Swift 5.
Here's my implementation in Swift 5 for vertical cell-based paging:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = self.collectionView else {
let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
return latestOffset
}
// Page height used for estimating and calculating paging.
let pageHeight = self.itemSize.height + self.minimumLineSpacing
// Make an estimation of the current page position.
let approximatePage = collectionView.contentOffset.y/pageHeight
// Determine the current page based on velocity.
let currentPage = velocity.y == 0 ? round(approximatePage) : (velocity.y < 0.0 ? floor(approximatePage) : ceil(approximatePage))
// Create custom flickVelocity.
let flickVelocity = velocity.y * 0.3
// Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)
let newVerticalOffset = ((currentPage + flickedPages) * pageHeight) - collectionView.contentInset.top
return CGPoint(x: proposedContentOffset.x, y: newVerticalOffset)
}
Some notes:
Doesn't glitch
SET PAGING TO FALSE! (otherwise this won't work)
Allows you to set your own flickvelocity easily.
If something is still not working after trying this, check if your itemSize actually matches the size of the item as that's often a problem, especially when using collectionView(_:layout:sizeForItemAt:), use a custom variable with the itemSize instead.
This works best when you set self.collectionView.decelerationRate = UIScrollView.DecelerationRate.fast.
Here's a horizontal version (haven't tested it thoroughly so please forgive any mistakes):
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = self.collectionView else {
let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
return latestOffset
}
// Page width used for estimating and calculating paging.
let pageWidth = self.itemSize.width + self.minimumInteritemSpacing
// Make an estimation of the current page position.
let approximatePage = collectionView.contentOffset.x/pageWidth
// Determine the current page based on velocity.
let currentPage = velocity.x == 0 ? round(approximatePage) : (velocity.x < 0.0 ? floor(approximatePage) : ceil(approximatePage))
// Create custom flickVelocity.
let flickVelocity = velocity.x * 0.3
// Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)
// Calculate newHorizontalOffset.
let newHorizontalOffset = ((currentPage + flickedPages) * pageWidth) - collectionView.contentInset.left
return CGPoint(x: newHorizontalOffset, y: proposedContentOffset.y)
}
This code is based on the code I use in my personal project, you can check it out here by downloading it and running the Example target.
For anyone looking for a solution that...
DOES NOT GLITCH when the user performs a short fast scroll (i.e. it considers positive and negative scroll velocities)
takes the collectionView.contentInset (and safeArea on iPhone X) into consideration
only considers thoes cells visible at the point of scrolling (for peformance)
uses well named variables and comments
is Swift 4
then please see below...
public class CarouselCollectionViewLayout: UICollectionViewFlowLayout {
override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else {
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
}
// Identify the layoutAttributes of cells in the vicinity of where the scroll view will come to rest
let targetRect = CGRect(origin: proposedContentOffset, size: collectionView.bounds.size)
let visibleCellsLayoutAttributes = layoutAttributesForElements(in: targetRect)
// Translate those cell layoutAttributes into potential (candidate) scrollView offsets
let candidateOffsets: [CGFloat]? = visibleCellsLayoutAttributes?.map({ cellLayoutAttributes in
if #available(iOS 11.0, *) {
return cellLayoutAttributes.frame.origin.x - collectionView.contentInset.left - collectionView.safeAreaInsets.left - sectionInset.left
} else {
return cellLayoutAttributes.frame.origin.x - collectionView.contentInset.left - sectionInset.left
}
})
// Now we need to work out which one of the candidate offsets is the best one
let bestCandidateOffset: CGFloat
if velocity.x > 0 {
// If the scroll velocity was POSITIVE, then only consider cells/offsets to the RIGHT of the proposedContentOffset.x
// Of the cells/offsets to the right, the NEAREST is the `bestCandidate`
// If there is no nearestCandidateOffsetToLeft then we default to the RIGHT-MOST (last) of ALL the candidate cells/offsets
// (this handles the scenario where the user has scrolled beyond the last cell)
let candidateOffsetsToRight = candidateOffsets?.toRight(ofProposedOffset: proposedContentOffset.x)
let nearestCandidateOffsetToRight = candidateOffsetsToRight?.nearest(toProposedOffset: proposedContentOffset.x)
bestCandidateOffset = nearestCandidateOffsetToRight ?? candidateOffsets?.last ?? proposedContentOffset.x
}
else if velocity.x < 0 {
// If the scroll velocity was NEGATIVE, then only consider cells/offsets to the LEFT of the proposedContentOffset.x
// Of the cells/offsets to the left, the NEAREST is the `bestCandidate`
// If there is no nearestCandidateOffsetToLeft then we default to the LEFT-MOST (first) of ALL the candidate cells/offsets
// (this handles the scenario where the user has scrolled beyond the first cell)
let candidateOffsetsToLeft = candidateOffsets?.toLeft(ofProposedOffset: proposedContentOffset.x)
let nearestCandidateOffsetToLeft = candidateOffsetsToLeft?.nearest(toProposedOffset: proposedContentOffset.x)
bestCandidateOffset = nearestCandidateOffsetToLeft ?? candidateOffsets?.first ?? proposedContentOffset.x
}
else {
// If the scroll velocity was ZERO we consider all `candidate` cells (regarless of whether they are to the left OR right of the proposedContentOffset.x)
// The cell/offset that is the NEAREST is the `bestCandidate`
let nearestCandidateOffset = candidateOffsets?.nearest(toProposedOffset: proposedContentOffset.x)
bestCandidateOffset = nearestCandidateOffset ?? proposedContentOffset.x
}
return CGPoint(x: bestCandidateOffset, y: proposedContentOffset.y)
}
}
fileprivate extension Sequence where Iterator.Element == CGFloat {
func toLeft(ofProposedOffset proposedOffset: CGFloat) -> [CGFloat] {
return filter() { candidateOffset in
return candidateOffset < proposedOffset
}
}
func toRight(ofProposedOffset proposedOffset: CGFloat) -> [CGFloat] {
return filter() { candidateOffset in
return candidateOffset > proposedOffset
}
}
func nearest(toProposedOffset proposedOffset: CGFloat) -> CGFloat? {
guard let firstCandidateOffset = first(where: { _ in true }) else {
// If there are no elements in the Sequence, return nil
return nil
}
return reduce(firstCandidateOffset) { (bestCandidateOffset: CGFloat, candidateOffset: CGFloat) -> CGFloat in
let candidateOffsetDistanceFromProposed = fabs(candidateOffset - proposedOffset)
let bestCandidateOffsetDistancFromProposed = fabs(bestCandidateOffset - proposedOffset)
if candidateOffsetDistanceFromProposed < bestCandidateOffsetDistancFromProposed {
return candidateOffset
}
return bestCandidateOffset
}
}
}
While this answer has been a great help to me, there is a noticeable flicker when you swipe fast on a small distance. It's much easier to reproduce it on the device.
I found that this always happens when collectionView.contentOffset.x - proposedContentOffset.x and velocity.x have different sings.
My solution was to ensure that proposedContentOffset is more than contentOffset.x if velocity is positive, and less if it is negative. It's in C# but should be fairly simple to translate to Objective C:
public override PointF TargetContentOffset (PointF proposedContentOffset, PointF scrollingVelocity)
{
/* Determine closest edge */
float offSetAdjustment = float.MaxValue;
float horizontalCenter = (float) (proposedContentOffset.X + (this.CollectionView.Bounds.Size.Width / 2.0));
RectangleF targetRect = new RectangleF (proposedContentOffset.X, 0.0f, this.CollectionView.Bounds.Size.Width, this.CollectionView.Bounds.Size.Height);
var array = base.LayoutAttributesForElementsInRect (targetRect);
foreach (var layoutAttributes in array) {
float itemHorizontalCenter = layoutAttributes.Center.X;
if (Math.Abs (itemHorizontalCenter - horizontalCenter) < Math.Abs (offSetAdjustment)) {
offSetAdjustment = itemHorizontalCenter - horizontalCenter;
}
}
float nextOffset = proposedContentOffset.X + offSetAdjustment;
/*
* ... unless we end up having positive speed
* while moving left or negative speed while moving right.
* This will cause flicker so we resort to finding next page
* in the direction of velocity and use it.
*/
do {
proposedContentOffset.X = nextOffset;
float deltaX = proposedContentOffset.X - CollectionView.ContentOffset.X;
float velX = scrollingVelocity.X;
// If their signs are same, or if either is zero, go ahead
if (Math.Sign (deltaX) * Math.Sign (velX) != -1)
break;
// Otherwise, look for the closest page in the right direction
nextOffset += Math.Sign (scrollingVelocity.X) * SnapStep;
} while (IsValidOffset (nextOffset));
return proposedContentOffset;
}
bool IsValidOffset (float offset)
{
return (offset >= MinContentOffset && offset <= MaxContentOffset);
}
This code is using MinContentOffset, MaxContentOffset and SnapStep which should be trivial for you to define. In my case they turned out to be
float MinContentOffset {
get { return -CollectionView.ContentInset.Left; }
}
float MaxContentOffset {
get { return MinContentOffset + CollectionView.ContentSize.Width - ItemSize.Width; }
}
float SnapStep {
get { return ItemSize.Width + MinimumLineSpacing; }
}
After long testing I found solution to snap to center with custom cell width (each cell has diff. width) which fixes the flickering. Feel free to improve the script.
- (CGPoint) targetContentOffsetForProposedContentOffset: (CGPoint) proposedContentOffset withScrollingVelocity: (CGPoint)velocity
{
CGFloat offSetAdjustment = MAXFLOAT;
CGFloat horizontalCenter = (CGFloat) (proposedContentOffset.x + (self.collectionView.bounds.size.width / 2.0));
//setting fastPaging property to NO allows to stop at page on screen (I have pages lees, than self.collectionView.bounds.size.width)
CGRect targetRect = CGRectMake(self.fastPaging ? proposedContentOffset.x : self.collectionView.contentOffset.x,
0.0,
self.collectionView.bounds.size.width,
self.collectionView.bounds.size.height);
NSArray *attributes = [self layoutAttributesForElementsInRect:targetRect];
NSPredicate *cellAttributesPredicate = [NSPredicate predicateWithBlock: ^BOOL(UICollectionViewLayoutAttributes * _Nonnull evaluatedObject,
NSDictionary<NSString *,id> * _Nullable bindings)
{
return (evaluatedObject.representedElementCategory == UICollectionElementCategoryCell);
}];
NSArray *cellAttributes = [attributes filteredArrayUsingPredicate: cellAttributesPredicate];
UICollectionViewLayoutAttributes *currentAttributes;
for (UICollectionViewLayoutAttributes *layoutAttributes in cellAttributes)
{
CGFloat itemHorizontalCenter = layoutAttributes.center.x;
if (ABS(itemHorizontalCenter - horizontalCenter) < ABS(offSetAdjustment))
{
currentAttributes = layoutAttributes;
offSetAdjustment = itemHorizontalCenter - horizontalCenter;
}
}
CGFloat nextOffset = proposedContentOffset.x + offSetAdjustment;
proposedContentOffset.x = nextOffset;
CGFloat deltaX = proposedContentOffset.x - self.collectionView.contentOffset.x;
CGFloat velX = velocity.x;
// detection form gist.github.com/rkeniger/7687301
// based on http://stackoverflow.com/a/14291208/740949
if (fabs(deltaX) <= FLT_EPSILON || fabs(velX) <= FLT_EPSILON || (velX > 0.0 && deltaX > 0.0) || (velX < 0.0 && deltaX < 0.0))
{
}
else if (velocity.x > 0.0)
{
// revert the array to get the cells from the right side, fixes not correct center on different size in some usecases
NSArray *revertedArray = [[array reverseObjectEnumerator] allObjects];
BOOL found = YES;
float proposedX = 0.0;
for (UICollectionViewLayoutAttributes *layoutAttributes in revertedArray)
{
if(layoutAttributes.representedElementCategory == UICollectionElementCategoryCell)
{
CGFloat itemHorizontalCenter = layoutAttributes.center.x;
if (itemHorizontalCenter > proposedContentOffset.x) {
found = YES;
proposedX = nextOffset + (currentAttributes.frame.size.width / 2) + (layoutAttributes.frame.size.width / 2);
} else {
break;
}
}
}
// dont set on unfound element
if (found) {
proposedContentOffset.x = proposedX;
}
}
else if (velocity.x < 0.0)
{
for (UICollectionViewLayoutAttributes *layoutAttributes in cellAttributes)
{
CGFloat itemHorizontalCenter = layoutAttributes.center.x;
if (itemHorizontalCenter > proposedContentOffset.x)
{
proposedContentOffset.x = nextOffset - ((currentAttributes.frame.size.width / 2) + (layoutAttributes.frame.size.width / 2));
break;
}
}
}
proposedContentOffset.y = 0.0;
return proposedContentOffset;
}
refer to this answer by Dan Abramov here's Swift version
override func targetContentOffset(
forProposedContentOffset proposedContentOffset: CGPoint,
withScrollingVelocity velocity: CGPoint
) -> CGPoint {
var _proposedContentOffset = CGPoint(
x: proposedContentOffset.x, y: proposedContentOffset.y
)
var offSetAdjustment: CGFloat = CGFloat.greatestFiniteMagnitude
let horizontalCenter: CGFloat = CGFloat(
proposedContentOffset.x + (self.collectionView!.bounds.size.width / 2.0)
)
let targetRect = CGRect(
x: proposedContentOffset.x,
y: 0.0,
width: self.collectionView!.bounds.size.width,
height: self.collectionView!.bounds.size.height
)
let array: [UICollectionViewLayoutAttributes] =
self.layoutAttributesForElements(in: targetRect)!
as [UICollectionViewLayoutAttributes]
for layoutAttributes: UICollectionViewLayoutAttributes in array {
if layoutAttributes.representedElementCategory == UICollectionView.ElementCategory.cell {
let itemHorizontalCenter: CGFloat = layoutAttributes.center.x
if abs(itemHorizontalCenter - horizontalCenter) < abs(offSetAdjustment) {
offSetAdjustment = itemHorizontalCenter - horizontalCenter
}
}
}
var nextOffset: CGFloat = proposedContentOffset.x + offSetAdjustment
repeat {
_proposedContentOffset.x = nextOffset
let deltaX = proposedContentOffset.x - self.collectionView!.contentOffset.x
let velX = velocity.x
if
deltaX == 0.0 || velX == 0 || (velX > 0.0 && deltaX > 0.0) ||
(velX < 0.0 && deltaX < 0.0)
{
break
}
if velocity.x > 0.0 {
nextOffset = nextOffset + self.snapStep()
} else if velocity.x < 0.0 {
nextOffset = nextOffset - self.snapStep()
}
} while self.isValidOffset(offset: nextOffset)
_proposedContentOffset.y = 0.0
return _proposedContentOffset
}
func isValidOffset(offset: CGFloat) -> Bool {
return (offset >= CGFloat(self.minContentOffset()) &&
offset <= CGFloat(self.maxContentOffset()))
}
func minContentOffset() -> CGFloat {
return -CGFloat(self.collectionView!.contentInset.left)
}
func maxContentOffset() -> CGFloat {
return CGFloat(
self.minContentOffset() + self.collectionView!.contentSize.width - self.itemSize.width
)
}
func snapStep() -> CGFloat {
return self.itemSize.width + self.minimumLineSpacing
}
or gist here https://gist.github.com/katopz/8b04c783387f0c345cd9
Here is my Swift solution on a horizontally scrolling collection view. It's simple, sweet and avoids any flickering.
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return proposedContentOffset }
let currentXOffset = collectionView.contentOffset.x
let nextXOffset = proposedContentOffset.x
let maxIndex = ceil(currentXOffset / pageWidth())
let minIndex = floor(currentXOffset / pageWidth())
var index: CGFloat = 0
if nextXOffset > currentXOffset {
index = maxIndex
} else {
index = minIndex
}
let xOffset = pageWidth() * index
let point = CGPointMake(xOffset, 0)
return point
}
func pageWidth() -> CGFloat {
return itemSize.width + minimumInteritemSpacing
}
a small issue I encountered while using targetContentOffsetForProposedContentOffset is a problem with the last cell not adjusting according to the new point I returned.
I found out that the CGPoint I returned had a Y value bigger then allowed so i used the following code at the end of my targetContentOffsetForProposedContentOffset implementation:
// if the calculated y is bigger then the maximum possible y we adjust accordingly
CGFloat contentHeight = self.collectionViewContentSize.height;
CGFloat collectionViewHeight = self.collectionView.bounds.size.height;
CGFloat maxY = contentHeight - collectionViewHeight;
if (newY > maxY)
{
newY = maxY;
}
return CGPointMake(0, newY);
just to make it clearer this is my full layout implementation which just imitates vertical paging behavior:
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
{
return [self targetContentOffsetForProposedContentOffset:proposedContentOffset];
}
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
CGFloat heightOfPage = self.itemSize.height;
CGFloat heightOfSpacing = self.minimumLineSpacing;
CGFloat numOfPage = lround(proposedContentOffset.y / (heightOfPage + heightOfSpacing));
CGFloat newY = numOfPage * (heightOfPage + heightOfSpacing);
// if the calculated y is bigger then the maximum possible y we adjust accordingly
CGFloat contentHeight = self.collectionViewContentSize.height;
CGFloat collectionViewHeight = self.collectionView.bounds.size.height;
CGFloat maxY = contentHeight - collectionViewHeight;
if (newY > maxY)
{
newY = maxY;
}
return CGPointMake(0, newY);
}
hopefully this will save someone some time and a headache
I prefer to allow user flicking through several pages. So here is my version of targetContentOffsetForProposedContentOffset (which based on DarthMike answer) for vertical layout.
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGFloat approximatePage = self.collectionView.contentOffset.y / self.pageHeight;
CGFloat currentPage = (velocity.y < 0.0) ? floor(approximatePage) : ceil(approximatePage);
NSInteger flickedPages = ceil(velocity.y / self.flickVelocity);
if (flickedPages) {
proposedContentOffset.y = (currentPage + flickedPages) * self.pageHeight;
} else {
proposedContentOffset.y = currentPage * self.pageHeight;
}
return proposedContentOffset;
}
- (CGFloat)pageHeight {
return self.itemSize.height + self.minimumLineSpacing;
}
- (CGFloat)flickVelocity {
return 1.2;
}
Fogmeisters answer worked for me unless I scrolled to the end of the row. My cells don't fit neatly on the screen so it would scroll to the end and jump back with a jerk so that the last cell always overlapped the right edge of the screen.
To prevent this add the following line of code at the start of the targetcontentoffset method
if(proposedContentOffset.x>self.collectionViewContentSize.width-320-self.sectionInset.right)
return proposedContentOffset;
#André Abreu's Code
Swift3 version
class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offsetAdjustment = CGFloat.greatestFiniteMagnitude
let horizontalOffset = proposedContentOffset.x
let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: self.collectionView!.bounds.size.width, height: self.collectionView!.bounds.size.height)
for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
let itemOffset = layoutAttributes.frame.origin.x
if abs(itemOffset - horizontalOffset) < abs(offsetAdjustment){
offsetAdjustment = itemOffset - horizontalOffset
}
}
return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
}
}
Swift 4
The easiest solution for collection view with cells of one size (horizontal scroll):
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return proposedContentOffset }
// Calculate width of your page
let pageWidth = calculatedPageWidth()
// Calculate proposed page
let proposedPage = round(proposedContentOffset.x / pageWidth)
// Adjust necessary offset
let xOffset = pageWidth * proposedPage - collectionView.contentInset.left
return CGPoint(x: xOffset, y: 0)
}
func calculatedPageWidth() -> CGFloat {
return itemSize.width + minimumInteritemSpacing
}
A shorter solution (assuming you're caching your layout attributes):
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let proposedEndFrame = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView!.bounds.width, height: collectionView!.bounds.height)
let targetLayoutAttributes = cache.max { $0.frame.intersection(proposedEndFrame).width < $1.frame.intersection(proposedEndFrame).width }!
return CGPoint(x: targetLayoutAttributes.frame.minX - horizontalPadding, y: 0)
}
To put this in context:
class Layout : UICollectionViewLayout {
private var cache: [UICollectionViewLayoutAttributes] = []
private static let horizontalPadding: CGFloat = 16
private static let interItemSpacing: CGFloat = 8
override func prepare() {
let (itemWidth, itemHeight) = (collectionView!.bounds.width - 2 * Layout.horizontalPadding, collectionView!.bounds.height)
cache.removeAll()
let count = collectionView!.numberOfItems(inSection: 0)
var x: CGFloat = Layout.horizontalPadding
for item in (0..<count) {
let indexPath = IndexPath(item: item, section: 0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = CGRect(x: x, y: 0, width: itemWidth, height: itemHeight)
cache.append(attributes)
x += itemWidth + Layout.interItemSpacing
}
}
override var collectionViewContentSize: CGSize {
let width: CGFloat
if let maxX = cache.last?.frame.maxX {
width = maxX + Layout.horizontalPadding
} else {
width = collectionView!.width
}
return CGSize(width: width, height: collectionView!.height)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache.first { $0.indexPath == indexPath }
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cache.filter { $0.frame.intersects(rect) }
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let proposedEndFrame = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView!.bounds.width, height: collectionView!.bounds.height)
let targetLayoutAttributes = cache.max { $0.frame.intersection(proposedEndFrame).width < $1.frame.intersection(proposedEndFrame).width }!
return CGPoint(x: targetLayoutAttributes.frame.minX - Layout.horizontalPadding, y: 0)
}
}
To make sure it works in Swift version (swift 5 now), I used the answer from #André Abreu, I add some more informations:
When subclassing UICollectionViewFlowLayout, the "override func awakeFromNib(){}" doesn't works (don't know why). Instead, I used "override init(){super.init()}"
This is my code put in class SubclassFlowLayout: UICollectionViewFlowLayout {} :
let padding: CGFloat = 16
override init() {
super.init()
self.minimumLineSpacing = padding
self.minimumInteritemSpacing = 2
self.scrollDirection = .horizontal
self.sectionInset = UIEdgeInsets(top: 0, left: padding, bottom: 0, right: 100) //right = "should set for footer" (Horizental)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offsetAdjustment = CGFloat.greatestFiniteMagnitude
let leftInset = padding
let horizontalOffset = proposedContentOffset.x + leftInset // leftInset is for "where you want the item stop on the left"
let targetRect = CGRect(origin: CGPoint(x: proposedContentOffset.x, y: 0), size: self.collectionView!.bounds.size)
for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
let itemOffset = layoutAttributes.frame.origin.x
if (abs(itemOffset - horizontalOffset) < abs(offsetAdjustment)) {
offsetAdjustment = itemOffset - horizontalOffset
}
}
let targetPoint = CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
return targetPoint
}
After subclassing, make sure to put this in ViewDidLoad():
customCollectionView.collectionViewLayout = SubclassFlowLayout()
customCollectionView.isPagingEnabled = false
customCollectionView.decelerationRate = .fast //-> this for scrollView speed
For those looking for a solution in Swift:
class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
private let collectionViewHeight: CGFloat = 200.0
private let screenWidth: CGFloat = UIScreen.mainScreen().bounds.width
override func awakeFromNib() {
super.awakeFromNib()
self.itemSize = CGSize(width: [InsertItemWidthHere], height: [InsertItemHeightHere])
self.minimumInteritemSpacing = [InsertItemSpacingHere]
self.scrollDirection = .Horizontal
let inset = (self.screenWidth - CGFloat(self.itemSize.width)) / 2
self.collectionView?.contentInset = UIEdgeInsets(top: 0,
left: inset,
bottom: 0,
right: inset)
}
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offsetAdjustment = CGFloat.max
let horizontalOffset = proposedContentOffset.x + ((self.screenWidth - self.itemSize.width) / 2)
let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: self.screenWidth, height: self.collectionViewHeight)
var array = super.layoutAttributesForElementsInRect(targetRect)
for layoutAttributes in array! {
let itemOffset = layoutAttributes.frame.origin.x
if (abs(itemOffset - horizontalOffset) < abs(offsetAdjustment)) {
offsetAdjustment = itemOffset - horizontalOffset
}
}
return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
}
}
It is not about collectionView, but it works better.
It is the best solution I ever seen.
Just use it with .linear type.
https://github.com/nicklockwood/iCarousel
God bless the author!:)
Here is a demo for paging by cell (when scroll fast, not skip one or more cell): https://github.com/ApesTalk/ATPagingByCell

resize and crop image centered

so currently i'm trying to crop and resize a picture to fit it into a specific size without losing the ratio.
a small image to show what i mean:
i played a bit with vocaro's categories but they don't work with png's and have problems with gifs. also the image doesn't get cropped.
does anyone have a suggestion how to do this resizing the best way or probably have a link to an existing library/categorie/whatever?
thanks for all hints!
p.s.: does ios implement a "select a excerpt" so that i have the right ratio and only have to scale it?!
This method will do what you want and is a category of UIImage for ease of use. I went with resize then crop, you could switch the code around easily enough if you want crop then resize. The bounds checking in the function is purely illustrative. You might want to do something different, for example center the crop rect relative to the outputImage dimensions but this ought to get you close enough to make whatever other changes you need.
#implementation UIImage( resizeAndCropExample )
- (UIImage *) resizeToSize:(CGSize) newSize thenCropWithRect:(CGRect) cropRect {
CGContextRef context;
CGImageRef imageRef;
CGSize inputSize;
UIImage *outputImage = nil;
CGFloat scaleFactor, width;
// resize, maintaining aspect ratio:
inputSize = self.size;
scaleFactor = newSize.height / inputSize.height;
width = roundf( inputSize.width * scaleFactor );
if ( width > newSize.width ) {
scaleFactor = newSize.width / inputSize.width;
newSize.height = roundf( inputSize.height * scaleFactor );
} else {
newSize.width = width;
}
UIGraphicsBeginImageContext( newSize );
context = UIGraphicsGetCurrentContext();
// added 2016.07.29, flip image vertically before drawing:
CGContextSaveGState(context);
CGContextTranslateCTM(context, 0, newSize.height);
CGContextScaleCTM(context, 1, -1);
CGContextDrawImage(context, CGRectMake(0, 0, newSize.width, newSize.height, self.CGImage);
// // alternate way to draw
// [self drawInRect: CGRectMake( 0, 0, newSize.width, newSize.height )];
CGContextRestoreGState(context);
outputImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
inputSize = newSize;
// constrain crop rect to legitimate bounds
if ( cropRect.origin.x >= inputSize.width || cropRect.origin.y >= inputSize.height ) return outputImage;
if ( cropRect.origin.x + cropRect.size.width >= inputSize.width ) cropRect.size.width = inputSize.width - cropRect.origin.x;
if ( cropRect.origin.y + cropRect.size.height >= inputSize.height ) cropRect.size.height = inputSize.height - cropRect.origin.y;
// crop
if ( ( imageRef = CGImageCreateWithImageInRect( outputImage.CGImage, cropRect ) ) ) {
outputImage = [[[UIImage alloc] initWithCGImage: imageRef] autorelease];
CGImageRelease( imageRef );
}
return outputImage;
}
#end
I have came across the same issue in one of my application and developed this piece of code:
+ (UIImage*)resizeImage:(UIImage*)image toFitInSize:(CGSize)toSize
{
UIImage *result = image;
CGSize sourceSize = image.size;
CGSize targetSize = toSize;
BOOL needsRedraw = NO;
// Check if width of source image is greater than width of target image
// Calculate the percentage of change in width required and update it in toSize accordingly.
if (sourceSize.width > toSize.width) {
CGFloat ratioChange = (sourceSize.width - toSize.width) * 100 / sourceSize.width;
toSize.height = sourceSize.height - (sourceSize.height * ratioChange / 100);
needsRedraw = YES;
}
// Now we need to make sure that if we chnage the height of image in same proportion
// Calculate the percentage of change in width required and update it in target size variable.
// Also we need to again change the height of the target image in the same proportion which we
/// have calculated for the change.
if (toSize.height < targetSize.height) {
CGFloat ratioChange = (targetSize.height - toSize.height) * 100 / targetSize.height;
toSize.height = targetSize.height;
toSize.width = toSize.width + (toSize.width * ratioChange / 100);
needsRedraw = YES;
}
// To redraw the image
if (needsRedraw) {
UIGraphicsBeginImageContext(toSize);
[image drawInRect:CGRectMake(0.0, 0.0, toSize.width, toSize.height)];
result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
}
// Return the result
return result;
}
You can modify it according to your needs.
Had the same task for preview image in a gallery. For fixed Crop-Area (Image for SwiftUI and Canvas Rect for Kotiln) I want to crop central content of image - by maximum of on of the image's side. (see explanation below)
Here solutions for
Swift
func getImageCropped(srcImage : UIImage, sizeToCrop : CGSize) -> UIImage{
let ratioImage = Double(srcImage.cgImage!.width ) / Double(srcImage.cgImage!.height )
let ratioCrop = Double(sizeToCrop.width) / Double(sizeToCrop.height)
let cropRect: CGRect = {
if(ratioCrop > 1.0){
// crop LAND -> fit image HORIZONTALLY
let widthRatio = CGFloat(srcImage.cgImage!.width) / CGFloat(sizeToCrop.width)
var cropWidth = Int(sizeToCrop.width * widthRatio)
var cropHeight = Int(sizeToCrop.height * widthRatio)
var cropX = 0
var cropY = srcImage.cgImage!.height / 2 - cropHeight / 2
// [L1] [L2] : OK
if(ratioImage > 1.0) {
// image LAND
// [L3] : OK
if(cropHeight > srcImage.cgImage!.height) {
// [L4] : Crop-Area exceeds Image-Area > change crop orientation to PORTrait
let heightRatio = CGFloat(srcImage.cgImage!.height) / CGFloat(sizeToCrop.height)
cropWidth = Int(sizeToCrop.width * heightRatio)
cropHeight = Int(sizeToCrop.height * heightRatio)
cropX = srcImage.cgImage!.width / 2 - cropWidth / 2
cropY = 0
}
}
return CGRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight)
}
else if(ratioCrop < 1.0){
// crop PORT -> fit image VERTICALLY
let heightRatio = CGFloat(srcImage.cgImage!.height) / CGFloat(sizeToCrop.height)
var cropWidth = Int(sizeToCrop.width * heightRatio)
var cropHeight = Int(sizeToCrop.height * heightRatio)
var cropX = srcImage.cgImage!.width / 2 - cropWidth / 2
var cropY = 0
// [P1] [P2] : OK
if(ratioImage < 1.0) {
// // image PORT
// [P3] : OK
if(cropWidth > srcImage.cgImage!.width) {
// [L4] : Crop-Area exceeds Image-Area > change crop orientation to LANDscape
let widthRatio = CGFloat(srcImage.cgImage!.width) / CGFloat(sizeToCrop.width)
cropWidth = Int(sizeToCrop.width * widthRatio)
cropHeight = Int(sizeToCrop.height * widthRatio)
cropX = 0
cropY = srcImage.cgImage!.height / 2 - cropHeight / 2
}
}
return CGRect(x: cropX, y: cropY, width: cropWidth, height: cropHeight)
}
else {
// CROP CENTER SQUARE
var fitSide = 0
var cropX = 0
var cropY = 0
if (ratioImage > 1.0){
// crop LAND -> fit image HORIZONTALLY !!!!!!
fitSide = srcImage.cgImage!.height
cropX = srcImage.cgImage!.width / 2 - fitSide / 2
}
else if (ratioImage < 1.0){
// crop PORT -> fit image VERTICALLY
fitSide = srcImage.cgImage!.width
cropY = srcImage.cgImage!.height / 2 - fitSide / 2
}
else{
// ImageAre and GropArea are square both
fitSide = srcImage.cgImage!.width
}
return CGRect(x: cropX, y: cropY, width: fitSide, height: fitSide)
}
}()
let imageRef = srcImage.cgImage!.cropping(to: cropRect)
let cropped : UIImage = UIImage(cgImage: imageRef!, scale: 0, orientation: srcImage.imageOrientation)
return cropped
}
and
Kotlin
data class RectCrop(val x: Int, val y: Int, val width: Int, val height: Int)
fun getImageCroppedShort(srcBitmap: Bitmap, sizeToCrop: Size):Bitmap {
val ratioImage = srcBitmap.width.toFloat() / srcBitmap.height.toFloat()
val ratioCrop = sizeToCrop.width.toFloat() / sizeToCrop.height.toFloat()
// 1. choose fit size
val cropRect: RectCrop =
if(ratioCrop > 1.0){
// crop LAND
val widthRatio = srcBitmap.width.toFloat() / sizeToCrop.width.toFloat()
var cropWidth = (sizeToCrop.width * widthRatio).toInt()
var cropHeight= (sizeToCrop.height * widthRatio).toInt()
var cropX = 0
var cropY = srcBitmap.height / 2 - cropHeight / 2
if(ratioImage > 1.0) {
// image LAND
if(cropHeight > srcBitmap.height) {
val heightRatio = srcBitmap.height.toFloat() / sizeToCrop.height.toFloat()
cropWidth = (sizeToCrop.width * heightRatio).toInt()
cropHeight = (sizeToCrop.height * heightRatio).toInt()
cropX = srcBitmap.width / 2 - cropWidth / 2
cropY = 0
}
}
RectCrop(cropX, cropY, cropWidth, cropHeight)
}
else if(ratioCrop < 1.0){
// crop PORT
val heightRatio = srcBitmap.height.toFloat() / sizeToCrop.height.toFloat()
var cropWidth = (sizeToCrop.width * heightRatio).toInt()
var cropHeight= (sizeToCrop.height * heightRatio).toInt()
var cropX = srcBitmap.width / 2 - cropWidth / 2
var cropY = 0
if(ratioImage < 1.0) {
// image PORT
if(cropWidth > srcBitmap.width) {
val widthRatio = srcBitmap.width.toFloat() / sizeToCrop.width.toFloat()
cropWidth = (sizeToCrop.width * widthRatio).toInt()
cropHeight = (sizeToCrop.height * widthRatio).toInt()
cropX = 0
cropY = srcBitmap.height / 2 - cropHeight / 2
}
}
RectCrop(cropX, cropY, cropWidth, cropHeight)
}
else {
// CROP CENTER SQUARE
var fitSide = 0
var cropX = 0
var cropY = 0
if (ratioImage > 1.0){
fitSide = srcBitmap.height
cropX = srcBitmap.width/ 2 - fitSide / 2
}
else if (ratioImage < 1.0){
fitSide = srcBitmap.width
cropY = srcBitmap.height / 2 - fitSide / 2
}
else{
fitSide = srcBitmap.width
}
RectCrop(cropX, cropY, fitSide, fitSide)
}
return Bitmap.createBitmap(
srcBitmap,
cropRect.x,
cropRect.y,
cropRect.width,
cropRect.height)
}
An explanation for those who want to understand algorithm. The main idea - we should stretch a Crop-Area proportionally(!) until the biggest side of it fits image. But there is one unacceptable case (L4 and P4) when Crop-Area exceeds Image-Area. So here we have only one way - change fit direction and stretch Crop-Area to the other side
On Scheme I didn't centering of crop (for better understanding idea), but both of this solutions do this. Here result of getImageCropped:
This SwiftUI code provides images above to test:
var body: some View {
// IMAGE LAND
let ORIG_NAME = "image_land.jpg"
let ORIG_W = 400.0
let ORIG_H = 265.0
// > crop Land
let cropW = 400.0
let cropH = 200.0
// > crop Port
// let cropW = 50.0
// let cropH = 265.0
// > crop Center Square
// let cropW = 265.0
// let cropH = 265.0
// IMAGE PORT
// let ORIG_NAME = "image_port.jpg"
// let ORIG_W = 350.0
// let ORIG_H = 500.0
// > crop Land
// let cropW = 350.0
// let cropH = 410.0
// > crop Port
// let cropW = 190.0
// let cropH = 500.0
// > crop Center Square
// let cropW = 350.0
// let cropH = 350.0
let imageOriginal = UIImage(named: ORIG_NAME)!
let imageCropped = self.getImageCroppedShort(srcImage: imageOriginal, sizeToCrop: CGSize(width: cropW, height: cropH))
return VStack{
HStack{
Text("ImageArea \nW:\(Int(ORIG_W)) \nH:\(Int(ORIG_H))").font(.body)
Text("CropArea \nW:\(Int(cropW)) \nH:\(Int(cropH))").font(.body)
}
ZStack{
Image(uiImage: imageOriginal)
.resizable()
.opacity(0.4)
Image(uiImage: imageCropped)
.resizable()
.frame(width: CGFloat(cropW), height: CGFloat(cropH))
}
.frame(width: CGFloat(ORIG_W), height: CGFloat(ORIG_H))
.background(Color.black)
}
}
Kotlin solution works identically. Trust me)