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.
Related
In my application, i listed delivered notifications as sorted by date in tableView. If user tap to notifications from device notification screen, app highlights row. But before user presses notification on device main screen, if users scroll towards the end of the tableView, when user presses notification, app scrolls the tableView to the row with as an animated and also highlights the row.
But if users scrolled contentView to end of the tableView, highlight is not working. I think scroll to row animation function and highlight animation function working at same time.
How can i catch when tableView.scrollToRow(at: indexPath, at: .bottom, animated: true) animation finished. If i know when scrollToRow animation finish, i can run highlight row code after.
Thanks.
DispatchQueue.main.async {
self.tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
if let cell = self.tableView.cellForRow(at: indexPath) as? NewsItemCell {
let highlightView = UIView.init(frame: cell.contentView.frame)
highlightView.backgroundColor = .white
highlightView.alpha = 0.0
cell.contentView.addSubview(highlightView)
print(indexPath)
UIView.animate(withDuration: 1.0, delay: 0, options: .curveEaseIn) {
highlightView.alpha = 1.0
} completion: { Bool in
UIView.animate(withDuration: 1.5, delay: 0, options: .curveEaseOut) {
highlightView.alpha = 0.0
} completion: { Bool in
highlightView.removeFromSuperview()
}
}
}
}
A UITableView is a subclass of UIScrollView.
You should be able to implement the UIScrollViewDelegate method scrollViewDidEndScrollingAnimation(_:) in your table view delegate (usually the owning view controller) and then highlight the row in your implementation of that method. (You'll probably need to add logic and state variables to track the fact that you are in this situation.)
Edit:
Note that as mentioned in the answer from #trndjc linked by HangerRash, you should your follow-on animation code from a call to Dispatch.main.async(). (That will help avoid stutters in the animations.)
From that answer:
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
}
}
Here's my function that animates my view
func animateViewMoving (up:Bool, moveValue :CGFloat) {
let movementDuration:NSTimeInterval = 0.3
let movement:CGFloat = ( up ? -moveValue : moveValue)
self.view.setNeedsLayout()
UIView.animateWithDuration(movementDuration, animations: {
self.view.layoutIfNeeded()
self.view.frame.origin.y += movement
})
}
I call this function on these two functions of UITextFieldDelegate
func textFieldDidBeginEditing(textField: UITextField) {
animateViewMoving(true, moveValue: 200)
}
func textFieldDidEndEditing(textField: UITextField) {
animateViewMoving(false, moveValue: 200)
}
The problem is this- Whenever I run my build and open the view for the first time, the view shifts up on pressing the textField and shifts down when editing the textField ends. However if I go back to a previous view and enter the view again, the view does not shift up on pressing the textField. Why is animateWithDuration working irregularly?
Sorry I don't know swift so I'll just write my solution in objective-C.
I assume that you are using AutoLayout. If that's the case I suggest that when you animate a view, you animate it's constraints, not the frame of the view. I encountered a lot of problems before when I animate the frame of the view.
Here's a sample:
- (void)moveSubview
{
_subviewLeadingSuperLeading.constant = 20; //just a sample to move the subview horizontally.
[UIView animateWithDuration:0.5 animations:^
{
[self.view layoutIfNeeded];
}
completion:^(BOOL isFinished)
{
//Do whatever you want
}];
}
I hope this will help you. :)
I'm trying to make my own selection animation. I've created a subclass of UITableViewCell. I do my selection animation in -setSelected:animated: method. It works as intended when you select or deselect cells by tapping them. Problem is that animation is also seen during scrolling, since -setSelected:animated: is called on each cell before it appears. This is how reusing cells mechanism works, I get it. What I don't get is that it always calls this method with animated = NO either on tap or on scroll. This seems like a logic mistake to me. I presumed it was supposed to select cells with animation when you tap them and without animation when reused cell appears. Is animated parameter even ever used anywhere except manual calls? Here's my code:
- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
BOOL alreadySelected = (self.isSelected) && (selected);
BOOL alreadyDeselected = (!self.isSelected) && (!selected);
[super setSelected:selected animated:animated];
if ((alreadySelected) || (alreadyDeselected)) return;
NSLog(#"Animated selection: %#", animated ? #"YES" : #"NO");
NSTimeInterval duration;
if (animated) {
duration = 0.25;
} else {
duration = 0.0;
}
[CATransaction begin];
[CATransaction setAnimationDuration:duration];
if (selected) {
//layer properties are changed here...
} else {
//layer properties are changed here...
}
[CATransaction commit];
}
This always goes without the animation.
I can't think of any other such an easy way to handle custom selection. Implementing -didSelectRow methods in controller seems so much worse and it's not called during scrolling, so reused cells will appear in a wrong state. Any idea how to fix this?
UPDATE:
I've found a temporary solution:
#pragma mark - Table View Delegate
- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
[cell setSelected:YES animated:YES];
return indexPath;
}
- (NSIndexPath *)tableView:(UITableView *)tableView willDeselectRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
[cell setSelected:NO animated:YES];
return indexPath;
}
It does work, but I don't like it. The fact that TableView's delegate has to know something about selection and it's not all contained in one place bugs me a lot. And -setSelected is called twice when taping a row - with and without animation.
This is how table views were designed to work. When you select it it highlights immediately, so no animation is needed. However, you will (you should, anyway) see the table view cell deselect with animation when you pop back to the table view. You can see that by using this code in the -viewWillAppear:override:
[self.tableView deselectRowAtIndexPath:[self.tableView indexPathForSelectedRow] animated:animated]
That will happen for you automatically if you are using a UITableViewController and have its clearsSelectionOnViewWillAppear property to YES.
If you want a different behavior, you need to code it yourself. If the code you posted here is working to your liking, keep it. You could also modify the cell subclass to always pass YES to the superclass in the -setSelected:animated: method override.
Although being late in the discussion, here's a simple tweak to add in your cell code:
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
// Whatever you need to do here
DispatchQueue.main.asyncAfter(deadline: .now() + 100.milliseconds) { self.setSelected(false, animated: true) }
}
Using touchesEnded instead of setSelected does the trick, either on the iPad or the iPhone.
I found a little workaround with prepareForReuse method which is called every time before the cell is reused:
class MyTableViewCell: UITableViewCell {
private var shouldAnimate = false
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
shouldAnimate = false
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
if shouldAnimate {
//your animation
}
else {
shouldAnimate = true
}
}
Hope it helps.
I would like to create a special animation for when I'm reloading the rows of a UITableView. The thing is I don't want to use this animation all the time, as I am sometimes using built-in animation for reloading the rows.
So how can I do this? By overriding reloadRowsAtIndexPaths:withRowAnimation in my own UITableView implementation? How?
Or maybe there is a better way to get my own animation while reloading a row?
I think you should not override reloadRowsAtIndexPaths:withRowAnimation. Just implement a custom method reloadRowsWithMyAnimationAtIndexPaths:in UITableView's category and use it when it is needed.
But if you want to override this method in UITableView's subclass, you could do this:
- (void)reloadRowsAtIndexPaths:(NSArray *)indexPaths
withRowAnimation:(UITableViewRowAnimation)animation {
if (self.useMyAnimation)
[self reloadRowsWithMyAnimationAtIndexPaths:indexPaths];
else
[super reloadRowsAtIndexPaths:indexPaths withRowAnimation:animation];
}
self.useMyAnimation is just a flag (BOOL property) that indicates what animation to use. Set this flag before reload operation.
For 2 or more customAnimations you could implement enum:
enum MyTableViewReloadAnimationType {
case None
case First
case Second
case Third
}
Then make a MyTableViewReloadAnimationType property (for example, reloadAnimationType) and select an appropriate animation method with switch:
var reloadAnimationType = MyTableViewReloadAnimationType.None
override func reloadRowsAtIndexPaths(indexPaths: [AnyObject], withRowAnimation animation: UITableViewRowAnimation) {
switch self.reloadAnimationType {
case .None:
super .reloadRowsAtIndexPaths(indexPaths, withRowAnimation:animation)
default:
self .reloadRowsAtIndexPaths(indexPaths, withCustomAnimationType:self.reloadAnimationType)
}
}
func reloadRowsAtIndexPaths(indexPaths: [AnyObject], withCustomAnimationType animationType: MyTableViewReloadAnimationType) {
switch animationType {
case .First:
self .reloadRowsWithFirstAnimationAtIndexPaths(indexPaths)
case .Second:
self .reloadRowsWithSecondAnimationAtIndexPaths(indexPaths)
case .Third:
self .reloadRowsWithThirdAnimationAtIndexPaths(indexPaths)
}
}
You could call the custom method reloadRowsAtIndexPaths:withCustomAnimationType: directly:
self.tableView .reloadRowsAtIndexPaths([indexPath], withCustomAnimationType: MyTableViewReloadAnimationType.First)
Inside the custom method you need to get a current cell and a new cell with method of dataSource:
func reloadRowsWithFirstAnimationAtIndexPaths(indexPaths: [AnyObject]) {
for indexPath in indexPaths {
var currentCell = self .cellForRowAtIndexPath(indexPath as! NSIndexPath)
var newCell = self.dataSource .tableView(self, cellForRowAtIndexPath: indexPath as! NSIndexPath)
var newCellHeight = self.delegate .tableView(self, heightForRowAtIndexPath: indexPath)
var frame: CGRect = currentCell.frame
frame.size.height = newCellHeight
newCell.frame = frame
self .replaceCellWithFirstAnimation(currentCell!, withAnotherCell: newCell);
}
}
func replaceCellWithFirstAnimation(firstCell : UITableViewCell, withAnotherCell secondCell: UITableViewCell) {
var cellsSuperview = firstCell.superview!
//make this with animation
firstCell .removeFromSuperview()
cellsSuperview .addSubview(secondCell)
}
You need to handle a situation when newCell's height > or < then currentCell's height. All other cells frames must be recalculated. I think it could be done with beginUpdates and endUpdates methods. Call them before the manipulation with cells.
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.