Background Info
I am building a tiny stopwatch app. It consists of a main view with a label displaying the time and two buttons: start & stop.
The start button activates NSTimers which then call methods that calculate the time passed and then update the labels accordingly. The stop button simply invalidates the timers.
On the upper right hand corner, there is an arrow button that is supposed to move all the UI elements to the left and show a menu. That works great when the timers and not running and thereby the labels are not being updated.
Issue
However, when running the timers & thereby updating the labels simultaneously, shortly after beginning the animation, everything snaps back to the position it was in previously. When invalidating the timers with the stop button, the animation is working fine again.
Demo
Code
Animation in toggleMenu()
UIView.animateWithDuration(0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0.5, options: .CurveEaseOut, animations: {
if self.arrow.frame.origin.x >= screenBounds.width/2 {
//if menu is NOT visible (mainView ON-SCREEN, menu right)
for element in self.UIElements {element.frame.origin.x -= screenBounds.width}
self.arrow.frame.origin.x = 0
self.arrow.transform = CGAffineTransformMakeRotation(CGFloat(-M_PI))
} else {
//if menu IS visible (mainView left, menu ON-SCREEN)
for element in self.UIElements {element.frame.origin.x += screenBounds.width}
self.arrow.frame.origin.x = screenBounds.width-self.arrow.frame.width
self.arrow.transform = CGAffineTransformMakeRotation(CGFloat(0))
}
}, completion: nil)
Updating Labels in timer-called updateTime()
let currentTime = NSDate.timeIntervalSinceReferenceDate()
var elapsedTime: NSTimeInterval = currentTime - startTime
let hrs = UInt8(elapsedTime/3600)
elapsedTime -= NSTimeInterval(hrs)*3600
let mins = UInt8(elapsedTime/60)
elapsedTime -= NSTimeInterval(mins)*60
let secs = UInt8(elapsedTime)
elapsedTime -= NSTimeInterval(secs)
let strHrs = String(format: "%02d", hrs)
let strMins = String(format: "%02d", mins)
let strSecs = String(format: "%02d", secs)
//update time labels
self.time.text = "\(strHrs):\(strMins)"
self.secs.text = "\(strSecs)"
Attempts
With no luck, I tried...
...hiding the labels once the animation is finished
...animating / hiding all the elements' superview once the animation is finished (clipsToBounds set true)
Note
I figured...
...commenting out the lines where the labels are being updated "fixes" the issue
...animating the UI elements to move only a couple pixels (like 100px) causes the same problem, meaning that the issue happens on- and off-screen
Any thoughts?
Thanks in advance!
When you update the labels, Auto Layout is running and placing your UI elements back to where their constraints say they should be. You shouldn't alter the frames of objects that are under the control of Auto Layout or you will get unexpected results.
Instead of updating the origin.x of your UI elements, create #IBOutlets to the NSLayoutContraints that position them horizontally and then update the constant property of the constraints and call self.view.layoutIfNeeded() in your animation loop.
It might be easier to make all of your UI elements be a subview of a top level UIView, and then you'd only need to update the one constraint that places that UIView horizontally to move it offscreen.
Related
For hours I've been trying to solve the smallest animation glitch. My code successfully moves the view off screen, then animates it back in. It gets the x-coordinations right but the Y axis has behavior I don't understand. Here's the code:
func listTrans_slideIn (slideFrom: String) {
//var newFrame = tableView.frame
tableView.frame.origin.x = 1000
//tableView.frame.origin.y = 100
print("Table pushed to side")
UIView.animateKeyframesWithDuration(1.375 /*Total*/, delay: 0.0, options: UIViewKeyframeAnimationOptions.CalculationModeLinear, animations: {
UIView.addKeyframeWithRelativeStartTime(0.0, relativeDuration: 1/1, animations:{
self.tableView_toTLG_Top.constant = 130
self.tableView_toSV_Left.constant = 0
self.tableView_toSV_Right.constant = 0
self.setupView()
//newFrame.origin.y = self.hdrBox.frame.height+50
//newFrame.origin.x = 0
//self.tableView.frame = newFrame
self.tableView.layoutIfNeeded()
})
},
completion: { finished in
if (!finished) { return }
})
}
The weird behavior is that if I put the correct y-coordinate in the animation keyframe, it comes in too high but then settles at the correct coordinate. If I put in a y-Coordinate that is too low, it comes in at the correct height but then settles too low.
As you can see, I've tried using frames and constraints. I've tried changing the height that I move it off screen to, but that seems to have no effect.
Anyone have any idea why I've spent half my day seeing this bug?
Can you try something like this :
// Use the same value here and below, it moves it out of the screen
self.tableView.center.y -= 500
UIView.animateWithDuration(1.0, animations: { () -> Void in
// Then it comes back
self.tableView.center.y += 500
})
This turned out to be an order of operations problem. The solution required that I move the animation to take place in the viewDidAppear.
Other notes:
To make the animation look as smooth as possible, consider turning off the segue's animation.
Also make sure you're running the segue call and animation in your main thread so that everything happens smoothly and without delay
I am trying to replicate this animation to dismiss a view controller (15 second video): https://www.youtube.com/watch?v=u87thAbT0CQ
This is what my animation looks like so far: https://www.youtube.com/watch?v=A2XmXTVxLdw
This is my code for the pan gesture recognizer:
#IBAction func recognizerDragged(sender: AnyObject) {
let displacement = recognizer.translationInView(view)
view.center = CGPointMake(view.center.x + displacement.x, view.center.y + displacement.y)
recognizer.setTranslation(CGPoint.zero, inView: view)
switch recognizer.state {
case .Ended:
UIView.animateWithDuration(0.4, animations: { () -> Void in
self.view.layer.position = CGPoint(x: self.view.frame.width / 2, y: self.view.frame.height / 2)
})
default:
print("default")
}
let velocity = recognizer.velocityInView(self.titleView)
print(velocity)
if velocity.y < -1500 {
up = true
self.dismissViewControllerAnimated(true, completion: nil)
}
if velocity.x > 1500 {
right = true
self.dismissViewControllerAnimated(true, completion: nil)
}
}
It may be a little hard to notice in my video, but there is a small disconnect in how fast the user flicks up, and how fast the animation completes. That is to say, the user may flip up very fast but the animation is set to a hardcoded 0.3 seconds. So if the user flicks the view fast, then as the animation completes, as soon as their finger lifts off the view, the animation actually slows down.
I think what I need is a way to take the velocity recorded in the recognizerDragged IBAction, and pass that to the animation controller, and based on that, calculate how long the animation should take, so that the velocity is consistent throughout, and it looks smooth. How can I do that?
Additioanlly, I'm slightly confused because the Apple Documentation says that the velocityInView function returns a velocity in points, not pixels. Yet different iOS devices have different points per pixels, so that would further complicate how I would translate the velocity before passing it to the animation class.
Any idea how to pass the velocity back to the animation controller, so that the animation duration changes based on that, and make it work for different iPhones ?
thanks
What you are likely looking at in the video you are trying to replicate is a UIDynamics style interaction, not a CoreAnimation animation. The velocity returned from velocityInView can be used directly in UIDynamics like this:
[self.behavior addLinearVelocity:
[pan velocityInView:pan.view.superview] forItem:pan.view];
I wrote a tutorial for doing this style of view interaction here: https://www.smashingmagazine.com/2014/03/ios7-new-dynamic-app-interactions/
To stick with UIView animations you just need to look at the frame's bottom (which is also in points) and calculate the new time. This assume that you want frame's bottom to be at 0 at the end of the animation:
animationTime = CGRectGetMaxY(frame) / velocity
You aren't showing how you created the animation controller, but just keep a reference to it and pass the time before calling dismiss. This is also assuming you are using a linear curve. With any other kind of curve, you will have to estimate what the starting velocity would have to be to be based on time and adjust.
I'm making collection view with a dynamic content loading ability. In my case, I'm making calendar. Current date will be in the visible part (after loading). If you scroll up, the older dates will shown and if you scroll down you'll see next dates.
My collection view use standard flow layout. I have 7 cells in a row and 4 visible rows.
I have a problem with adding older dates (e.g. cells that appears if you scroll up). What I'm doing: first I implement scroll view method for detecting scroll event.
startOffsetY is contentOffset.y value set in viewDidLoad. It's not equal to 0 because I set contentInset. upd is just a flag that means that new update is could be start.
func scrollViewDidScroll(scrollView: UIScrollView) {
if scrollView.contentOffset.y <= startOffsetY - 20 && upd {
calendar.updateDataSource()
}
}
Next, I calculate dates for previous time slice (about 4 weeks, I also tried 1 week) from the first date that is in my data source array.
After that, I calculate indexPath array and make update to collection view:
var indexes = [NSIndexPath]()
for n in 1...4 {
for i in reverse(1...7) {
indexes.append(NSIndexPath(forItem: n * 7 - i, inSection: 0))
}
}
self.calendarCollectionView.performBatchUpdates({ () -> Void in
self.calendarCollectionView.insertItemsAtIndexPaths(indexes)
}, completion: { (finish) -> Void in
self.upd = true
})
but, I have visible lags when rows added and scrolling is in progress.
I tried different strategies: I used reloadData() and it was ideal (on the simulator) and extremely laggy on my iPhone 4S (this was the first time in my experience when simulator was faster than the device). From this point, I figure out, that animation of inserting items might be the problem. I tried to wrap performBatchUpdates into the UIView.performWithoutAnimation block, but with no luck also.
I'm really looking for some help, I don't looking for a ready made solutions except they work as I describe (scroll up'n'down and load content) and I can look how it works. Once again, scrolling already loaded items is not a problem, the problem is a lagging when content is add at the begging of my data array.
EDIT
Code provided by #teamnorge in Swift
func scrollViewDidScroll(scrollView: UIScrollView) {
let currentOffset = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
var offset : CGFloat!
println(currentOffset)
/* 0.125 - 1/8, 0.5 - 1/2 */
if currentOffset < contentHeight * 0.125 {
offset = contentHeight * 0.125 - currentOffset
// reload content here
scrollView.contentOffset = CGPoint(x: 0, y: currentOffset + contentHeight * 0.5 + offset - CGFloat(cellHeight))
}
/* 0.75 - 6/8, 0.5 - 1/2 */
if currentOffset > contentHeight * 0.75 {
offset = currentOffset - contentHeight * 0.75
// reload content here
scrollView.contentOffset = CGPoint(x: 0, y: currentOffset - contentHeight * 0.5 - offset + CGFloat(cellHeight))
}
It works very nice, but need to play with contentOffset y formula because right now trick works only when you scroll up. Will fix this tomorrow and add calendar date calculations.
EDIT 2
reloading data ruins everything in all of my prototypes. Including the one I made previously. Found something that makes lags a bit lower but still very noticeable and totally unacceptable. Here are these things:
remove autolayout from cell prototype in the storyboard
add this code to the cell:
cell.layer.shouldRasterize = true
cell.layer.rasterizationScale = UIScreen.mainScreen().scale
I think that invalidation of the layout results in these lags. So, I maybe I need to make custom flow layout? And if so what recommendations can you give.
That's actually the very interesting topic. I'll try to explain the idea I came up with while working on another Calendar app.
In my case it was not a Calendar View but Day View, hours 00:00 - 24:00 listed from top to bottom, so I had UITableView and not UICollectionView but it's not that important in this case.
The language used was not Swift but Objective-C, so I just try to translate code samples.
Instead of manipulation with the data source I created UITableView with the fixed amount of rows, in my case to store exactly two days (48 rows, two days of 24 hours each). You could choose the amount of rows containing in two full screens.
The important thing is that total amount of rows must be a multiple of 8.
Then you need a formula to calculate what's the day number for each particular cell based on what's inside the first visible row.
The idea is that UICollectionView is in fact UIScrollView so when we scroll down or up we can handle the corresponding event and calculate the visible offsets.
To simulate the infinitive scrolling we handle the scrollViewDidScroll and check if you just passed the 1/8 of the UIScrollView height scrolling up, move your UIScrollView to the 1/2 of height plus the exact offset so it moves to the "second screen" smoothly. And back, if you passed the 6/8 of the UIScrollView height while scrolling down, move the UIScrollView up to 1/2 of its height minus offset.
When you do this you see scrolling indicator jumps up and down which is very confusing so we have to hide it, just put somewhere in viewDidLoad:
calendarView.showsHorizontalScrollIndicator = false
calendarView.showsVerticalScrollIndicator = false
where calendarView is your UICollectionView instance name.
Here is the code (translated from Objective-C right here, not tried in the real project):
func scrollViewDidScroll(scrollView_:UIScrollView) {
if !enableScrollingHandling {return}
var currentOffsetX:CGFloat = scrollView_.contentOffset.x
var currentOffSetY:CGFloat = scrollView_.contentOffset.y
var contentHeight:CGFloat = scrollView_.contentSize.height
var offset:CGFloat
/* 0.125 - 1/8, 0.5 - 1/2 */
if currentOffSetY < (contentHeight * 0.125) {
enableScrollingHandling = false
offset = (contentHeight * 0.125) - currentOffSetY
// #todo: your code, specify which days are listed in the first row (2nd screen)
scrollView_.contentOffset = CGPoint(x: currentOffsetX, y: currentOffset +
contentHeight * 0.5 + offset - CGFloat(kRowHeight))
enableScrollingHandling = true
return
}
/* 0.75 - 6/8, 0.5 - 1/2 */
if currentOffSetY > (contentHeight * 0.75) {
enableScrollingHandling = false
offset = currentOffSetY - (contentHeight * 0.75)
// #todo: your code, specify which days are listed in the first row (1st screen)
scrollView_.contentOffset = CGPoint(x: currentOffsetX, y: currentOffset -
contentHeight * 0.5 - offset + CGFloat(kRowHeight))
enableScrollingHandling = true
return
}
}
Then in your collectionView:cellForItemAtIndexPath: based on what's in the first row (is this content of the first screen or second screen) and what are the current visible days (formula) you just update the cell content.
This formula could also be a tricky part and may require some workaround especially if you decide to put Month Names in between months. I do also have some ideas on how to organise it, so if you encounter any problem we can discuss it here.
But such an approach to simulate infinitive scrolling with "two screens loop and jumps in between" works really like a charm, very smooth scrolling like behaviour tested on older phones also.
PS: kRowHeight is just a constant the height of the exact row (cell) it's needed for precise and smooth scrolling behaviour it could be skipped I think.
UPDATE:
Important notice, I skipped this from original code, but see this is important. When you manually set the contentOffset you also triggers the scrollViewDidScroll event, to prevent this you need to temporary disable your scrollViewDidScroll event processing. You can do it by adding, for example, state variable enableScrollHandling and change its state true/false, see updated code.
I have created a storyboard with multiple movable (using pan gesture recognisers) UIImageView objects, that are hidden by default. I have a UIButton, that when pressed, generates random X and Y positions for the buttons to be moved to as follows:
// Places each puzzle piece at a random location on the screen
for puzzlePiece in puzzlePieces {
// Generate a random X position for the new center point of the puzzle,
// so that the piece is on the screen. Must convert to UInt and then CGFloat
var randomXPosition: CGFloat = CGFloat(UInt(114 + arc4random_uniform(796)))
// Generate a random Y position for the new center point of the puzzle,
// so that the piece is on the screen. Must convert to UInt and then CGFloat.
var randomYPosition: CGFloat = CGFloat(UInt(94 + arc4random_uniform(674)))
puzzlePiece.frame = CGRect(x: randomXPosition, y: randomYPosition, width: puzzlePiece.frame.width, height: puzzlePiece.frame.height)
}
After the UIImageViews are moved to random positions, they are un-hidden, and a UILabel displaying a timer begins to keep track of time, as follows:
timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: Selector("updateTimerLabel"), userInfo: nil, repeats: true)
The problem is that whenever the NSTimer calls the "updateTimerLabel" method and the UILabel's text is modified, ALL of the UIImageViews revert to their default location as specified on the Storyboard (plus any transformations as a result of panning). Specifically, the last line of this method causes the issue:
func updateTimerLabel() {
secondsElapsed++;
var numSecondsToDisplay = secondsElapsed % 60
var numMinutesToDisplay = ((secondsElapsed - numSecondsToDisplay) % 3600) / 60
var numHoursToDisplay = (secondsElapsed - numSecondsToDisplay - numMinutesToDisplay) / 3600
var secondsToDisplay = String(format: "%02d", numSecondsToDisplay)
var minutesToDisplay = String(format: "%02d", numMinutesToDisplay)
var hoursToDisplay = String(format: "%02d", numHoursToDisplay)
timerLabel.text! = "Timer: \(hoursToDisplay):\(minutesToDisplay):\(secondsToDisplay)"
}
I'm wondering if there is any way to prevent the UIImageViews from reverting from their random positions to their default Storyboard positions when changing the UILabel's text.
Auto Layout is running and repositioning your objects. Turn off Auto Layout and your objects will stay where you put them.
I want to observe changes to the x coordinate of my UIView's origin while it is being animated using animateWithDuration:delay:options:animations:completion:. I want to track changes in the x coordinate during this animation at a granular level because I want to make a change in interaction to another view that the view being animated may make contact with. I want to make that change at the exact point of contact. I want to understand the best way to do something like this at a higher level:
-- Should I use animateWithDuration:... in the completion call back at the point of contact? In other words, The first animation runs until it hits that x coordinate, and the rest of the animation takes place in the completion callback?
-- Should I use NSNotification observers and observe changes to the frame property? How accurate / granular is this? Can I track every change to x? Should I do this in a separate thread?
Any other suggestions would be welcome. I'm looking for a abest practice.
Use CADisplayLink since it is specifically built for this purpose. In the documentation, it says:
Once the display link is associated with a run loop, the selector on the target is called when the screen’s contents need to be updated.
For me I had a bar that fills up, and as it passed a certain mark, I had to change the colors of the view above that mark.
This is what I did:
let displayLink = CADisplayLink(target: self, selector: #selector(animationDidUpdate))
displayLink.frameInterval = 3
displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)
UIView.animateWithDuration(1.2, delay: 0.0, options: [.CurveEaseInOut], animations: {
self.viewGaugeGraph.frame.size.width = self.graphWidth
self.imageViewGraphCoin.center.x = self.graphWidth
}, completion: { (_) in
displayLink.invalidate()
})
func animationDidUpdate(displayLink: CADisplayLink) {
let presentationLayer = self.viewGaugeGraph.layer.presentationLayer() as! CALayer
let newWidth = presentationLayer.bounds.width
switch newWidth {
case 0 ..< width * 0.3:
break
case width * 0.3 ..< width * 0.6:
// Color first mark
break
case width * 0.6 ..< width * 0.9:
// Color second mark
break
case width * 0.9 ... width:
// Color third mark
break
default:
fatalError("Invalid value observed. \(newWidth) cannot be bigger than \(width).")
}
}
In the example, I set the frameInterval property to 3 since I didn't have to rigorously update. Default is 1 and it means it will fire for every frame, but it will take a toll on performance.
create a NSTimer with some delay and run particular selector after each time lapse. In that method check the frame of animating view and compare it with your colliding view.
And make sure you use presentationLayer frame because if you access view.frame while animating, it gives the destination frame which is constant through out the animation.
CGRect animationViewFrame= [[animationView.layer presentationLayer] frame];
If you don't want to create timer, write a selector which calls itself after some delay.Have delay around .01 seconds.
CLARIFICATION->
Lets say you have a view which you are animating its position from (0,0) to (100,100) with duration of 5secs. Assume you implemented KVO to the frame of this view
When you call the animateWithDuration block, then the position of the view changes directly to (100,100) which is final value even though the view moves with intermediate position values.
So, your KVO will be fired one time at the instant of start of animation.
Because, layers have layer Tree and Presentation Tree. While layer tree just stores destination values while presentation Layer stores intermediate values.
When you access view.frame it will always gives the value of frame in layer tree not the intermediate frames it takes.
So, you had to use presentation Layer frame to get intermediate frames.
Hope this helps.
UIDynamics and collision behaviours would be worth investigating here. You can set a delegate which is called when a collision occurs.
See the collision behaviour documentation for more details.