I'm a newbie to the iOS development and I'm trying to figure out how CATiledLayer works. NOT how to use it!
There are two key features of CATiledLayer I want to know how it works.
Visible bounds automatic reporting. How does CATiledLayer know its visible bounds have changed? Can I create a custom CALayer and register to know the same information?
Tile drawing. How are these tiles drawn? Are they drawn as sublayers?
The reason I want to find out how these two key points work is that I'm building a custom UIView that can have a very large width and a CALayer will crash due to having such a large backed layer. CATiledLayer has a light memory usage and its' almost perfect!
The reason I don't want to use CATiledLayer is how it works internally. All drawing happens on a background thread. I know you can even have the drawing logic be called on the main thread but this would happen on the next drawing cycle. Because drawing doesn't happen on the same draw cycle, during a view resize our drawing logic has a delay updating the drawings thus causing the UIView content to shake during updates by the user.
Just to add a little more to this. I'm building an audio editor where it shows the audio waveform and the user can resize this clip. The clip is shown inside a collection view. The mentioned issue above with CATiledLayer seems to be the same issue Garage band has. I can barely notice it when resizing an audio clip to the left. They are likely using CATiledLayer.
I know one way to do question number one but I am not sure of the consequences and efficiency. This is more theoretical except it works. I am using a CADisplayLink to run a check to see if the frame of the layer is in the main window. I did notice a small bit of CPU (1% or less) being used so I would test it more compared to the CATiledLayer. CATiledLayer just breaks the drawing up but operates on the same premise that only what is visible can be drawn. drawRect I think fundamentally works when visible or the visible bounds change. As far as subclass I tested I used it inside a UICollectionView and know that it works. I could even get logs of when a cell was created and not on screen. Here is the working subclass of CALayer. I don't know if this helps you but it is possible.
import UIKit
protocol OnScreenLayerDelegate:class {
func layerIsOnScreen(currentScreenSpace:CGRect)
func layerIsNotOnScreen(currentScreenSpace:CGRect)
}
class OnScreenLayer: CALayer {
var displayLink : CADisplayLink?
weak var onScreenDelegate : OnScreenLayerDelegate?
override init() {
super.init()
commonSetup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonSetup()
}
func commonSetup(){
displayLink = CADisplayLink(target: self, selector: #selector(checkOnScreen))
displayLink?.add(to: .main, forMode: .commonModes)
}
#objc func checkOnScreen(){
if let window = UIApplication.shared.keyWindow{
let currentRect = self.convert(self.bounds, to: window.layer)
if window.bounds.intersects(currentRect){
onScreenDelegate?.layerIsOnScreen(currentScreenSpace: currentRect)
}else{
onScreenDelegate?.layerIsNotOnScreen(currentScreenSpace: currentRect)
}
}
}
}
As to question 2 I think CATiledLayers probably does not use sublayers and instead slowly draws all the contents into a single contents image but by tiling and probably easier math for them. It might be something that takes the visible area draws it in the background and provides the layer contents. Then caches that section and adds another until it is complete.This is only a guess.
Here is the code from my test in a collectionView cell.
import UIKit
class AlbumCollectionViewCell: UICollectionViewCell {
#IBOutlet weak var imageView: UIImageView!
var onScreenLayer = OnScreenLayer()
var currentIndex : Int = 0
override func awakeFromNib() {
super.awakeFromNib()
onScreenLayer.frame = self.bounds
self.layer.addSublayer(onScreenLayer)
onScreenLayer.onScreenDelegate = self
}
override func layoutSubviews() {
super.layoutSubviews()
onScreenLayer.frame = self.bounds
}
}
extension AlbumCollectionViewCell : OnScreenLayerDelegate{
func layerIsOnScreen(currentScreenSpace: CGRect) {
//or more proof
print("it is on screen \(currentIndex)")
}
func layerIsNotOnScreen(currentScreenSpace: CGRect) {
print("not on screen \(currentIndex)")
}
}
The current index was set in cellForItem so I could monitor it. Let me know if this helps. Also the check could modify the frame to catch it right before it comes on screen by a margin that way you are drawing prior to that.
Related
I was trying to solve this problem (TL;DR An overlaid SKScene using the overlaySKScene property in SCNView wasn't causing a redraw when children were added and removed from it) using view.setNeedsDisplay() to force a redraw since the SCNView wasn't doing it automatically.
The problem with using view.setNeedsDisplay() was that the CPU usage was spiking to 50% and I assumed it was because the entire SCNView was having to redraw its contents, which included a 3D SCNScene as well. My solution was to use view.setNeedsDisplay(_: CGRect) to minimise the region that needs to be redrawn. However, to my surprise, no matter what I put as the CGRect value the SCNView refused to render the SKScene contents that had been overlaid on it.
Steps to reproduce issue
Open SceneKit template
From the Main (Base) storyboard, set the "Scene" attribute on the SCNView to be "art.scnassets/ship.scn" or whatever the path is
Delete all boilerplate code and just leave
class CustomSKScene: SKScene {
override func didMove(to view: SKView) {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(userTapped(_:)))
view.addGestureRecognizer(tapGestureRecognizer)
}
#objc func userTapped(_ sender: UITapGestureRecognizer) {
let finger = convertPoint(fromView: sender.location(in: view))
let circle = SKShapeNode(circleOfRadius: 25)
circle.position = finger
addChild(circle)
}
}
class GameViewController: UIViewController {
private var gameView: SCNView { view as! SCNView }
override func viewDidLoad() {
gameView.overlaySKScene = CustomSKScene(size: gameView.bounds.size)
}
}
(This should still allow the ship scene to render when you run the app)
When you tap the screen, circles shouldn't show up. Fix this issue by adding view!.setNeedsDisplay() below the addChild function. Notice how CPU usage goes up to around 40-50% if you tap repeatedly after adding this fix.
Replace view!.setNeedsDisplay() with view!.setNeedsDisplay(view!.frame) (which should be equivalent).
At this point we are now back to square one. The circles are not showing up on screen again and confusion ensues. view.setNeedsDisplay() and view.setNeedsDisplay(view.frame) should be equivalent, yet, nothing is redrawn.
Does anyone know how to fix this problem? I feel it only happens when using the overlaySKScene property so maybe there is some caveat with its implementation that I am unaware of.
Some observations:
When you debug the view hierarchy, the overlaid SKScene doesn't show up anywhere, which is strange
sender.view === view returns true
(sender.view as! SCNScene).overlaySKScene === self also returns true
Issue
I trigger random animations on a selection of UIViews using a Timer. All animations work as intended but one where the draw method of a CALayer is called after the exit of the timer selector.
Detailed Description
For the sake of clarity and simplification, let me schematise the actions performed.
All the animations I have created so far work as intended: they are a combination of CABasicAnimations on existing subviews/sublayers or new ones added to the selected view hierarchy. They are coded as an UIView extension, so that they can be called on any views, irrespectively of the view or the view controller the view is in. Well, all work except one.
I have indeed created a custom CALayer class which consists in drawing patterns on a CALayer. In an extension of this custom class, there is a method to animate those patterns (see hereafter the code). So all in all, when I reach the step/method animate selected view and run this particular animation, here is what should happen:
a method named animatePattern is called
this method adds the custom CALayer class to the selected view and then calls the animation extension of this layer
The issue: if with all the other animations, all the drawings are performed prior to the exit of the animate selected view step/method, in that particular case, the custom CALayer class draw method is called after the exit of the performAnimation method, which in turn results in the crash of the animation.
I should add that I have tried the custom CALayer class animation in a separate and simplified playground and it works well (I add the custom layer to a UIView in the UIViewController's viewDidLoad method and then I call the layer animation in the UIViewController's viewDidAppear method.)
The code
the method called by animate selected view step/method:
func animatePattern(for duration: TimeInterval = 1) {
let patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
patternLayer.play(for: duration)
}
(note that this method is in a UIView extension, therefore self here represents the UIView on which the animation has been called)
the simplified custom CALayer Class:
override var bounds: CGRect {
didSet {
self.setNeedsDisplay()
}
// Initializers
override init() {
super.init()
self.setNeedsDisplay()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init(effectType: EffectType){
super.init()
// sets various properties
self.setNeedsDisplay()
}
// Drawing
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
guard self.frame != CGRect.zero else { return }
self.masksToBounds = true
// do the drawings
}
the animation extension of the custom CALayer class:
func play(for duration: Double, removeAfterCompletion: RemoveAfterCompletion = .no) {
guard self.bounds != CGRect.zero else {
print("Cannot animate nil layer")
return }
CATransaction.begin()
CATransaction.setCompletionBlock {
if removeAfterCompletion.removeStatus {
if case RemoveAfterCompletion.yes = removeAfterCompletion { self.fadeOutDuration = 0.0 }
self.fadeToDisappear(duration: &self.fadeOutDuration)
}
}
// perform animations
CATransaction.commit()
}
Attempts so far
I have tried to force draw the layer by inserting setNeedsDisplay / setNeedsLayout at various places in the code but it does not work: debugging the custom CALayer class's draw method is constantly reached after the exit of the performAnimation method, whilst it should be called when the layer's frame is modified in the animatePattern method. I must miss something quite obvious but I am currently running in circles and I'd appreciate a fresh pair of eyes on it.
Thank you in advance for taking the time to consider this issue!
Best,
You override the draw(_ context) as the UIView can be the delegate of CALayer.
UIView: {
var patternLayer : PatternLayer
func animatePattern(for duration: TimeInterval = 1) {
patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
}
func draw(_ layer: CALayer, in ctx: CGContext){
layer.draw(ctx)
if NECESSARY { patternLayer.play(for: duration)}
}
}
As it is frequently the case, when you take the time to write down your issue, new ideas start to pop up. In fact, I remembered that self.setNeedsDisplay() informs the system that the layer needs to be (re)drawn but it does not (re)draw it at once: it will be done during the next refresh. It seems then that the refresh occurs after the end of the cycle for that specific animation. In order to overcome this is issue, I first added a call for the display method right after the patternLayer bounds are set, and it worked, but given #Matt comment, I changed the solution by calling displayIfNeeded method instead. It works as well.
func animatePattern(for duration: TimeInterval = 1) {
let patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
// asks the system to draw the layer now
patternLayer.displayIfNeeded()
patternLayer.play(for: duration)
}
Again, should anyone come up with another more elegant solution/explanation, please do not refrain to share!
Can anyone help in creating a pool of AVPlayer's to be reused in a tableView? So the idea is to have a pool of AVPlayer's, let's say 3, and continue to reuse them as the user scrolls through the table.
Here's a quick synopsis of the app and what we want to build:
Each video is an mp4 and is only 5 seconds long. So each cell will play a 5 second video and loop it.
All videos are local, they will be downloaded to disk before the table is even shown to the user. This will help in terms of smooth scroll performance of the tableView.
Right now I am creating too many AVPlayer's and not reusing them which is having a bad effect on performance such as scrolling is a bit choppy. Also, Apple does not allow an infinite amount of AVPlayer's to be created.
Any ideas? Thanks
Update 1:
import UIKit
import VIMVideoPlayer
var cache = NSCache<NSString, VIMVideoPlayerView>()
class FeedTableViewCell: UITableViewCell {
// MARK: - Properties
#IBOutlet weak var containerView: UIView!
static let reuseIdentifier = "FeedTableViewCell"
var video: Video? {
didSet {
if let cachedVideoPlayerView = cache.object(forKey: video!.preview!.remoteURL as NSString) {
// We have a cached video player view!
containerView.addSubview(cachedVideoPlayerView)
} else {
// There is nothing cached.
let previewURL = FileManager.applicationDocumentsDirectory.appendingPathComponent(video!.preview!.fileName!)
let newVideoPlayer = VIMVideoPlayer()
newVideoPlayer.setURL(previewURL)
newVideoPlayer.isLooping = true
newVideoPlayer.isMuted = true
newVideoPlayer.disableAirplay()
let newVideoPlayerView = VIMVideoPlayerView()
newVideoPlayerView.frame = contentView.bounds
newVideoPlayerView.delegate = self
newVideoPlayerView.setVideoFillMode(AVLayerVideoGravityResizeAspectFill)
newVideoPlayerView.player = newVideoPlayer
containerView.addSubview(newVideoPlayerView)
cache.setObject(newVideoPlayerView, forKey: video!.preview!.remoteURL as NSString)
}
}
}
// MARK: - Life Cycle
override func awakeFromNib() {
super.awakeFromNib()
print("AWAKE FROM NIB CELL")
}
override func prepareForReuse() {
super.prepareForReuse()
}
}
// MARK: - VIMVideoPlayerViewDelegate
extension FeedTableViewCell: VIMVideoPlayerViewDelegate {
func videoPlayerViewIsReady(toPlayVideo videoPlayerView: VIMVideoPlayerView!) {
videoPlayerView.player.play()
}
}
Extending your idea of using a pool, instead of reinventing the wheel, why not leverage UITableViewCell's current implementation of reusability? At any given time, n 'reusable' UITableViewCells exist, acting as your pool.
If each one of these cells contains a single AVPlayer subview, then the management is done for you by the table view. Therefore in the UITableViewCell's reuse identifier, stop the player (if needed), update its MP4 (from an in-memory cache ideally), and start it again.
If needed, you can cache the position of the video when the cell disappears in order to make it seem like the video never stopped playing while scrolling was in progress.
-- As a side note, theoretically this will work, but has not been tested with a live app. The obvious caveat is the size of the video being loaded into the AVPlayer on the main thread, while attempting to maintain 60fps.
Edit:
Please see https://stackoverflow.com/a/35514126/556479 for more info.
TL;DR
Need to keep autorotation, but exclude one UIView from autorotating on orientation change, how?
Back story
I need to keep a UIView stationary during the animation accompanied by autorotation (which happens on orientation change). Similar to how the iOS camera app handles the rotation (i.e controls rotate in their place).
Things I've tried
Returning false from shouldAutorotate(), subscribing to UIDeviceOrientationDidChangeNotification, and trying to manually handle the rotation event for each view separately.
Works well if you don't need to change any of your UIViews' places, otherwise it's a pain figuring out where it should end up and how to get it there
Placing a non rotating UIWindow under the main UIWindow, and setting the main UIWindow background colour to clear.
This works well if it's only one item, but I don't want to manage a bunch of UIWindows
Inverse rotation I.e rotating the UIView in the opposite direction to the rotation. Not reliable, and looks weird, it's also vertigo inducing
Overriding the animation in the viewWillTransitionToSize method. Failed
And a bunch of other things that would be difficult to list here, but they all failed.
Question
Can this be done? if so, how?
I'm supporting iOS8+
Update This is how the views should layout/orient given #Casey's example:
I have faced with same problem and found example from Apple, which helps to prevent UIView from rotation: https://developer.apple.com/library/ios/qa/qa1890/_index.html
However, if UIView is not placed in the center of the screen, you should handle new position manually.
i think part of the reason this is so hard to answer is because in practice it doesn't really make sense.
say i make a view that uses autolayout to look like this in portrait and landscape:
if you wanted to prevent c from rotating like you are asking, what would you expect the final view to look like? would it be one of these 3 options?
without graphics of the portrait/landscape view you are trying to achieve and a description of the animation you are hoping for it'll be very hard to answer your question.
are you using NSLayoutConstraint, storyboard or frame based math to layout your views? any code you can provide would be great too
If you're wanting to have the same effect as the camera app, use size classes (see here and here).
If not, what is wrong with creating a UIWindow containing a view controller that doesn't rotate? The following code seems to work for me (where the UILabel represents the view you don't want to rotate).
class ViewController: UIViewController {
var staticWindow: UIWindow!
override func viewDidLoad() {
super.viewDidLoad()
showWindow()
}
func showWindow() {
let frame = CGRect(x: 10, y: 10, width: 100, height: 100)
let vc = MyViewController()
let label = UILabel(frame: frame)
label.text = "Hi there"
vc.view.addSubview(label)
staticWindow = UIWindow(frame: frame)
staticWindow.rootViewController = MyViewController()
staticWindow.windowLevel = UIWindowLevelAlert + 1;
staticWindow.makeKeyAndVisible()
staticWindow.rootViewController?.presentViewController(vc, animated: false, completion: nil)
}
}
class MyViewController: UIViewController {
override func shouldAutorotate() -> Bool {
return false
}
override func shouldAutomaticallyForwardRotationMethods() -> Bool {
return false
}
override func shouldAutomaticallyForwardAppearanceMethods() -> Bool {
return false
}
override func supportedInterfaceOrientations() -> UIInterfaceOrientationMask {
return UIInterfaceOrientationMask.Portrait
}
}
How to redraw non-visible UICollectionViewCell's ready for when reuse occurs???
One approach I thought of was per the code in the Layout Cell prepareForReuse function, however whilst it works it non-optimal as it causes more re-drawing then required.
Background: Need to trigger drawRect for cells after an orientation change that are not current visible, but pop up to be used and haven't been redraw, so so far I can only see that prepareForReuse would be appropriate. Issue is I'm re-drawing all "reuse" cells, whereas I really only want to redraw those that initially pop up that were created during the previous orientation position of the device.
ADDITIONAL INFO: So currently I'm doing this:
In ViewController:
override func viewWillLayoutSubviews() {
// Clear cached layout attributes (to ensure new positions are calculated)
(self.cal.collectionViewLayout as! GCCalendarLayout).resetCache()
self.cal.collectionViewLayout.invalidateLayout()
// Trigger cells to redraw themselves (to get new widths etc)
for cell in self.cal?.visibleCells() as! [GCCalendarCell] {
cell.setNeedsDisplay()
}
// Not sure how to "setNeedsDisplay" on non visible cells here?
}
In Layout Cell class:
override func prepareForReuse() {
super.prepareForReuse()
// Ensure "drawRect" is called (only way I could see to handle change in orientation
self.setNeedsDisplay()
// ISSUE: It does this also for subsequent "prepareForReuse" after all
// non-visible cells have been re-used and re-drawn, so really
// not optimal
}
Example of what happens without the code in prepareForReuse above. Snapshot taken after an orientation change, and just after scrolling up a little bit:
I think I have it now here:
import UIKit
#IBDesignable class GCCalendarCell: UICollectionViewCell {
var prevBounds : CGRect?
override func layoutSubviews() {
if let prevBounds = prevBounds {
if !( (prevBounds.width == bounds.width) && (prevBounds.height == bounds.height) ) {
self.setNeedsDisplay()
}
}
}
override func drawRect(rect: CGRect) {
// Do Stuff
self.prevBounds = self.bounds
}
}
Noted this check didn't work in "prepareForReuse" as at this time the cell had not had the rotation applied. Seems to work in "layoutSubviews" however.
You can implement some kind of communication between the cells and the view controller holding the collection view ( protocol and delegate or passed block or even direct reference to the VC ). Then You can ask the view controller for rotation changes.
Its a bit messy, but if You have some kind of rotation tracking in Your view controller You can filter the setNeedsDisplay with a simple if statement.
I had similar challenged updating cells that were already displayed and off the screen. While cycling through ALLL cells may not be possible - refreshing / looping through non-visible ones is.
IF this is your use case - then read on. Pre - Warning - if you're adding this sort of code - explain why you're doing it. It's kind of anti pattern - but can help fix that bug and help ship your app albeit adding needless complexity. Don't use this in multiple spots in app.
Any collectionviewcell that's de-initialized (off the screen and being recylced) should be unsubscribed automatically.
Notification Pattern
let kUpdateButtonBarCell = NSNotification.Name("kUpdateButtonBarCell")
class Notificator {
static func fireNotification(notificationName: NSNotification.Name) {
NotificationCenter.default.post(name: notificationName, object: nil)
}
}
extension UICollectionViewCell{
func listenForBackgroundChanges(){
NotificationCenter.default.removeObserver(self, name: kUpdateButtonBarCell, object: nil)
NotificationCenter.default.addObserver(forName:kUpdateButtonBarCell, object: nil, queue: OperationQueue.main, using: { (note) in
print( " contentView: ",self.contentView)
})
}
}
override func collectionView(collectionView: UICollectionView!, cellForItemAtIndexPath indexPath: NSIndexPath!) -> UICollectionViewCell! {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("die", forIndexPath: indexPath) as UICollectionViewCell
cell.listenForBackgroundChanges()
return cell
}
// Where appropriate broadcast notification to hook into all cells past and present
Notificator.fireNotification(notificationName: kUpdateButtonBarCell)
Delegate Pattern
It's possible to simplify this.... an exercise for the reader. just do not retain the cells (use a weak link) - otherwise you'll have memory leaks.