I'm working on iOS app which should enable users to create Instagram stories photos and export them to Instagram. Basically app like Unfold, Stellar, Chroma Stories... I've prepared UI where user can select from prepared templates and add to them own photos with filters, labels etc.
My question is - what is the best way to export created UIView to bigger Image?
I mean how to get the best quality, sharp pixels of labels etc?
Because the template view with subviews (added photos, labels...) is taking +- half of device's screen. But I need bigger size for exported image.
Currently I use:
func makeImageFromView() -> UIImage {
let format = UIGraphicsImageRendererFormat()
let size = CGSize(width: 1080 / format.scale, height: 1920 / format.scale)
let renderer = UIGraphicsImageRenderer(size: size, format: format)
let image = renderer.image { (ctx) in
templateView.drawHierarchy(in: CGRect(origin: .zero, size: size), afterScreenUpdates: true)
}
return image
}
The resulted image has size of 1080 x 1920, but labels aren't sharp.
Do I need to scale somehow the photo and font size before making it to an image?
Thanks!
So actually yes, before capturing image I need to scale whole view and it's subviews. Here are my findings (maybe obvious things but it took me a while to realize that – I'll be glad for any improvements)
Rendering image of the same size
When you want to capture UIView as an image, you can simply use this function. Resulted image will have a same size as a view (scaled 2x / 3x depending on actual device)
func makeImageFrom(_ desiredView: MyView) -> UIImage {
let size = CGSize(width: desiredView.bounds.width, height: desiredView.bounds.height)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { (ctx) in
desiredView.drawHierarchy(in: CGRect(origin: .zero, size: size), afterScreenUpdates: true)
}
return image
}
Rendering image of the different size
But what to do, when you want a specific size for your exported image?
So from my use-case I wanted to render image of final size (1080 x 1920), but a view I wanted to capture had a smaller size (in my case 275 x 487). If you do such a rendering without anything, there must be a loss in quality.
If you want to avoid that and preserve sharp labels and other subviews, you need to try to scale the view ideally to the desired size. In my case, make it from 275 x 487 to 1080 x 1920.
func makeImageFrom(_ desiredView: MyView) -> UIImage {
let format = UIGraphicsImageRendererFormat()
// We need to divide desired size with renderer scale, otherwise you get output size larger #2x or #3x
let size = CGSize(width: 1080 / format.scale, height: 1920 / format.scale)
let renderer = UIGraphicsImageRenderer(size: size, format: format)
let image = renderer.image { (ctx) in
// remake constraints or change size of desiredView to 1080 x 1920
// handle it's subviews (update font size etc.)
// ...
desiredView.drawHierarchy(in: CGRect(origin: .zero, size: size), afterScreenUpdates: true)
// undo the size changes
// ...
}
return image
}
My approach
But because I didn't want to mess with a size of a view displayed to the user, I took a different way and used second view which isn't shown to the user. That means that just before I want to capture image, I prepare "duplicated" view with the same content but bigger size. I don't add it to the view controller's view hierarchy, so it's not visible.
Important note!
You really need to take care of subviews. That means, that you have to increase the font size, update position of moved subviews (for example their center) etc.!
Here is just a few lines to illustrate that:
// 1. Create bigger view
let hdView = MyView()
hdView.frame = CGRect(x: 0, y: 0, width: 1080, height: 1920)
// 2. Load content according to the original view (desiredView)
// set text, images...
// 3. Scale subviews
// Find out what scale we need
let scaleMultiplier: CGFloat = 1080 / desiredView.bounds.width // 1080 / 275 = 3.927 ...
// Scale everything, for examples label's font size
[label1, label2].forEach { $0.font = UIFont.systemFont(ofSize: $0.font.pointSize * scaleMultiplier, weight: .bold) }
// or subview's center
subview.center = subview.center.applying(.init(scaleX: scaleMultiplier, y: scaleMultiplier))
// 4. Render image from hdView
let hdImage = makeImageFrom(hdView)
Difference in quality from real usage – zoomed to the label:
Related
Im trying to remove the top part of an image by cropping, but the result is unexpected.
The code used:
extension UIImage {
class func removeStatusbarFromScreenshot(_ screenshot:UIImage) -> UIImage {
let statusBarHeight = 44.0
let newHeight = screenshot.size.height - statusBarHeight
let newSize = CGSize(width: screenshot.size.width, height: newHeight)
let newOrigin = CGPoint(x: 0, y: statusBarHeight)
let imageRef:CGImage = screenshot.cgImage!.cropping(to: CGRect(origin: newOrigin, size: newSize))!
let cropped:UIImage = UIImage(cgImage:imageRef)
return cropped
}
}
My logic is that I need to make the image smaller in heigh by 44px and move the origin y by 44px, but it ends up only creating an image much smaller of the top left corner.
The only way that I get it to work as expected is by multiplying the width by 2 and height by 2.5 in newSize, but that also double the size of the image produced..
Which anyways doesnt make much sense.. can someone help make it work without using magic values?
There are two main problems with what you're doing:
A UIImage has a scale (usually tied to resolution of your device's screen), but a CGImage does not.
Different devices have different "status bar" heights. In general, what you want to cut off from the top is not the status bar but the safe area. The top of the safe area is where your content starts.
Because of this:
You are wrong to talk about 44 px. There are no pixels here. Pixels are physical atomic illuminations on your screen. In code, there are points. Points are independent of the scale (and the scale is the multiplier between points and pixels).
You are wrong to talk about the number 44 itself as if it were hard-coded. You should get the top of the safe area instead.
By crossing into the CGImage world without taking scale into account, you lose the scale information, because CGImage knows nothing of scale.
By crossing back into the UIImage world without taking scale into account, you end up with a UIImage with a resolution of 1, which may not be the resolution of the original UIImage.
The simplest solution is not to do any of what you are doing. First, get the height of the safe area; call it h. Then just draw the snapshot image into a graphics image context that is the same scale as your image (which, if you play your cards right, it will be automatically), but is h points shorter than the height of your image — and draw it with its y origin at -h, thus cutting off the safe area. Extract the resulting image and you're all set.
Example! This code comes a view controller. First, I'll take a screenshot of my own device's current screen (this view controller's view) as my app runs:
let renderer = UIGraphicsImageRenderer(size: view.bounds.size)
let screenshot = renderer.image { context in
view.layer.render(in: context.cgContext)
}
Now, I'll cut the safe area off the top of that screenshot:
let h = view.safeAreaInsets.top
let size = screenshot.size
let r = UIGraphicsImageRenderer(
size: .init(width: size.width, height: size.height - h)
)
let result = r.image { _ in
screenshot.draw(at: .init(x: 0, y: -h))
}
Experimentation will confirm that this works perfectly on every device, regardless of whether it has a bezel and regardless of its screen resolution: the top of the resulting image, result, is the top of your actual content.
I'm using AlamoFireImage to crop an user profile picture before sending it to the server. Our server has some restrictions and we can't send images larger than 640x640.
I'm using the af_imageAspectScaled UIImage extension function like so:
let croppedImage = image.af_imageAspectScaled(
toFill: CGSize(
width: 320,
height: 320
)
)
I was expecting this to crop image to a 320px by 320px image. However I found out that the output image is being saved as a 640x640px image with scale 2.0. The following XCTest shows this:
class UIImageTests: XCTestCase {
func testAfImageAspectScaled() {
if let image = UIImage(
named: "ipad_mini2_photo_1.JPG",
in: Bundle(for: type(of: self)),
compatibleWith: nil
) {
print (image.scale) // prints 1.0
print (image.size) // prints (1280.0, 960.0)
let croppedImage = image.af_imageAspectScaled(
toFill: CGSize(
width: 320,
height: 320
)
)
print (croppedImage.scale) // prints 2.0
print (croppedImage.size) // prints (320.0, 320.0)
}
}
}
I'm running this on the iPhone Xr simulator on Xcode 10.2.
The original image is 1280 by 960 points, with scale 1, which would be equivalent to 1280 by 960 pixels. The cropped image is 320 by 320 points, with scale 2, which would be equivalent to 640 by 640 pixels.
Why is the scale set to 2? Can I change that? How can I generate a 320 by 320 pixels image independent on the scale and device?
Well, checking the source code for the af_imageAspectScaled method I found the following code for generating the actual scaled image:
UIGraphicsBeginImageContextWithOptions(size, af_isOpaque, 0.0)
draw(in: CGRect(origin: origin, size: scaledSize))
let scaledImage = UIGraphicsGetImageFromCurrentImageContext() ?? self
UIGraphicsEndImageContext()
The parameter with value 0.0 on UIGraphicsBeginImageContextWithOptions tells the method to use the main screen scale factor for defining the image size.
I tried setting this to 1.0 and, when running my testcase, af_imageAspectScaled generated an image with the correct dimensions I wanted.
Here there is a table showing all the iOS devices resolutions. My app was sending appropriate sized images for all devices where the scale factor was 2.0, however several devices have scale factor 3.0. For those the app wasn't working.
Well, unfortunately it seems that if I want to use af_imageAspectScaled I have to divide the final size I want by the device's scale when setting the scaled size like so:
let scale = UIScreen.main.scale
let croppedImage = image.af_imageAspectScaled(
toFill: CGSize(
width: 320/scale,
height: 320/scale
)
)
I've sent a pull request to AlamofireImage proposing the addition of a parameter scale to the functions af_imageAspectScaled(toFill:), af_imageAspectScaled(toFit:) and af_imageScaled(to:). If they accept it, the above code should become:
// this is not valid with Alamofire 4.0.0 yet! waiting for my pull request to
// be accepted
let croppedImage = image.af_imageAspectScaled(
toFill: CGSize(
width: 320,
height: 320
),
scale: 1.0
)
// croppedImage would be a 320px by 320px image, regardless of the device type.
I have a UIScrollView which contains a UIImage. On top of that is a box that the user can move the image, so that that portion is cropped.
This screenshot explains it better:
So they can scroll the image around until the portion they want is inside that box.
I then want to be able to crop the scrollView/UIImage to exactly that size and store the cropped image.
It shouldn't be very hard but I've spent ages trying screenshots, UIGraphicsContext, etc. and cant seem to get anything to work.
Thanks for the help.
I finally figured out how to get it to work. Here is the code:
func croppedImage() -> UIImage {
let cropSize = CGSize(width: 280, height: 280)
let scale = (imageView.image?.size.height)! / imageView.frame.height
let cropSizeScaled = CGSize(width: cropSize.width * scale, height: cropSize.height * scale)
if #available(iOS 10.0, *) {
let r = UIGraphicsImageRenderer(size: cropSizeScaled)
let x = -scrollView.contentOffset.x * scale
let y = -scrollView.contentOffset.y * scale
return r.image { _ in
imageView.image!.draw(at: CGPoint(x: x, y: y))
}
} else {
return UIImage()
}
}
So it first calculates the scale of the imageView and the actual image.
Then it creates a CGSize of that crop box as shown in the photo. However, the width and height must be scaled by the scale factor. (e.g. 280 * 6.5)
You must check if the phone is running iOS 10.0 for UIGraphicsImageRender - if not, it won't work.
Initialise this with the crop box size.
The image must then be offset, and this is calculated by getting the scrollView's content offset, negating it, and multiplying by the scale factor.
Then return the image drawn at that point!
By default UIGraphicsImageRenderer sets the scale to the device's screen scale, on iPhone 6s it's 2x and iPhone 6s Plus 3x, therefore even though you've given it a size with dimension 300 it's creating it at either 600 or 900 depending on which device is being used. When you want to ensure it's always 300, how do you set the scale?
let outputBounds = CGRect(x: 0, y: 0, width: 300, height: 300)
let renderer = UIGraphicsImageRenderer(bounds: outputBounds)
let image = renderer.image { context in
//...
}
Previously you would set the scale via the last parameter here:
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 1)
You should use the UIGraphicsImageRendererFormat class when creating your UIGraphicsImageRenderer. If you want to write exact pixels rather than scaled points, use something like this:
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: yourImageSize, format: format)
let image = renderer.image { ctx in
// etc
}
I'm keeping a list of iOS 10 code examples and will add one based on this.
There are several questions on SO asking how to render a UIView into a PDF context, but they all use view.layer.renderInContext(pdfContext), which results in a 72 DPI image (and one that looks terrible when printed). What I'm looking for is a technique to somehow get the UIView to render at something like 300 DPI.
In the end, I was able to take hints from several prior posts and put together a solution. I'm posting this since it took me a long time to get working, and I really hope to save someone else time and effort doing the same.
This solution uses two basic techniques:
Render the UIView into a scaled bitmap context to produce a large image
Draw the image into a PDF Context which has been scaled down, so that the drawn image has a high resolution
Build your view:
let v = UIView()
... // then add subviews, constraints, etc
Create the PDF Context:
UIGraphicsBeginPDFContextToData(data, docRect, stats.headerDict) // zero == (612 by 792 points)
defer { UIGraphicsEndPDFContext() }
UIGraphicsBeginPDFPage();
guard let pdfContext = UIGraphicsGetCurrentContext() else { return nil }
// I tried 300.0/72.0 but was not happy with the results
let rescale: CGFloat = 4 // 288 DPI rendering of VIew
// You need to change the scale factor on all subviews, not just the top view!
// This is a vital step, and there may be other types of views that need to be excluded
Then create a large bitmap of the image with an expanded scale:
func scaler(v: UIView) {
if !v.isKindOfClass(UIStackView.self) {
v.contentScaleFactor = 8
}
for sv in v.subviews {
scaler(sv)
}
}
scaler(v)
// Create a large Image by rendering the scaled view
let bigSize = CGSize(width: v.frame.size.width*rescale, height: v.frame.size.height*rescale)
UIGraphicsBeginImageContextWithOptions(bigSize, true, 1)
let context = UIGraphicsGetCurrentContext()!
CGContextSetFillColorWithColor(context, UIColor.whiteColor().CGColor)
CGContextFillRect(context, CGRect(origin: CGPoint(x: 0, y: 0), size: bigSize))
// Must increase the transform scale
CGContextScaleCTM(context, rescale, rescale)
v.layer.renderInContext(context)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
Now we have a large image with each point representing one pixel.
To get it drawn into the PDF at high resolution, we need to scale the PDF down while drawing the image at its large size:
CGContextSaveGState(pdfContext)
CGContextTranslateCTM(pdfContext, v.frame.origin.x, v.frame.origin.y) // where the view should be shown
CGContextScaleCTM(pdfContext, 1/rescale, 1/rescale)
let frame = CGRect(origin: CGPoint(x: 0, y: 0), size: bigSize)
image.drawInRect(frame)
CGContextRestoreGState(pdfContext)
... // Continue with adding other items
You can see that the left "S" contained in the cream colored bitmap looks pretty nice compared to a "S" drawn but an attributed string:
When the same PDF is viewed by a simple rendering of the PDF without all the scaling, this is what you would see: