I have the following View extension function which creates a UIImage of the SwiftUI view by putting it in a UIHostingController then using a UIGraphicsImageRenderer to render the view:
extension View {
func snapshot() -> UIImage {
let controller = UIHostingController(rootView: self.ignoresSafeArea())
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { _ in
view?.drawHierarchy(
in: controller.view.bounds,
afterScreenUpdates: true
)
}
}
}
This works but it blocks the main thread completely such that any loading spinners I display on screen don't spin. How can I stop that, or is there another way of creating a snapshot that I could use that doesn't block the main thread?
You can't run UI on background, but as help states about drawHierarchy:
Use this method when you want to apply a graphical effect, such as a blur, to a view snapshot. This method is not as fast as the snapshotView(afterScreenUpdates:) method.
Since you are not applying any graphical effect, you could use snapshotView, which as help again states:
This method very efficiently captures the current rendered appearance of a view and uses it to build a new snapshot view. You can use the returned view as a visual stand-in for the current view in your app. For example, you might use a snapshot view for animations where updating a large view hierarchy might be expensive. Because the content is captured from the already rendered content, this method reflects the current visual appearance of the view and is not updated to reflect animations that are scheduled or in progress. However, calling this method is faster than trying to render the contents of the current view into a bitmap image yourself.
Related
I have recently moved a bunch of UIImageViews into nested Stack Views (Horizontal and Vertical). Whenever the user presses the play button it animates, then starts a timer. Once the timer reaches 0 it is supposed to flip each card consecutively and hide the image itself. This was working until I added them to a Stack View. I've read that the stack views are buggy when it comes to this sort of thing, but any attempt at fixing it hasn't worked thus far. I'm at an impasse, can anyone point me in the right direction?
Issue Occurs In Here I Think
//INFO: Disable cards while viewing.
for card in cardsPhone
{
card.isUserInteractionEnabled = false
flipCard(sender: card)
}
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 5) {
for card in self.cardsPhone
{
DispatchQueue.main.async {
//THIS IS THE ANIMATION CALL THAT MAKES VIEWS DISAPPEAR.
self.flipCard(sender: card)
}
usleep(20000)
}
DispatchQueue.main.async{
//INFO: Enable cards after hidden.
for card in self.cardsPhone
{
card.isUserInteractionEnabled = true
}
//INFO: Enable play button after cards are hidden to prevent crashing layout.
self.playButtonView.isUserInteractionEnabled = true
}
}
Flip Animation Controller
func flipCard(sender: UIImageView)
{
playButtonView.isUserInteractionEnabled = false
//INFO: If there is no UIImage, assign the appropriate image from the array.
if sender.image == nil
{
sender.image = cardsImages[sender.tag]
UIView.transition(with: sender, duration: 0.3, options: .transitionFlipFromLeft, animations: nil, completion: nil)
}
//INFO: If there is an image, remove it and replace with no Image.
else
{
sender.image = nil
UIView.transition(with: sender, duration: 0.3, options: .transitionFlipFromRight, animations: nil, completion: nil)
}
}
Update
I've discovered that if I replace the image on the UIImageView then that is actually what is causing it to disappear. So the issue is related to the code above in the else statement where sender.image = UIImage(). It doesn't matter if I replace with a different image or not, the moment the image is changed it disappears.
I've read that the stack views are buggy when it comes to this sort of thing
Well, that's not so. They are in fact quite sophisticated and consistent, and their behavior is well defined.
What is a stack view? It's a builder of autolayout constraints. And that is all it is. Putting views inside a stack view means "Please configure these views with equal spacing" (or whatever your setting is for the stack view) "using autolayout constraints that you impose."
Okay, but how does the stack view do that? Autolayout requires four pieces of information, x and y position, and width and height. The stack view will supply the position, but what about the size (width and height) of its views? Well, one way to tell it is to give your views internal constraints (if they don't conflict with what else the stack view will do). But in the absence of that, it has to use the intrinsic size of its views. And the intrinsic size of an image view under autolayout is the size of the image it contains.
So everything was fine when you had image views with images that were the same size. They all had the same intrinsic size and now the stack view could space them out. But then you took away an image view's image. Now it has no image. So it has no intrinsic size. So the stack view just removes it from the row (or column or whatever it is).
Your workaround is actually quite a good one, namely, to make sure that the image view always has an image; if it is to look blank, you just give it a blank image. The image looks like the background, but it is still an image with an actual size, and so it gives the image view a size and the image view continues to hold its place.
However another solution would have been simply to give the image view a width constraint the same size as the card image's width.
Solution
I've discovered that changing an image on a UIImageView while it's inside of a stack causes it to completely disappear. This is a weird Xcode but but the solution I found was to create a custom background on the fly to get rid of the image and only have the background color. This was done with the following code snippet. It's a bit messy, but it gets the job done.
func setColorImage(viewToModify: UIImageView, colorToSet: UIColor)
{
let rect = CGRect(origin: .zero, size: viewToModify.layer.bounds.size)
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
colorToSet.setFill()
UIRectFill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard (image?.cgImage) != nil else { return }
viewToModify.image = image
viewToModify.backgroundColor = colorToSet
viewToModify.isUserInteractionEnabled = false
}
Essentially this code creates a new cgRect, sets the color, creates a new image from that color, then applies it back to the UIImageView.
I need convert the contents of an UICollectionView into an UIIImage. But, the image only contains the visible portion of the UICollectionView and leaves out the parts which are yet to be rendered. Is there any that I can achieve it ?
I am using the following extension
func toImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(size: self.bounds.size)
return renderer.image { ctx in
self.drawHierarchy(in: self.bounds, afterScreenUpdates: true)
}
}
Is there any that I can achieve it ?
No, at least not by a simple screen capture. The offscreen cells do not even exist, so there is nothing for you to capture.
There are two reasons why your full Collection View is not rendered as an Image.
You call drawHierarchy in self.bounds, which will only capture the content in the specified bounds
The rest of the collection view will not be rendered as the rest of the cells will be off screen.
I am creating an image from a UIlablel‘s text in a UIView subclass. The UIlablel‘s text is set in awakeFromNib and will be different everytime the app launches. The only place that the image captures the entire UIlablel‘s text is in layoutSubviews —> due to frame sizes I believe. Notice in my code below that I am relying on the bounds on the label's parent view. The problem is that layoutSubviews is called multiple times so this image might be created many times. I tried to capture the image in didMoveToWindow and other places, but only a portion of the UIlablel‘s text is captured. This is code to make image: Any thoughts?
func createImage(withText text: NSAttributedString, inParent parent: UIView) -> UIImage {
UIGraphicsBeginImageContextWithOptions(frame.size, false, 0)
text.draw(in: parent.bounds)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image!
}
The problem is that layoutSubviews is called multiple times so this image might be created many times
But that is why you have been given the if statement, so that you can make sure the image is only created once.
var createdImage = false
func createImage( ... {
if !createdImage {
createdImage = true
// create image
}
}
I usually use another variable to know if layoutSubviews have been called.
var layoutInvoked = false
func layoutSubviews () -> (Void)
{
super.layoutSubviews()
if layoutInvoked == false
{
layoutInvoked = true
// Do your stuff
}
}
I don't know if there is easier way, but this one does job for me.
This is how I take a screenshot of my view:
UIGraphicsBeginImageContextWithOptions(view.bounds.size, view.opaque, 0.0)
view.drawViewHierarchyInRect(view.bounds, afterScreenUpdates: true)
let img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
However, in the view, there is a UIVisualEffectsView which I'd like to exclude from the screenshot.
I tried hiding the UIVisualEffectsView just before taking the screenshot and un-hiding it afterwards but I don't want the user to see that process. (which he does if I simply hide the view because the iPad is too slow and it looks like the screen is flickering...)
Any ideas? Thanks in advance!
I would take advantage of the snapshotViewAfterScreenUpdates() method
This method very efficiently captures the current rendered appearance of a view and uses it to build a new snapshot view. You can use the returned view as a visual stand-in for the current view in your app.
Therefore you can use it in order to display an overlay UIView of the complete un-altered view hierarchy to the user while rendering a version of the hierarchy with your changes underneath it.
The only caveat is that if you're capturing a view controller's hierarchy, you'll have to create a 'content view' subview in order to prevent the overlay view from being rendered in your screenshot where you make the changes to the hierarchy. You'll then want to add your view hierarchy that you want to render to this 'content view'.
So your view hierarchy will want to look something like this:
UIView // <- Your view
overlayView // <- Only present when a screenshot is being taken
contentView // <- The view that gets rendered in the screenshot
view(s)ToHide // <- The view(s) that get hidden during the screenshot
Although, if you are able to add the overlayView to the view's superview - instead of to the view itself – you don't need to mess about with the hierarchy at all. For example:
overlayView // <- Only present when a screenshot is being taken
UIView // <- Your view – You can render this in the screenshot
view(s)ToHide // <- The view(s) that get hidden during the screenshot
otherViews // <- The rest of your hierarchy
Something like this should achieve the desired result:
// get a snapshot view of your content
let overlayView = contentView.snapshotViewAfterScreenUpdates(true)
// add it over your view
view.addSubview(overlayView)
// do changes to the view heirarchy
viewToHide.hidden = true
// begin image context
UIGraphicsBeginImageContextWithOptions(contentView.frame.size, false, 0.0)
// render heirarchy
contentView.drawViewHierarchyInRect(contentView.bounds, afterScreenUpdates: true)
// get image and end context
let img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// reverse changes to the view heirarchy
viewToHide.hidden = false
// remove the overlay view
overlayView.removeFromSuperview()
Maybe the simplest solution would be not to include your unwanted view into the hierarchy of the taken as screenshot view. You could simply put it on top of it.
Oh, actually, just following code (swift 4) can work. That is, create a context, and add the item you want in the snapshot. No need to add view in real screen.
Note: currentView is the view which snapshot based on, commonly, it will be view of whole screen. And addViews are the views you want to add.
func takeSnapShot(currentView: UIView , addViews: [UIView], hideViews: [UIView]) -> UIImage {
for hideView in hideViews {
hideView.isHidden = true
}
UIGraphicsBeginImageContextWithOptions(currentView.frame.size, false, 0.0)
currentView.drawHierarchy(in: currentView.bounds, afterScreenUpdates: true)
for addView in addViews{
addView.drawHierarchy(in: addView.frame, afterScreenUpdates: true)
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
for hideView in hideViews {
hideView.isHidden = false
}
return image!
}
Actually there is an easy way to do it.
For example we have a share button and View in storyboard and process with it in sample below code.
#IBAction func share(_ sender: Any) {
self.hideView.isHidden = true
let bounds = UIScreen.main.bounds
UIGraphicsBeginImageContextWithOptions(bounds.size, true, 0.0)
self.view.drawHierarchy(in: bounds, afterScreenUpdates: true)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
//Add Activity view controller to use it
self.hideView.isHidden = false
}
I used it for hiding Ads if the screenshot taken with my share button and it works for me.
afterScreenUpdates: true works here for hide to view while you click the button. It hides for millisecond hideView and took screenshot then it appears back to your view like it was never hidden.
*This is my first answer and hope it helps someone.
Thanks.
This is how I take a screenshot of my view:
UIGraphicsBeginImageContextWithOptions(view.bounds.size, view.opaque, 0.0)
view.drawViewHierarchyInRect(view.bounds, afterScreenUpdates: true)
let img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
However, in the view, there is a UIVisualEffectsView which I'd like to exclude from the screenshot.
I tried hiding the UIVisualEffectsView just before taking the screenshot and un-hiding it afterwards but I don't want the user to see that process. (which he does if I simply hide the view because the iPad is too slow and it looks like the screen is flickering...)
Any ideas? Thanks in advance!
I would take advantage of the snapshotViewAfterScreenUpdates() method
This method very efficiently captures the current rendered appearance of a view and uses it to build a new snapshot view. You can use the returned view as a visual stand-in for the current view in your app.
Therefore you can use it in order to display an overlay UIView of the complete un-altered view hierarchy to the user while rendering a version of the hierarchy with your changes underneath it.
The only caveat is that if you're capturing a view controller's hierarchy, you'll have to create a 'content view' subview in order to prevent the overlay view from being rendered in your screenshot where you make the changes to the hierarchy. You'll then want to add your view hierarchy that you want to render to this 'content view'.
So your view hierarchy will want to look something like this:
UIView // <- Your view
overlayView // <- Only present when a screenshot is being taken
contentView // <- The view that gets rendered in the screenshot
view(s)ToHide // <- The view(s) that get hidden during the screenshot
Although, if you are able to add the overlayView to the view's superview - instead of to the view itself – you don't need to mess about with the hierarchy at all. For example:
overlayView // <- Only present when a screenshot is being taken
UIView // <- Your view – You can render this in the screenshot
view(s)ToHide // <- The view(s) that get hidden during the screenshot
otherViews // <- The rest of your hierarchy
Something like this should achieve the desired result:
// get a snapshot view of your content
let overlayView = contentView.snapshotViewAfterScreenUpdates(true)
// add it over your view
view.addSubview(overlayView)
// do changes to the view heirarchy
viewToHide.hidden = true
// begin image context
UIGraphicsBeginImageContextWithOptions(contentView.frame.size, false, 0.0)
// render heirarchy
contentView.drawViewHierarchyInRect(contentView.bounds, afterScreenUpdates: true)
// get image and end context
let img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// reverse changes to the view heirarchy
viewToHide.hidden = false
// remove the overlay view
overlayView.removeFromSuperview()
Maybe the simplest solution would be not to include your unwanted view into the hierarchy of the taken as screenshot view. You could simply put it on top of it.
Oh, actually, just following code (swift 4) can work. That is, create a context, and add the item you want in the snapshot. No need to add view in real screen.
Note: currentView is the view which snapshot based on, commonly, it will be view of whole screen. And addViews are the views you want to add.
func takeSnapShot(currentView: UIView , addViews: [UIView], hideViews: [UIView]) -> UIImage {
for hideView in hideViews {
hideView.isHidden = true
}
UIGraphicsBeginImageContextWithOptions(currentView.frame.size, false, 0.0)
currentView.drawHierarchy(in: currentView.bounds, afterScreenUpdates: true)
for addView in addViews{
addView.drawHierarchy(in: addView.frame, afterScreenUpdates: true)
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
for hideView in hideViews {
hideView.isHidden = false
}
return image!
}
Actually there is an easy way to do it.
For example we have a share button and View in storyboard and process with it in sample below code.
#IBAction func share(_ sender: Any) {
self.hideView.isHidden = true
let bounds = UIScreen.main.bounds
UIGraphicsBeginImageContextWithOptions(bounds.size, true, 0.0)
self.view.drawHierarchy(in: bounds, afterScreenUpdates: true)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
//Add Activity view controller to use it
self.hideView.isHidden = false
}
I used it for hiding Ads if the screenshot taken with my share button and it works for me.
afterScreenUpdates: true works here for hide to view while you click the button. It hides for millisecond hideView and took screenshot then it appears back to your view like it was never hidden.
*This is my first answer and hope it helps someone.
Thanks.