Is there a way to either specify the duration for UITableView row animations, or to get a callback when the animation completes?
What I would like to do is flash the scroll indicators after the animation completes. Doing the flash before then doesn't do anything. So far the workaround I have is to delay half a second (that seems to be the default animation duration), i.e.:
[self.tableView insertRowsAtIndexPaths:newRows
withRowAnimation:UITableViewRowAnimationFade];
[self.tableView performSelector:#selector(flashScrollIndicators)
withObject:nil
afterDelay:0.5];
Just came across this. Here's how to do it:
Objective-C
[CATransaction begin];
[tableView beginUpdates];
[CATransaction setCompletionBlock: ^{
// Code to be executed upon completion
}];
[tableView insertRowsAtIndexPaths: indexPaths
withRowAnimation: UITableViewRowAnimationAutomatic];
[tableView endUpdates];
[CATransaction commit];
Swift
CATransaction.begin()
tableView.beginUpdates()
CATransaction.setCompletionBlock {
// Code to be executed upon completion
}
tableView.insertRowsAtIndexPaths(indexArray, withRowAnimation: .Top)
tableView.endUpdates()
CATransaction.commit()
Expanding on karwag's fine answer, note that on iOS 7, surrounding the CATransaction with a UIView Animation offers control of the table animation duration.
[UIView beginAnimations:#"myAnimationId" context:nil];
[UIView setAnimationDuration:10.0]; // Set duration here
[CATransaction begin];
[CATransaction setCompletionBlock:^{
NSLog(#"Complete!");
}];
[myTable beginUpdates];
// my table changes
[myTable endUpdates];
[CATransaction commit];
[UIView commitAnimations];
The UIView animation's duration has no effect on iOS 6. Perhaps iOS 7 table animations are implemented differently, at the UIView level.
That's one hell of a useful trick!
I wrote a UITableView extension to avoid writing CATransaction stuff all the time.
import UIKit
extension UITableView {
/// Perform a series of method calls that insert, delete, or select rows and sections of the table view.
/// This is equivalent to a beginUpdates() / endUpdates() sequence,
/// with a completion closure when the animation is finished.
/// Parameter update: the update operation to perform on the tableView.
/// Parameter completion: the completion closure to be executed when the animation is completed.
func performUpdate(_ update: ()->Void, completion: (()->Void)?) {
CATransaction.begin()
CATransaction.setCompletionBlock(completion)
// Table View update on row / section
beginUpdates()
update()
endUpdates()
CATransaction.commit()
}
}
This is used like so:
// Insert in the tableView the section we just added in sections
self.tableView.performUpdate({
self.tableView.insertSections([newSectionIndex], with: UITableViewRowAnimation.top)
}, completion: {
// Scroll to next section
let nextSectionIndexPath = IndexPath(row: 0, section: newSectionIndex)
self.tableView.scrollToRow(at: nextSectionIndexPath, at: .top, animated: true)
})
Shortening Brent's fine answer, for at least iOS 7 you can wrap this all tersely in a [UIView animateWithDuration:delay:options:animations:completion:] call:
[UIView animateWithDuration:10 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
[self.tableView beginUpdates];
[self.tableView endUpdates];
} completion:^(BOOL finished) {
// completion code
}];
though, I can't seem to override the default animation curve from anything other than EaseInOut.
Here's a Swift version of karwag's answer
CATransaction.begin()
tableView.beginUpdates()
CATransaction.setCompletionBlock { () -> Void in
// your code here
}
tableView.insertRowsAtIndexPaths(indexArray, withRowAnimation: .Top)
tableView.endUpdates()
CATransaction.commit()
For me I needed this for a collectionView. I've made a simple extension to solve this:
extension UICollectionView {
func reloadSections(sections: NSIndexSet, completion: () -> Void){
CATransaction.begin()
CATransaction.setCompletionBlock(completion)
self.reloadSections(sections)
CATransaction.commit()
}
}
Nowadays if you want to do this there is new function starting from iOS 11:
- (void)performBatchUpdates:(void (^)(void))updates
completion:(void (^)(BOOL finished))completion;
In updates closures you place the same code as in beginUpdates()/endUpdates section. And the completion is executed after all animations.
As tableView's performBatch method is available starting from iOS 11 only, you can use following extension:
extension UITableView {
func performUpdates(_ updates: #escaping () -> Void, completion: #escaping (Bool) -> Void) {
if #available(iOS 11.0, *) {
self.performBatchUpdates({
updates()
}, completion: completion)
} else {
CATransaction.begin()
beginUpdates()
CATransaction.setCompletionBlock {
completion(true)
}
updates()
endUpdates()
CATransaction.commit()
}
}
}
Antoine's answer is pretty good – but is for UICollectionView. Here it is for UITableView:
extension UITableView {
func reloadSections(_ sections: IndexSet, with rowAnimation: RowAnimation, completion: (() -> Void)?) {
CATransaction.begin()
CATransaction.setCompletionBlock(completion)
self.reloadSections(sections, with: rowAnimation)
CATransaction.commit()
}
}
Called like so:
tableView.reloadSections(IndexSet(0), with: .none, completion: {
// Do the end of animation thing
})
If someone is facing the problem when tableView is ignoring animation parameters from UIView.animate and using "from up to down" default animation for reloading rows, I've found a strange solution:
You need to:
Silence tableView animation
Use transitionAnimation instead
Example:
let indicesToUpdate = [IndexPath(row: 1, section: 0)]
UIView.transition(with: self.tableView,
duration: 0.5,
options: [.transitionCrossDissolve,
.allowUserInteraction,
.beginFromCurrentState],
animations: {
UIView.performWithoutAnimation {
self.tableView.reloadRows(at: indicesToUpdate,
with: .none)
}
})
PS: UIView.transition(..) also has optional completion :)
You could try to wrap the insertRowsAtIndexPath in a
- (void)beginUpdates
- (void)endUpdates
transaction, then do the flash afterwards.
Related
I have UITableView that update rapidly via WebSocket and RXSwift. Every update will play flash animation. Everything works well in iOS11 - iOS14 but after the iOS15 update, the animation has weird behavior. It isn't play properly. It skip most of animation updates. Sometime it play animation in all rows at the same time.
Edited: I've got another issue; when I press on the button in the cell, the action didn't fire. It take a lot of click on it to make it fired, looks like it can't touch the button while updating. Sometime I press on button in cell 1 but the action fired as cell 2 context.
(Cell information was hidden for secret)
From the video, on the left was run on iOS11-iOS14. The animation works smoothly while on the right the animation was skipped.
The code to update the animation is:
func flash()->Observable<Void>{
return Observable.create { (observer) -> Disposable in
UIView.animateKeyframes(withDuration: 0.5, delay: 0, options: []){
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
self.background.alpha = 0
}
UIView.addKeyframe(withRelativeStartTime: 0.25, relativeDuration: 0.5) {
self.background.alpha = 0.2
}
UIView.addKeyframe(withRelativeStartTime: 1, relativeDuration: 0.5) {
self.background.alpha = 0
}
}
return Disposables.create()
}
}
Call it like this. I didn't dispose someBehaviorRelay because it removes the animation
someBehaviorRelay.subscribe { [unowned self] value in
flash().subscribe().disposed(by: disposeBag)
}
And I reassign disposeBage when reuse
override func prepareForReuse() {
disposeBag = DisposeBag()
}
Is there any suggestion for me to solve this problem? Thank you.
UPDATE
I found the solution is use reconfigure instead of reloadData for UITableView
// if iOS15
let indexPaths = tableView.indexPathsForVisibleRows!
if !indexPaths.isEmpty {
tableView.reconfigureRows(at: indexPaths)
} else {
tableView.reloadData()
}
I found the solution already, updated in the question.
I currently have the problem that the completion of the animation function ends before the animation itself does.
The array progressBar[] includes multiple UIProgressViews. When one is finished animating I want the next one to start animating and so on. But right now they all start at once.
How can I fix this?
#objc func updateProgress() {
if self.index < self.images.count {
progressBar[index].setProgress(0.01, animated: false)
group.enter()
DispatchQueue.main.async {
UIView.animate(withDuration: 5, delay: 0.0, options: .curveLinear, animations: {
self.progressBar[self.index].setProgress(1.0, animated: true)
}, completion: { (finished: Bool) in
if finished == true {
self.group.leave()
}
})
}
group.notify(queue: .main) {
self.index += 1
self.updateProgress()
}
}
}
The problem is that UIView.animate() can only be used on animatable properties, and progress is not an animatable property. "Animatable" here means "externally animatable by Core Animation." UIProgressView does its own internal animations, and that conflicts with external animations. This is UIProgressView being a bit over-smart, but we can work around it.
UIProgressView does use Core Animation, and so will fire CATransaction completion blocks. It does not, however, honor the duration of the current CATransaction, which I find confusing since it does honor the duration of the current UIView animation. I'm not actually certain how both of these are true (I would think that the UIView animation duration would be implemented on the transaction), but it seems to be the case.
Given that, the way to do what you're trying looks like this:
func updateProgress() {
if self.index < self.images.count {
progressBar[index].setProgress(0.01, animated: false)
CATransaction.begin()
CATransaction.setCompletionBlock {
self.index += 1
self.updateProgress()
}
UIView.animate(withDuration: 5, delay: 0, options: .curveLinear,
animations: {
self.progressBar[self.index].setProgress(1.0, animated: true)
})
CATransaction.commit()
}
}
I'm creating a nested transaction here (with begin/commit) just in case there is some other completion block created during this transaction. That's pretty unlikely, and the code "works" without calling begin/commit, but this way is a little safer than messing with the default transaction.
This is a follow-up to How to get notified when a tableViewController finishes animating the push onto a nav stack.
In a tableView I want to deselect a row with animation, but only after the tableView has finished animating the scroll to the selected row. How can I be notified when that happens, or what method gets called the moment that finishes.
This is the order of things:
Push view controller
In viewWillAppear I select a certain row.
In viewDidAppear I scrollToRowAtIndexPath (to the selected row).
Then when that finishes scrolling I want to deselectRowAtIndexPath: animated:YES
This way, the user will know why they were scrolled there, but then I can fade away the selection.
Step 4 is the part I haven't figured out yet. If I call it in viewDidAppear then by the time the tableView scrolls there, the row has been deselected already which is no good.
You can use the table view delegate's scrollViewDidEndScrollingAnimation: method. This is because a UITableView is a subclass of UIScrollView and UITableViewDelegate conforms to UIScrollViewDelegate. In other words, a table view is a scroll view, and a table view delegate is also a scroll view delegate.
So, create a scrollViewDidEndScrollingAnimation: method in your table view delegate and deselect the cell in that method. See the reference documentation for UIScrollViewDelegate for information on the scrollViewDidEndScrollingAnimation: method.
try this
[UIView animateWithDuration:0.3 animations:^{
[yourTableView scrollToRowAtIndexPath:indexPath
atScrollPosition:UITableViewScrollPositionTop
animated:NO];
} completion:^(BOOL finished){
//do something
}];
Don't forget to set animated to NO, the animation of scrollToRow will be overridden by UIView animateWithDuration.
Hope this help !
To address Ben Packard's comment on the accepted answer, you can do this. Test if the tableView can scroll to the new position. If not, execute your method immediately. If it can scroll, wait until the scrolling is finished to execute your method.
- (void)someMethod
{
CGFloat originalOffset = self.tableView.contentOffset.y;
[self.tableView scrollToRowAtIndexPath:path atScrollPosition:UITableViewScrollPositionMiddle animated:NO];
CGFloat offset = self.tableView.contentOffset.y;
if (originalOffset == offset)
{
// scroll animation not required because it's already scrolled exactly there
[self doThingAfterAnimation];
}
else
{
// We know it will scroll to a new position
// Return to originalOffset. animated:NO is important
[self.tableView setContentOffset:CGPointMake(0, originalOffset) animated:NO];
// Do the scroll with animation so `scrollViewDidEndScrollingAnimation:` will execute
[self.tableView scrollToRowAtIndexPath:path atScrollPosition:UITableViewScrollPositionMiddle animated:YES];
}
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView
{
[self doThingAfterAnimation];
}
You can include the scrollToRowAtIndexPath: inside a [UIView animateWithDuration:...] block which will trigger the completion block after all included animations conclude. So, something like this:
[UIView
animateWithDuration:0.3f
delay:0.0f
options:UIViewAnimationOptionAllowUserInteraction
animations:^
{
// Scroll to row with animation
[self.tableView scrollToRowAtIndexPath:indexPath
atScrollPosition:UITableViewScrollPositionTop
animated:YES];
}
completion:^(BOOL finished)
{
// Deselect row
[self.tableView deselectRowAtIndexPath:indexPath animated:YES];
}];
Swift 5
The scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) delegate method is indeed the best way to execute a completion on a scroll-to-row animation but there are two things worth noting:
First, the documentation incorrectly says that this method is only called in response to setContentOffset and scrollRectToVisible; it's also called in response to scrollToRow (https://developer.apple.com/documentation/uikit/uiscrollviewdelegate/1619379-scrollviewdidendscrollinganimati).
Second, despite the fact that the method is called on the main thread, if you're running a subsequent animation here (one after the scroll has finished), it will still hitch (this may or may not be a bug in UIKit). Therefore, simply dispatch any follow-up animations back onto the main queue which just ensures that the animations will begin after the end of the current main task (which appears to include the scroll-to-row animation). Doing this will give you the appearance of a true completion.
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
DispatchQueue.main.async {
// execute subsequent animation here
}
}
Implementing this in a Swift extension.
//strong ref required
private var lastDelegate : UITableViewScrollCompletionDelegate! = nil
private class UITableViewScrollCompletionDelegate : NSObject, UITableViewDelegate {
let completion: () -> ()
let oldDelegate : UITableViewDelegate?
let targetOffset: CGPoint
#objc private func scrollViewDidEndScrollingAnimation(scrollView: UIScrollView) {
scrollView.delegate = oldDelegate
completion()
lastDelegate = nil
}
init(completion: () -> (), oldDelegate: UITableViewDelegate?, targetOffset: CGPoint) {
self.completion = completion
self.oldDelegate = oldDelegate
self.targetOffset = targetOffset
super.init()
lastDelegate = self
}
}
extension UITableView {
func scrollToRowAtIndexPath(indexPath: NSIndexPath, atScrollPosition scrollPosition: UITableViewScrollPosition, animated: Bool, completion: () -> ()) {
assert(lastDelegate == nil, "You're already scrolling. Wait for the last completion before doing another one.")
let originalOffset = self.contentOffset
self.scrollToRowAtIndexPath(indexPath, atScrollPosition: scrollPosition, animated: false)
if originalOffset.y == self.contentOffset.y { //already at the right position
completion()
return
}
else {
let targetOffset = self.contentOffset
self.setContentOffset(originalOffset, animated: false)
self.delegate = UITableViewScrollCompletionDelegate(completion: completion, oldDelegate: self.delegate, targetOffset:targetOffset)
self.scrollToRowAtIndexPath(indexPath, atScrollPosition: scrollPosition, animated: true)
}
}
}
This works for most cases although the TableView delegate is changed during the scroll, which may be undesired in some cases.
I'm trying to insert a row after another row deletion animation is completed. I've been trying doing the following:
tableView.beginUpdates()
CATransaction.begin()
CATransaction.setCompletionBlock {
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Right)
}
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Left)
CATransaction.commit()
tableView.endUpdates
This gave me the usual assertion failure when the count of rows is not the same as it's been expecting.
Then I've tried using an UIView animation with a completion block:
tableView.beginUpdates()
func animations() {
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Left)
}
func completion() {
if count == self.payments.count && self.payments.isEmpty { insert() }
}
UIView.animateWithDuration(0.5, animations: { animations() }) { _ in completion() }
tableView.endUpdates()
Both attempts is giving me the same error. Is it possible or should I look into custom animation for inserting / deleting tableview rows?
Edit:
I managed to make it work by moving tableView.endUpdates() to the completion block. But the insertion animation still animates at the same time when the row is being deleted.
Is there another way of doing this?
if you know how much time does your animation takes to complete just add this function to wait for a given amount of time before executing some code:
func delay(delay:Double, closure:()->()) {
dispatch_after(
dispatch_time(
DISPATCH_TIME_NOW,
Int64(delay * Double(NSEC_PER_SEC))
),
dispatch_get_main_queue(), closure)
}
Usage:
delay(seconds: 0.5) {
//code to be delayed "0.5 sec"
}
I think you're putting code in wrong position
It should be like this:
CATransaction.begin()
CATransaction.setCompletionBlock {
// animation has finished
}
tableView.beginUpdates()
// do some work
tableView endUpdates()
CATransaction.commit()
Reference: How to detect that animation has ended on UITableView beginUpdates/endUpdates?
I have a UIScrollview with buttons that I use for paging right and left:
#IBAction func leftPressed(sender: AnyObject) {
self.scrollView!.setContentOffset(CGPointMake(0, 0), animated: true)
}
I'd like to perform an action after the scrollview has finished the paging animation. Something like:
#IBAction func leftPressed(sender: AnyObject) {
self.scrollView!.setContentOffset(CGPointMake(0, 0), animated: true)
secondFunction()
}
The above code doesn't work because the second function runs before the scrollview is finished animating the offset. My initial reaction was to use a completion handler but I'm not sure how to apply one to the setContentOffset function. I've tried:
func animatePaging(completion: () -> Void) {
self.mainScrollView!.setContentOffset(CGPointMake(0, 0), animated: true)
completion()
}
with the call
animatePaging(completion: self.secondFunction())
But I get the error "Cannot invoke 'animatePaging' with an argument list of type '(completion())'. Any thoughts?
The problem is that you need a completion handler for the scrolling animation itself. But setContentOffset(_:animated:) does not have a completion handler.
One solution would be that you animate the scrolling yourself using UIView's static function animateWithDuration(_:animations:completion:). That function has a completion handler that you can use:
UIView.animateWithDuration(0.5, animations: { () -> Void in
self.scrollView.contentOffset = CGPointMake(0, 0)
}) { (finished) -> Void in
self.secondFunction()
}
Update from joern answer - Swift 4.2
UIView.animate(withDuration: 0.5, animations: { [unowned self] in
self.scrollView.contentOffset = .zero
}) { [unowned self] _ in
self.secondFunction()
}