viewWillTransitionToSize causes non-rotating view controller to resize and reposition - ios

A lot of people have discussed techniques for mimicking the native iOS camera app (where the UI elements pivot in-place as the device rotates). I actually asked a question about it before here. Most people have you lock the orientation of the UI, but then force a transformation on just the elements that you want to pivot. This works, but the elements don't pivot with the smooth animations you see in the native iOS app and it leads some issues. Specifically, part of my interface allows users to share without leaving this interface, but when the sharing view gets rotated, it comes out off-center. So I wanted to find a different way to do this.
I found a link to Apple's AVCam sample, which got me off to a start here. I'm new to this stuff, but I managed to convert it from Obj-C to Swift already. Below is the key element of what I'm currently using:
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animateAlongsideTransition({ (UIViewControllerTransitionCoordinatorContext) -> Void in
//Code here performs animations during the rotation
let deltaTransform: CGAffineTransform = coordinator.targetTransform()
let deltaAngle: CGFloat = atan2(deltaTransform.b, deltaTransform.a)
var currentRotation: CGFloat = self.nonRotatingView.layer.valueForKeyPath("transform.rotation.z") as! CGFloat
// Adding a small value to the rotation angle forces the animation to occur in a the desired direction, preventing an issue where the view would appear to rotate 2PI radians during a rotation from LandscapeRight -> LandscapeLeft.
currentRotation += -1 * deltaAngle + 0.0001;
self.nonRotatingView.layer.setValue(currentRotation, forKeyPath: "transform.rotation.z")
}, completion: { (UIViewControllerTransitionCoordinatorContext) -> Void in
print("rotation completed");
// Code here will execute after the rotation has finished
// Integralize the transform to undo the extra 0.0001 added to the rotation angle.
var currentTransform: CGAffineTransform = self.nonRotatingView.transform
currentTransform.a = round(currentTransform.a)
currentTransform.b = round(currentTransform.b)
currentTransform.c = round(currentTransform.c)
currentTransform.d = round(currentTransform.d)
self.nonRotatingView.transform = currentTransform
})
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
}
I then have separate view controllers for icons that do the same transformation in the opposite direction. The icons actually pivot in place properly now (with smooth animations and everything) and the camera preview stays properly oriented.
The problem is, the non-rotating view gets resized and everything gets misplaced when I rotate the device from portrait to landscape or vice-versa. How do I fix that?

I got it working (though it took me way more time than it should have). I needed to set the width and height parameters of the non-rotating view to remove at build time and then added the following to viewDidLoad():
let nonRotatingWidth = nonRotatingView.widthAnchor.constraintEqualToConstant(UIScreen.mainScreen().bounds.width)
let nonRotatingHeight = nonRotatingView.heightAnchor.constraintEqualToConstant(UIScreen.mainScreen().bounds.height)
nonRotatingView.addConstraints([nonRotatingWidth, nonRotatingHeight])
This uses the new Swift 2 constraint syntax, which is relatively concise and easy to read.
I've also tinkered with only using the new constraint syntax (and no transformations) to achieve the same type of interface via updateViewConstraints() (see my question about it here). I believe that approach is a cleaner way to create this type of interface, but I have not yet been able to make it work without crashing at runtime.

Related

Sizing a SKScene to fit within iOS screen boundaries

I have written a small macOS application that sizes a 64 x 64 grid of SKSpriteNodes based on the smaller of the height or width of the view:
let pixelSize = min(self.size.width / Double(numColumns), self.size.height / Double(numRows))
...numRows and Columns are both 64. When the window opens at 1024 x 768, it calculates based on 768 and produces a square drawing area centered in the window, and it follows resizes and such.
I then used the Xcode template to add an iOS target. I was surprised to find that the size of the view remains 1024 x 768, in spite of being in a view in Main.storyboard that is 380 x 844. In the storyboard, Game Controller View Scene is set to Aspect Fit and Resize Subviews is turned on. I tried Clips to Bounds and several other settings, but it seems the Scene simply isn't being resized.
Did I miss a setting somewhere? Or should I calculate my sizes some other way?
from within SKScene you can access size via self.view?.frame.size
note however that if you draw your scene once via didMove(to view: SKView) you will
potentially miss important resize events that can and will effect the dimensions
of your scene. for instance, on macOS the storyboard dimensions will often be overridden
with cached values after the application launches. for this reason, i suggest adding responders for resize on macOS
and rotation on iOS.
//macOS -- respond to window resize
class GameViewController: NSViewController {
//called on window resize
//(note: NSWindow.didResizeNotification also accesses this event and may be better in some cases)
override func viewDidLayout() {
print("view been resized to \(self.view.frame.size)")
}
}
//iOS -- respond to device rotation
class GameViewController: UIViewController {
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animate(alongsideTransition: { context in
// This is called during the animation
}, completion: { context in
// This is called after the rotation is finished. Equal to deprecated `didRotate`
print("view been resized to \(self.view.frame.size)")
})
}
}
ps your code says pixels, but my guess is you probably mean points. a point is aways 1/72th of an
inch, i.e. 72dpi. by contrast, pixels will vary depending on the device's resolution
(x2 for iPhone 8, x3 for iPhone 12, etc.). i basically only ever care about points when
designing an app, not pixels. although this isn't a hard and fast rule and ymmv etc etc.

What is the best way to rotate a view in swift and convert it to an image?

I have been given the task of creating a dynamic "ticket" in Swift. I am passed the ticket number, amount, etc from our servers API, and I am to generate the barcode, along with all labels associated with this ticket. I am able to generate all the necessary data without any issues.
The problem
The issue arises with laying it out. I need to have a thumbnail view for this ticket, along with a fullscreen view. This seems to be best done by converting the view into an image (right?) as it allows for features like zooming, having the thumbnail view etc. The main cause of the issue is the ticket labels and barcode need to be laid out vertically, or basically in landscape mode.
What I've tried
UIGraphicsBeginImageContext
I have created the image manually with UIGraphicsBeginImageContext() and associated APIs. This allows me to flip each view and convert it to an image. However, this method forces me to manually create a frame for each view and loses all accuracy and does not seem like the right way to do it when I have to add 10-15labels to a blank image.
AutoLayout
Next I tried laying everything out in a UIView with autolayout and applying a CGAffineTransform to each view and then converting the whole view to an image. This seems to work with the exception that I lose precision and can't line up views correctly. CGAffineTransform throws off constraints completely and I have to experiment with constraint constants until I get the view looking somewhat right and even then that doesn't translate all that well to all device sizes.
Landscape Mode
Lastly, I tried laying out the views normally, and forcing the view into landscape mode. Aside from the number of issues that arose because my app only supports portrait mode, I got it to work when the view is presented, but I have no idea how to get the thumbnail view which is supposed to show before the ticket view is presented to be in landscape mode. If I try doing so the thumbnail comes out in portrait mode and not landscape.
Do you guys have any ideas on a better way to accomplish this or should I stick to one of the methods that I've tried and try to work out all the bugs? I can provide code as needed but there's a lot that goes into it so I didn't want to just throw all the code in here if it wasn't necessary.
The following is an example of what I need to create except I need to add additional labels on there such as issue date, expiration date, etc:
Any help would be appreciated!
You asked:
What is the best way to rotate a view in swift and convert it to an image?
If you want to create a rotated snapshot of a view, apply a rotate and a translateBy to the context:
func clockwiseSnapshot(of subview: UIView) -> UIImage {
var rect = subview.bounds
swap(&rect.size.width, &rect.size.height)
return UIGraphicsImageRenderer(bounds: rect).image { context in
context.cgContext.rotate(by: .pi / 2)
context.cgContext.translateBy(x: 0, y: -rect.width)
subview.drawHierarchy(in: subview.bounds, afterScreenUpdates: true)
}
}
Or
func counterClockwiseSnapshot(of subview: UIView) -> UIImage {
var rect = subview.bounds
swap(&rect.size.width, &rect.size.height)
return UIGraphicsImageRenderer(bounds: rect).image { context in
context.cgContext.rotate(by: -.pi / 2)
context.cgContext.translateBy(x: -rect.height, y: 0)
subview.drawHierarchy(in: subview.bounds, afterScreenUpdates: true)
}
}
Obviously, if you want the Data associated with the image, instead, use pngData or jpegData instead:
func clockwiseSnapshotData(of subview: UIView) -> Data {
var rect = subview.bounds
swap(&rect.size.width, &rect.size.height)
return UIGraphicsImageRenderer(bounds: rect).pngData { context in
context.cgContext.rotate(by: .pi / 2)
context.cgContext.translateBy(x: 0, y: -rect.width)
subview.drawHierarchy(in: subview.bounds, afterScreenUpdates: true)
}
}
Or
func counterClockwiseSnapshotData(of subview: UIView) -> Data {
var rect = subview.bounds
swap(&rect.size.width, &rect.size.height)
return UIGraphicsImageRenderer(bounds: rect).pngData { context in
context.cgContext.rotate(by: -.pi / 2)
context.cgContext.translateBy(x: -rect.height, y: 0)
subview.drawHierarchy(in: subview.bounds, afterScreenUpdates: true)
}
}
If you don’t really need the image, but just want to rotate it in the UI, then apply a transform to the view that contains all of these subviews:
someView.transform = .init(rotationAngle: .pi / 2)

