Search code examples
swiftcrop

Can I select an image and choose a square crop from that image in Swift?


As a fledgling programmer I am told I will know when I've reached intermediate level when I am comfortably using libraries often. I am faced with the problem of allowing my user to choose a profile picture, and in the case that it is not a square, allowing my user to move a square around the image to crop it.

This is such a common feature (tinder, GitHub, Facebook, etc) I would think there would be a swift 3 compatible solution I could find online but I'm not having any luck; I hacked around with WDImagePicker only to find it doesn't quite meet the standards of what users are used to seeing.

If no solution exists I will code my own and if successful share it online, but I find it hard to believe this is the case. Anybody know of any solution?

To clarify my exact intention: A user should be able to choose an image to upload. If that image is not a square, then a square should appear with dimensions equal to min(imageWidth, imageHeight). That square should overlap the picture in a scrollview manner so the user can move the image to fit the square over what he or she wants the square image uploaded to look like. Then that portion of the image should be uploaded. In essence, I want an aspect fill where the user decides what part of the image gets cut off. Is there really no API to deal with this?


Solution

  • Right, let me start by apologising for not getting this to you sooner, but here is my completely custom implementation for a image cropper tool that actually works (unlike Apple's). It's quite straightforward but unfortunately I (stupidly) didn't build it to be easily reusable so you will have to try and recreate what I have in storyboard. I'll try to make it clear with screenshots.

    Firstly however, please note that this is all Swift 2.3 code as it is an old project, if you are working in Swift 2.3, great, if not, you'll need to update it.

    Here is the view controller file that you will need to add to your project, note the comments which explain how the various parts work:

    import Foundation
    import UIKit
    import AVFoundation
    
    class EditProfilePictureViewController : UIViewController {
    
        var imageView: UIImageView?
        var image : UIImage!
        var center: CGPoint!
    
        @IBOutlet var indicatorView: UIView!
        @IBOutlet var spaceView: UIView!
    
        // Set these to the desired width and height of your output image.
        let desiredWidth:CGFloat = 75
        let desiredHeight: CGFloat = 100
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // Set up UI and gesture recognisers.
            indicatorView.layer.borderColor = UIColor.whiteColor().CGColor
            indicatorView.layer.borderWidth = 8
    
            let pan = UIPanGestureRecognizer(target: self, action: "didPan:")
            let pinch = UIPinchGestureRecognizer(target: self, action: "didPinch:")
            indicatorView.addGestureRecognizer(pan)
            indicatorView.addGestureRecognizer(pinch)
        }
    
        override func viewDidAppear(animated: Bool) {
            super.viewDidAppear(animated)
    
            // Set up the image view in relation to the storyboard views.
            imageView = UIImageView()
            imageView?.image = image
            imageView!.frame = AVMakeRectWithAspectRatioInsideRect(image.size, indicatorView.frame)
            center = imageView!.center
            spaceView.insertSubview(imageView!, belowSubview: indicatorView)
        }
    
    
        // Invoked when the user pans accross the screen. The logic happening here basically just checks if the pan would move the image outside the cropping square and if so, don't allow the pan to happen.
        func didPan(recognizer: UIPanGestureRecognizer) {
    
            let translation = recognizer.translationInView(spaceView)
    
            if (imageView!.frame.minX + translation.x >= indicatorView.frame.minX && imageView!.frame.maxX + translation.x <= indicatorView.frame.maxX) || ((imageView!.frame.size.width >= indicatorView.frame.size.width) && (imageView!.frame.minX + translation.x <= indicatorView.frame.minX && imageView!.frame.maxX + translation.x >= indicatorView.frame.maxX)) {
                imageView!.center.x += translation.x
            }
    
            if (imageView!.frame.minY + translation.y >= indicatorView.frame.minY && imageView!.frame.maxY + translation.y <= indicatorView.frame.maxY) || ((imageView!.frame.size.height >= indicatorView.frame.size.height) && (imageView!.frame.minY + translation.y <= indicatorView.frame.minY && imageView!.frame.maxY + translation.y >= indicatorView.frame.maxY)) {
                imageView!.center.y += translation.y
            }
    
            recognizer.setTranslation(CGPointZero, inView: spaceView)
        }
    
        // Invoked when the user pinches the screen. Again the logic here just ensures that zooming the image would not make it exceed the bounds of the cropping square and cancels the zoom if it does.
        func didPinch(recognizer: UIPinchGestureRecognizer) {
    
            let view = UIView(frame: imageView!.frame)
    
            view.transform = CGAffineTransformScale(imageView!.transform, recognizer.scale, recognizer.scale)
    
            if view.frame.size.width >= indicatorView.frame.size.width || view.frame.size.height >= indicatorView.frame.size.height {
    
                imageView!.transform = CGAffineTransformScale(imageView!.transform, recognizer.scale, recognizer.scale)
                recognizer.scale = 1
            }
    
            if recognizer.state == UIGestureRecognizerState.Ended {
    
                if imageView!.frame.minX > indicatorView.frame.minX || imageView!.frame.maxX < indicatorView.frame.maxX {
    
                    UIView.animateWithDuration(0.3, animations: { () -> Void in
                        self.imageView!.center = self.indicatorView.center
                    })
                }
    
                if imageView!.frame.size.height < indicatorView.frame.size.height && imageView!.frame.size.width < indicatorView.frame.size.width {
    
                    UIView.animateWithDuration(0.3, animations: { () -> Void in
                        self.imageView!.frame = AVMakeRectWithAspectRatioInsideRect(self.image.size, self.indicatorView.frame)
                    })
                }
            }
        }
    
        // Outlet for the cancel button.
        @IBAction func cancelButtonPressed(sender: AnyObject) {
            dismissViewControllerAnimated(true, completion: nil)
        }
    
        // Outlet for the save button. The logic here scales the outputed image down to the desired size.
        @IBAction func saveButtonPressed(sender: AnyObject) {
    
            let croppedImage = grabIndicatedImage()
            UIGraphicsBeginImageContext(CGSizeMake(desiredWidth, desiredHeight))
            CGContextSetFillColorWithColor(UIGraphicsGetCurrentContext(), UIColor.blackColor().CGColor)
    
            if desiredWidth / desiredHeight == croppedImage.size.width / croppedImage.size.height  {
                croppedImage.drawInRect(CGRect(x: 0, y: 0, width: desiredWidth, height: desiredHeight))
            } else {
                let croppedImageSize : CGRect = AVMakeRectWithAspectRatioInsideRect(croppedImage.size, CGRectMake(0, 0, desiredWidth, desiredHeight))
                croppedImage.drawInRect(croppedImageSize)
            }
    
            let resizedCroppedImage = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            let data = UIImagePNGRepresentation(resizedCroppedImage)
            // At this point you now have an image cropped to your desired size, as well as data representation of it should you want to send to an API.
        }
    
        // When you call this method it basically takes a screenshot of the crop area and gets the UIImage object from it.
        func grabIndicatedImage() -> UIImage  {
    
            UIGraphicsBeginImageContext(self.view.layer.frame.size)
            let context : CGContextRef = UIGraphicsGetCurrentContext();
            self.view.layer.renderInContext(context)
            let screenshot : UIImage = UIGraphicsGetImageFromCurrentImageContext();
    
            let rectToCrop = CGRectMake(indicatorView.frame.minX + 8, indicatorView.frame.minY + 72, indicatorView.frame.width - 16, indicatorView.frame.height - 16)
    
            let imageRef : CGImageRef = CGImageCreateWithImageInRect(screenshot.CGImage, rectToCrop)
            let croppedImage = UIImage(CGImage: imageRef)!
    
    
            UIGraphicsEndImageContext();
            return croppedImage
        }
    
        // MARK: The following methods relate to re-laying out the view if the user changes the device orientation.
    
        override func didRotateFromInterfaceOrientation(fromInterfaceOrientation: UIInterfaceOrientation) {
            if (floor(NSFoundationVersionNumber) <= NSFoundationVersionNumber_iOS_7_1)
            {
                UIView.animateWithDuration(0.3, animations: { () -> Void in
                    self.imageView!.center = self.indicatorView.center
                    self.imageView!.frame = AVMakeRectWithAspectRatioInsideRect(self.image.size, self.indicatorView.frame)
                })
            }
        }
    
        override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
    
            coordinator.animateAlongsideTransition({ (context) -> Void in
    
                if UIDevice.currentDevice().userInterfaceIdiom == .Pad
                {
                    UIView.animateWithDuration(0.3, animations: { () -> Void in
                        self.imageView!.center = self.indicatorView.center
                        self.imageView!.frame = AVMakeRectWithAspectRatioInsideRect(self.image.size, self.indicatorView.frame)
                    })
                }
            }, completion: { (context) -> Void in
            })
        }
    }
    

    Next you'll need to set up your storyboard/nib and connect the outlets. The UI within storyboard looks like this:

    enter image description here

    Not particularly helpful, I know. The view heirarchy is a little more insightful and looks like this:

    enter image description here

    As you can see, there isn't a lot to set up in storyboard. Space View is literally just a subview of the main, root view. It is pinned with constraints on all four sides so it matches the size of the root view. Very easy to replicate. It has a black background but this can be any colour of your choice.

    The Indicator View is a little more complicated. It is a subview of Space View as can be seen from the view heirarchy screenshot. In terms of constraints the most important one is aspect ratio. This needs to be the aspect ratio of the crop you desire. In my case it was 4:3 but for you it may well be 1:1 if you want a square crop. You can change this easily but be aware that desiredHeight and desiredWidth must be set to reflect the aspect ratio.

    enter image description here

    These constraints may look complicated but they are actually quite simple, let me break them down:

    enter image description here

    As I mentioned, set the aspect ratio. Next center horizontally and vertically within the space view. Then create a set of equal width, equal height constraints to the space view. Make both of these 'less than or equal to'. After, create another set of equal width, equal height constraints. Set both their priority's to 750.

    Right, that's it for laying out the UI; now you just need to connect the outlets. It's fairly obvious what to connect the spaceView and indicatorView outlets to so go ahead and do that. Also don't forget to hook up the cancel and save buttons to their actions.

    Finally, I will explain how to use it. Create a segue to this new view controller and override the prepareForSegue method on the view controller. Get a reference to the view controller through whatever means you choose and set the image property to the image you want to crop. Be aware that this is not a replacement for UIImagePickerController but more to suplement it. You will still need to use UIImagePickerController to get the image from camera roll or the camera but this is used to handle the editing. For example:

    override func prepareForSegue(segue: UIStoryboardSegue) {
        if let editVC = segue.destinationViewController as? EditProfilePictureViewController {
            editVC.image = self.pickedImage // Set image to the image you want to crop.
        }
    }
    

    The editor will then popup and you can position the image to your liking before hitting save. You may choose to implement a delegate to get the cropped image back from the editor but I'll leave that to you.

    Again sorry for the delay in getting this to you, but I hope you'll find it was worth it! Good luck with your app, please post a link when it's live so I can check it out!