Why restrain a subview from animation? Why not simply carry out its layout changes after the animation?
That's logical but not feasible considering my view hierarchy and this particular use-case.
My view hierarchy:
MyViewController: UIViewController -> MyCustomView: UIView -> MyCustomScrollView: UIScrollView, UIScrollViewDelegate, UICollectionViewDelegate, UICollectionViewDataSource
Why is it not possible?:
1) I do this in MyViewController after various constraint changes:
UIView.animate(withDuration: 0.3, animations: {
self.view.layoutIfNeeded()
})
2) Since MyCustomView is a subview which contains MyCustomScrollView (which in turn contains a UICollectionView as its subview), the layout update triggers CV's willDisplay delegate method under which I'm adding a bunch of labels to MyCustomView, to be precise.
Here's the function in MyCustomView that I'm calling:
func addLabel(forIndexPath indexPath: IndexPath) {
var label: UILabel!
label.frame = Util.labelFrame(forIndex: indexPath, fillWidth: false) // The frame for the label is generated here!
//Will assign text and font to the label which are unnecessary to this context
self.anotherSubView.addSubview(label) //Add the label to MyCustomView's subview
}
3) Since these changes get caught up within the animation block from point 1, I get some unnecessary, undesired animations happening. And so, MyCustomView's layout change is bound with this animation block, forcing me to look for a way to restrain this from happening
Things tried so far:
1) Tried the wrap the addSubView() from addLabel(forIndexPath:) inside a UIView.performWithoutAnimation {} block. - No luck
2) Tried the wrap the addSubView() from addLabel(forIndexPath:) inside another animation block with 0.0 seconds time as to see if this overrides the parent animation block - No luck
3) Explored UIView.setAnimationsEnabled(enabled:) but it seems like this won't cancel/pause the existing animators, and will completely disable all the animations if true (which is not what I want)
To sum this all up, my problem is:
I need to restrain the animations on MyCustomView but I need all my other desired layout changes to take place. Is this even possible? Would really appreciate a hint or a solution, TYIA!
Thanks to this answer, removing all the animations from anotherSubview's layer(inside the addLabel(forIndexPath:)) after adding the label:
self.anotherSubview.addSubview(label)
self.anotherSubview.layer.removeAllAnimations() //Doing this removes the animations queued to animate the label's frame into the view
does exactly what I want!
Related
To help in following this question, I've put up a GitHub repository:
https://github.com/mattneub/SelfSizingCells/tree/master
The goal is to get self-sizing cells in a table view, based on a custom view that draws its own text rather than a UILabel. I can do it, but it involves a weird layout kludge and I don't understand why it is needed. Something seems to be wrong with the timing, but then I don't understand why the same problem doesn't occur for a UILabel.
To demonstrate, I've divided the example into three scenes.
Scene 1: UILabel
In the first scene, each cell contains a UILabel pinned to all four sides of the content view. We ask for self-sizing cells and we get them. Looks great.
Scene 2: StringDrawer
In the second scene, the UILabel has been replaced by a custom view called StringDrawer that draws its own text. It is pinned to all four sides of the content view, just like the label was. We ask for self-sizing cells, but how will we get them?
To solve the problem, I've given StringDrawer an intrinsicContentSize based on the string it is displaying. Basically, we measure the string and return the resulting size. In particular, the height will be the minimal height that this view needs to have in order to display the string in full at this view's current width, and the cell is to be sized to that.
class StringDrawer: UIView {
#NSCopying var attributedText = NSAttributedString() {
didSet {
self.setNeedsDisplay()
self.invalidateIntrinsicContentSize()
}
}
override func draw(_ rect: CGRect) {
self.attributedText.draw(with: rect, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin], context: nil)
}
override var intrinsicContentSize: CGSize {
let measuredSize = self.attributedText.boundingRect(
with: CGSize(width:self.bounds.width, height:10000),
options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin],
context: nil).size
return CGSize(width: UIView.noIntrinsicMetric, height: measuredSize.height.rounded(.up) + 5)
}
}
But something's wrong. In this scene, some of the initial cells have some extra white space at the bottom. Moreover, if you scroll those cells out of view and then back into view, they look correct. And all the other cells look fine. That proves that what I'm doing is correct, so why isn't it working for the initial cells?
Well, I've done some heavy logging, and I've discovered that at the time intrinsicContentSize is called initially for the visible cells, the StringDrawer does not yet correctly know its own final width, the width that it will have after autolayout. We are being called too soon. The width we are using is too narrow, so the height we are returning is too tall.
Scene 3: StringDrawer with workaround
In the third scene, I've added a workaround for the problem we discovered in the second scene. It works great! But it's horribly kludgy. Basically, in the view controller, I wait until the view hierarchy has been assembled, and then I force the table view to do another round of layout by calling beginUpdates and endUpdates.
var didInitialLayout = false
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if !didInitialLayout {
didInitialLayout = true
UIView.performWithoutAnimation {
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
}
}
The Mystery
Okay, so here are my questions:
(1) Is there a better, less kludgy workaround?
(2) Why do we need this workaround at all? In particular, why do we have this problem with my StringDrawer but not with a UILabel? Clearly, a UIlabel does know its own width early enough for it to give its own content size correctly on the first pass when it is interrogated by the layout system. Why is my StringDrawer different from that? Why does it need this extra layout pass?
I recently faced an issue where all layouts animated. I can't find out what causes this. Any idea about how can I detect the issue source would be welcome.
Controller hierarchy:
UITabBarController
UINavigationControler
UIViewController
UIPageViewController
UICollectionView
GoogleMap
Visible animated stuff: (without being inside animation block)
layout:
labels and views moving across the cell to the destination position (self-sizing cell)
horizontal collection view items comes from corner to their position
layer:
corner radius from 0 to height/2 animated.
view:
isHidden not works sometimes. It is inside UIStackView and when is hide it, it's just push it out from the stack view (visible because of the bug, strange but it's true)
setting title on buttons animated (setTitle:forState: method. And not the flashing animation, some kind of morphing animation)
Where are the layout and styling codes?
first after viewDidLoad and inside datasource didSet observer
What about threads?
I double checked all UI works dispatched in Main queue and Main Thread Checker is on.
- Show me The code !!!
Unfortunately this is an epic production project and changes that caused this issue is UNKNOWN. I wasn't able to reproduce the issue in some demo app to post it. Sorry
I found the issue after about 3 days of investigating and the answer in just a couple of minutes:
In some point of the code there was a animation block:
UIView.animate(withDuration: 1, animations: {
self.view.layoutIfNeeded()
})
I don't know why but this caused all layout animated for the rest of the controller's life (even after animation is done)
So for get around this I tried this:
UIView.animate(withDuration: 1, animations: {
DispatchQueue.main.async {
self.view.layoutIfNeeded()
}
})
Problem solved BUT!!! new issue appeared:
The original animation is not working at all!
So for the final workaround I changed the code a bit:
DispatchQueue.main.async {
UIView.animate(withDuration: 1, animations: {
self.view.layoutIfNeeded()
})
}
And fortunately it works! I knew:
all UI works should be done on main thread
and also animation blocks doesn't capture self, and perform their job on current thread. but strangely done.
class ApplyCorners: UIButton {
override func didMoveToWindow() {
self.layer.cornerRadius = self.frame.height / 2
}
}
I apply this class to the buttons in my application and it is working great, but when I apply it to a button used in every cell in a table view the button corners are not round upon entering the view, but if I click one of the buttons I get segued to another view. If I then segue back the corners are "fixed" / round.
The green is the button when returning and the red is upon first entering the view.
Anyone know how to fix this?
I'd suggest layoutSubviews, which captures whenever the frame of the button changes:
class ApplyCorners: UIButton {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = frame.height / 2
}
}
This takes care of both the original appearance and any subsequent appearance. It also avoids all sorts of problems related to not only whether the frame was known when the view appeared, but also if you do anything that might change the size of the button (e.g. anything related to constraints, rotation events, etc.).
This sort of thing is likely to be a timing problem. Consider the phrase self.frame.height. At the time didMoveToWindow is called, we may not yet know our frame. If you are going to call a method that depends upon layout, do so when layout has actually occurred.
Gonna propose another alternative: listen to any bounds changes. This avoids the problem of wondering "is my frame set yet when this is called?"
class ApplyCorners: UIButton {
override var bounds: CGRect {
didSet {
layer.cornerRadius = bounds.height / 2
}
}
}
Edited frame to bounds because as #Rob points out, listening for frame changes will cause you to miss the initial load sometimes.
Putting your code in didMoveToWindow() does not make sense to me. I'd suggest implementing layoutSubviews() instead. That method gets called any time a view object's layout changes, so it should update if you resize your view.
(Changed my suggestion based on comments from TNguyen and and Rob.)
Preface: I took a peek at this post and it didn't solve my problem. Here's a link to a repo that shows the error I'm encountering.
I have a UIViewController with a vertical UIStackView containing nested horizontal UIStackViews.
There are two UIStackViews that are toggled. If UIStackViewNumber1 is displayed, UIStackViewNumber2 is hidden & vice versa. I originally set the view to display both, but it's too crowded.
UIStackView
stackViewThatDoesntMove
stackViewNumber1
stackViewNumber2
stackViewThatDoesntMove
Everything works fine with no AutoLayout errors if I don't hide anything. Each of the UIStackViews has nested stackViews containing buttons, labels, sliders, etc. I found the view was too crowded, so I thought I'd set hide one of stackViews and animate the change, as follows:
stackViewNumber2.isHidden = true
UIView.animate(withDuration: 0.33,
options: [.curveEaseOut],
animations: {
self.stackViewNumber1.layoutIfNeeded()
self.stackViewNumber2.layoutIfNeeded()
self.view.layoutIfNeeded()
},
completion: nil)
}
While I got the desired result in Simulator and on a physical device, the moment I hide one of the UIStackViews, my console fills up with AutoLayout errors.
I took a look at this post and my problem seemed pretty straightforward. I went with the approach with the least "bookkeeping" and wrapped stackViewNumber2 in a UIView. I end up with the same set of errors I originally had without wrapping stackViewNumber1 in a UIView.
I also read Apple's documentation on UIStackView and tried playing around with the arrangedSubviews() to set constraints isActive = false, as follows:
for subview in stackViewNumber2.arrangedSubviews {
for constraint in subview.constraints {
constraint.isActive = false
}
}
stackViewNumber2.updateConstraints()
// then run animation block to update view
Is there a more efficient way to hide nested UIStackViews without adding an IBOutlet for constraint, making sure it's not weak, and doing bookkeeping on whether isActive is true/false?
I think my problem has to do with nested stackViews, but I'm unable to figure out how to address it. Thank you for reading and I welcome suggestions on how to hide a UIStackView containing nested UIStackViews.
Instead of hiding I think it would be better to remove the stackView itself.
myStackView.removeArrangedSubview(playButton)
playButton.removeFromSuperview()
Hope it helps.
I started out wanting to animate hiding one view and unhiding another, but at Md. Ibrahim Hassan's suggestion, I tried removing the stackViewNumber1 from the superview and replacing it with stackViewNumber2. The end result is much cleaner with minimal bookkeeping, no dragging a bazillion #IBOutlets onto the viewController for updating constraints, etc.
The views of parentStackView are stored in the stack's arrangedSubviews() array in the order they're displayed in Main.storyboard.
In this example, the arrangedSubviews array looks like this:
[topStackView, stackViewNumber1, stackViewNumber2, stopButton]
I only need to know about parentStackView, stackViewNumber1, and stackViewNumber2, so I dragged 3 outlets to the storyboard, as follows:
// StackViews the viewController needs to know about...
#IBOutlet weak var parentStackView: UIStackView!
// note weak is removed from the following 2 stackViews
#IBOutlet var stackViewNumber1: UIStackView!
#IBOutlet var stackViewNumber2: UIStackView!
From there, if I want to remove stackViewNumber2, the following code removes it:
parentStackView.removeArrangedSubview(stackViewNumber2)
// parentStackView.arrangedSubviews() = [topStackView, stackViewNumber1, stopButton]
stackViewNumber2.removeFromSuperview()
parentStackView.insertArrangedSubview(stackViewNumber1, at: 1)
// parentStackView.arrangedSubviews() = [topStackView, stackViewNumber1, stopButton]
Now, let's say I want to take out stackViewNumber1 and replace it with stackViewNumber2:
parentStackView.removeArrangedSubview(stackViewNumber1)
// parentStackView.arrangedSubviews() = [topStackView, stopButton]
stackViewNumber2.removeFromSuperview()
parentStackView.insertArrangedSubview(stackViewNumber2, at: 1)
// parentStackView.arrangedSubviews() = [topStackView, stackViewNumber2, stopButton]
Recall stackViewNumber1 and stackViewNumber2 are not weak. The reason is I want to keep them around so I can swap them in and out. I didn't see any adverse impact on memory usage as a result of changing the 2 UIStackView outlets to weak.
End result: The slew of AutoLayout errors I encountered with my initial approach is gone without a ton of bookkeeping.
Update:
With a little more tinkering, I got animation to work, too. Here's the code:
UIView.animate(withDuration: 0.25,
delay: 0,
usingSpringWithDamping: 2.0,
initialSpringVelocity: 10.0,
options: [.curveEaseOut],
animations: {
self.view.layoutIfNeeded()
},
completion: nil)
I'm using the following code to have a label slide onto the screen when a button is pressed, but it's having no effect.
override func viewDidLayoutSubviews() {
summaryLabel.alpha = 0
}
#IBAction func searchButton(sender: AnyObject) {
UIView.animateWithDuration(2) { () -> Void in
self.summaryLabel.center = CGPointMake(self.summaryLabel.center.x - 400, self.summaryLabel.center.y)
self.summaryLabel.alpha = 1
self.summaryLabel.center = CGPointMake(self.summaryLabel.center.x + 400, self.summaryLabel.center.y)
}
//button code continues...
I've tested what's going on by fixing the alpha at 1, but the label just stays where it is and does not move when the button is pressed. What am I missing?
A couple of things:
First of all, your two changes to the view's center cancel each other out. The animation applies the full set of changes that are inside the animation block all in one animation. If the end result is no change, then no change is applied. As Ramy says in his comment, you either need 2 animations, timed so the second one takes place after the first one has completed, or you nee to apply the first change before the animation begins. I would suggest starting with a single change, and a single animation, and get that working first.
Second problem: View controllers use auto layout by default. With auto layout, you can't animate the position of a view directly. It doesn't work reliably. Instead, you have to put a constraint on the view, connect it to an outlet, and the animate a change to the constraint's constant value by changing the constant and calling layoutIfNeeded() inside the animation block. The call to layoutIfNeeded() inside the animation block causes the view's position to be changed, and since it's inside the animation block, the change is applied with animation.