Adding UIView Laggy on Specific iPad Models

I have a pdf viewer for sheet music, which is based on PDFKit. PDFKit has an option to use an internal UIPageViewController, but it is very problematic - you cannot set the transition type, and worse than that, there is no way to check whether a page swipe succeeded or failed. You end up seeing one page, while the reported page index is another one.
Therefore I decided to create my own page flipping method. I added a UITapGestureRecognizer, and when the right or left edges are tapped, the page flips programmatically. To achieve curl animation, I add a UIView with the same image of what's underneath it, do the curl animation to the PDFView, and then remove the view. Here is part of the code:
// Function to flip pages with page curl
func flipPage (direction: String) {
let renderer = UIGraphicsImageRenderer(size: pdfView.bounds.size)
let image = renderer.image { ctx in
pdfView.drawHierarchy(in: pdfView.bounds, afterScreenUpdates: true)
}
let imageView = UIImageView(image: image)
imageView.frame = pdfView.frame
imageView.tag = 830
self.view.addSubview(imageView)
self.view.bringSubviewToFront(imageView)
if direction == "forward" && pdfView.canGoToNextPage() {
pdfView.goToNextPage(nil)
let currentImageView = self.view.subviews.filter({$0.tag == 830})
if currentImageView.count > 0 {
UIView.transition(from: currentImageView[0],
to: pdfView, duration: 0.3,
options: [.transitionCurlUp, .allowUserInteraction],
completion: {finished in
currentImageView[0].removeFromSuperview()
})
}
}
Now comes the weird part. On my own iPad Pro 12.9 inches 1st generation, this method of flipping is blazing fast. No matter the build configuration or optimization level, it simply works. If I tap in a fast succession, the pages flip as fast as I tap.
I have users with the 2nd gen iPad Pro 12.9, and they experience a terrible lag when the UIView is drawn on top of the PDFView. This also happens on all build configurations - it happened with a release build, and also happened when I installed a debug build from my computer on such a device (sadly, I could not keep the device to explore things further).
There are several other instances in the app in which I add a UIView on top - to add a semi-transparent veil, or to capture UIGestureRecognizers. On my own device, these are all very fast. On the iPad 2nd gen, each and every one causes a lag. Incidentally, a user with a 3rd gen iPad Pro reported that the performance was very fast on his device, without any lags. On the simulator the animation is sometimes incomplete, but the response is as fast as it should be - for all iPad models.
I searched for answers, and found absolutely no references to such a weird situation. Has anyone experienced anything like this? Any quick fixes, or noticeable problems in the logic of my code?
I am afraid that if I try to draw the custom UIViews ahead of time, and only bring them to the front when needed, I'll end up with a ridiculously large amount of UIViews in the background, and simply move the delay elsewhere.
After doing a bit of research, I can provide a solution for people who face similar issues. The problem appears to be scheduling.
I still do not know why the 2017 models schedule their threads differently. Any ideas about why this problem reared its head in the first place is welcome. However -
I was not, in fact, following the best practice. Changes to the UI should always happen in the main thread, so if you encounter a lag like this, encapsulate the actual adding and removing of the UIView like this:
DispatchQueue.main.async {
self.view.addSubview(imageView)
self.view.bringSubviewToFront(imageView)
}
My users report the problem just vanished after that.
EDIT
Be sure to include both adding the UIView and the animation block to the same DispatchQueue segment, otherwise they will compete for the execution slot. My final code looks like this:
func flipPage (direction: String) {
let renderer = UIGraphicsImageRenderer(size: pdfView.bounds.size)
let image = renderer.image { ctx in
pdfView.drawHierarchy(in: self.pdfView.bounds, afterScreenUpdates: true)
let imageView = UIImageView(image: image)
imageView.frame = pdfView.frame
if direction == "forward" && pdfView.canGoToNextPage() {
DispatchQueue.main.async {
self.view.addSubview(imageView)
self.view.bringSubviewToFront(imageView)
self.pdfView.goToNextPage(nil)
UIView.transition(from: imageView,
to: self.pdfView, duration: 0.3,
options: [.transitionCurlUp, .allowUserInteraction],
completion: {finished in
imageView.removeFromSuperview()
})
}
}
P.S. If possible, avoid using drawHierarchy - it is not a very fast method.
In any case, if you need to code differently for specific devices, check out DeviceKit. A wonderful project, that gives you the simplest interface possible.

How to animate drawing in Swift, but also change a UIImageView's scale?

I'd like to animate a drawing sequence. My code draws a spiral into a UIImageView.image. The sequence changes the image contents, but also changes the scale of the surrounding UIImageView. The code is parameterized for the number of revolutions of the spiral:
func drawSpiral(rotations:Double) {
let scale = scaleFactor(rotations) // do some math to figure the best scale
UIGraphicsBeginImageContextWithOptions(mainImageView.bounds.size, false, 0.0)
let context = UIGraphicsGetCurrentContext()!
context.scaleBy(x: scale, y: scale) // some animation prohibits changes!
// ... drawing happens here
myUIImageView.image = UIGraphicsGetImageFromCurrentImageContext()
}
For example, I'd like to animate from drawSpiral(2.0) to drawSpiral(2.75) in 20 increments, over a duration of 1.0 seconds.
Can I setup UIView.annimate(withDuration...) to call my method with successive intermediate values? How? Is there a better animation approach?
Can I setup UIView.annimate(withDuration...) to call my method with successive intermediate values
Animation is merely a succession of timed intermediate values being thrown at something. It is perfectly reasonable to ask that they be thrown at your code so that you can do whatever you like with them. Here's how.
You'll need a special layer:
class MyLayer : CALayer {
#objc var spirality : CGFloat = 0
override class func needsDisplay(forKey key: String) -> Bool {
if key == #keyPath(spirality) {
return true
}
return super.needsDisplay(forKey:key)
}
override func draw(in con: CGContext) {
print(self.spirality) // in real life, this is our signal to draw!
}
}
The layer must actually be in the interface, though it can be impossible for the user to see:
let lay = MyLayer()
lay.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
self.view.layer.addSublayer(lay)
Subsequently, we can initialize the spirality of the layer:
lay.spirality = 2.0
lay.setNeedsDisplay() // prints: 2.0
Now when we want to "animate" the spirality, this is what we do:
let ba = CABasicAnimation(keyPath:#keyPath(MyLayer.spirality))
ba.fromValue = lay.spirality
ba.toValue = 2.75
ba.duration = 1
lay.add(ba, forKey:nil)
CATransaction.setDisableActions(true)
lay.spirality = 2.75
The console shows the arrival of a succession of intermediate values over the course of 1 second!
2.03143266495317
2.04482554644346
2.05783333256841
2.0708108600229
2.08361491002142
2.0966724678874
2.10976020619273
2.12260236591101
2.13551922515035
2.14842618256807
2.16123360767961
2.17421661689878
2.18713565543294
2.200748950243
2.21360073238611
2.2268518730998
2.23987507075071
2.25273013859987
2.26560932397842
2.27846492826939
2.29135236144066
2.30436328798532
2.31764804571867
2.33049770444632
2.34330793470144
2.35606706887484
2.36881992220879
2.38163591921329
2.39440815150738
2.40716737508774
2.42003352940083
2.43287514150143
2.44590276479721
2.45875595510006
2.47169743478298
2.48451870679855
2.49806520342827
2.51120449602604
2.52407149970531
2.53691896796227
2.54965999722481
2.56257836520672
2.57552136480808
2.58910304307938
2.60209316015244
2.6151298135519
2.62802086770535
2.64094598591328
2.6540260463953
2.6669240295887
2.6798157542944
2.69264766573906
2.70616912841797
2.71896715462208
2.73285858333111
2.74564798176289
2.75
2.75
2.75
Those are exactly the numbers that would be thrown at an animatable property, such as when you change a view's frame origin x from 2 to 2.75 in a 1-second duration animation. But now the numbers are coming to you as numbers, and so you can now do anything you like with that series of numbers. If you want to call your method with each new value as it arrives, go right ahead.
Personally, in more complicated animations I would use lottie the animation itself is built with Adobe After Effect and exported as a JSON file which you will manage using the lottie library this approach will save you time and effort when you port your app to another platform like Android as they also have an Android Lottie which means the complicated process of creating the animation is only done once.
Lottie Files has some examples animations as well for you to look.
#Matt provided the answer and gets the checkmark. I'll recap some points for emphasis:
UIView animation is great for commonly animated properties, but if
you need to vary a property not on UIView's animatable list, you can't use it. You must
create a new CALayer and add a CABasicAnimation(keyPath:) to it.
I tried but was unable to get my CABasicAnimations to fire by adding them to the default UIView.layer. I needed to create a custom CALayer
sublayer to the UIView.layer - something like
myView.layer.addSublayer(myLayer)
Leave the custom sublayer installed and re-add the CABasicAnimation to that sublayer when (and only when) you want to animate drawing.
In the custom CALayer object, be sure to override class func needsDisplay(forKey key: String) -> Bool with your key property (as #Matt's example shows), and also override func draw(in cxt: CGContext) to do your drawing. Be sure to decorate your key property with #objc. And reference the key property within the drawing code.
A "gotcha" to avoid: in the UIView object, be sure to null out the usual draw method (override func draw(_ rect: CGRect) { }) to avoid conflict between animated and non-animated drawing on the separate layers. For coordinating animated and non-animated content in the same UIView, it's good (necessary?) to do all your drawing from your custom layer.
When doing that, use myLayer.setNeedsDisplay() to update the non-animated content within the custom layer; use myLayer.add(myBasicAnimation, forKey:nil) to trigger animated drawing within the custom layer.
As I said above, #Matt answered - but these items seemed worth emphasizing.

Performance optimization of waveform drawing

I'm building an app that draw the waveform of input audio data.
Here is a visual representation of how it looks:
It behaves in similar way to Apple's native VoiceMemos app. But it lacks the performance. Waveform itself is a UIScrollView subclass where I draw instances of CALayer to represent purple 'bars' and add them as a sublayers. At the beginning waveform is empty, when sound input starts I update waveform with this function:
class ScrollingWaveformPlot: UIScrollView {
var offset: CGFloat = 0
var normalColor: UIColor?
var waveforms: [CALayer] = []
var lastBarRect: CGRect?
var kBarWidth: Int = 5
func updateGraph(with value: Float) {
//Create instance
self.lastBarRect = CGRect(x: self.offset,
y: self.frame.height / 2,
width: CGFloat(self.barWidth),
height: -(CGFloat)(value * 2))
let barLayer = CALayer()
barLayer.bounds = self.lastBarRect!
barLayer.position = CGPoint(x: self.offset + CGFloat(self.barWidth) / 2,
y: self.frame.height / 2)
barLayer.backgroundColor = self.normalColor?.cgColor
self.layer.addSublayer(barLayer)
self.waveforms.append(barLayer)
self.offset += 7
}
...
}
When last rect of purple bar reaches the middle of screen I begin to increase contentOffset.x of waveform to keep it running like Apple's VoiceMemos app.
The problem is: when bar count reaches ~500...1000 some noticeable lag of waveform begin to happen during setContentOffset.
self.inputAudioPlot.setContentOffset(CGPoint(x: CGFloat(self.offset) - CGFloat(self.view.frame.width / 2 - 7),y: 0), animated: false)
What can be optimized here? Any help appreciated.
Simply remove the bars that get scrolled off-screen from their superlayer. If you want to get fancy you could put them in a queue to reuse when adding new samples, but this might not be worth the effort.
If you don’t let the user scroll inside that view it might even be worth it to not use a scroll view at all and instead move the visible bars to the left when you add a new one.
Of course if you do need to let the user scroll you have some more work to do. Then you first have to store all values you are displaying somewhere so you can get them back. With this you can override layoutSubviews to add all missing bars during scrolling.
This is basically how UITableView and UICollectionView works, so you might be able to implement this using a collection view and a custom layout. Doing it manually though might be easier and also perform a little better as well.
I suspect that this will probably be a larger refactoring than you can afford but SpriteKit would probably give you better performance and control over rendering of the waveform.
You can use SpiteNodes (which are much faster than shape nodes) for the wave bars. You can implement scrolling by merely moving a parent node that contains all these sprites.
Spritekit is quite good at skipping non visible node when rendering and you can even dynamically remove/add node from the scene if the number of nodes becomes a problem.

Resources