I am making a progress bar by increasing the with of a simple image:
let progressBar = createProgressBar(width: self.view.frame.width, height: 60.0)
let progressBarView = UIImageView(image: progressBar)
progressBarView.frame = CGRect(x: 0, y: 140, width: 0, height: 60)
UIView.animateWithDuration(60.0, delay: 0.0, options: [], animations: {
progressBarView.frame.size.width = self.backgroundView.frame.size.width
}, completion: {_ in
print("progress completed")
}
)
This works as expected, but I am having problems when changing views using a TabBarController. When I change view, I would like the progress bar to continue animating in the background, such that I can go back to this view to check on progress, but instead it does end immediately when I change views, and the completion block is called.
Why does this happen, and how to fix it?
When you tap another tabBar item, the viewController that is performing animation will be in a state of viewDidDisappear and the animation will be removed.
Actually, it is not recommended to perform any animation when the viewController is not presented in the front of the screen.
To continue the interrupted animation progress, you have to maintain the current states of the animation and restore them when the tabBar item switched back.
For example, you may hold some instance variables to keep the duration, progress, beginWidth and endWidth of the animation. And you can restore the animation in viewDidAppear:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
// `self.duration` is the total duration of the animation. In your case, it's 60s.
// `self.progress` is the time that had been consumed. Its initial value is 0s.
// `self.beginWidth` is the inital width of self.progressBarView.
// `self.endWidth`, in your case, is `self.backgroundView.frame.size.width`.
if self.progress < self.duration {
UIView.animateWithDuration(self.duration - self.progress, delay: 0, options: [.CurveLinear], animations: {
self.progressBarView.frame.size.width = CGFloat(self.endWidth)
self.beginTime = NSDate() // `self.beginTime` is used to track the actual animation duration before it interrupted.
}, completion: { finished in
if (!finished) {
let now = NSDate()
self.progress += now.timeIntervalSinceDate(self.beginTime!)
self.progressBarView.frame.size.width = CGFloat(self.beginWidth + self.progress/self.duration * (self.endWidth - self.beginWidth))
} else {
print("progress completed")
}
}
)
}
}
Related
I have this simple code:
func tappedButton() {
self.button.alpha = 1.0
UIView.animate(withDuration: 1.0, delay: 4.0, options: .curveLinear, animations: {
self.button.alpha = 0.0
}) { _ in }
}
This function aims at showing a button again for 4 seconds before hiding it (with a 1 second animation). However, while the button is completely visible for these 4 seconds, tapping it doesn't work anymore.
Thanks for your help.
As per the documentation in for the method hittest(_:with:) of UIView https://developer.apple.com/documentation/uikit/uiview/1622469-hittest
This method ignores view objects that are hidden, that have disabled user interactions, or have an alpha level less than 0.01. This method does not take the view’s content into account when determining a hit. Thus, a view can still be returned even if the specified point is in a transparent portion of that view’s content.
This means that any view, particularly a button, with alpha 0.0 would not be touched.
However, the problem here is that the button is still visible, at least for you. This odd behavior occurs because the actual alpha value of the button is already setted to 0.0 when the animations starts. Animations work by changing the visual hierachy and transition the difference with the parameters you give to the function. In your case, you have two states: a view with a visible button visible and another view without the button. Only the visual part is animated but the corresponding values are already setted. A solution would be:
func tappedButton() {
self.button.alpha = 1.0
UIView.animate(withDuration: 1.0, delay: 4.0, options: .curveLinear, animations: { [weak self] in
self?.button.alpha = 0.01
}) { [weak self] _ in self?.button.alpha = 0.0 }
}
EDIT: This solution seems like a hack but works. I use this approach because the completion handler is always called with a true value.
func tapped() {
let duration = 1.0
let delay = 2.0
let delayDuration = delay + duration
UIView.animate(withDuration: duration, delay: delay, options: [.curveLinear, .allowUserInteraction], animations: { [weak self] in
self?.saveButton.alpha = 0.1
})
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + delayDuration, execute: { [weak self] in
self?.saveButton.alpha = 0.0
})
}
You need to use allUserInteraction in the options and also check for touches. The animation is added immediately and although you see the button to the system it is already hidden. What does this mean? It means you are watching a movie. But at least with userInteraction enabled you can check for touch events. This is great but how do we know the button is really showing or not? Well you have to use two different checks most likely. One that checks the true UIView alpha of the button and one check that checks the opacity on the presentation layer. I have never fully looked at the link between UIView animations and Core Animation except that I think UIView animations are a wrapper for Core Animations. UIView animations definitely update the view model immediately. So an alpha animation is most likely interpreted into an opacity animation on the layer. Armed with this we can check the opacity of the presentation layer on touches and see that the button is being clicked even if the view model thinks the alpha is 0. This check on the presentation layer will work as long as the opacity is above 0. So here you go.
import UIKit
class ViewController: UIViewController {
lazy var testButton : UIButton = {
let v = UIButton(frame: CGRect(x: 20, y: 50, width: self.view.bounds.width - 40, height: 50))
v.backgroundColor = UIColor.blue
v.addTarget(self, action: #selector(buttonClicked), for: .touchUpInside)
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(testButton)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 1.0, delay: 4.0, options: .allowUserInteraction, animations: {
self.testButton.alpha = 0
}, completion: nil)
//test the layer
//test the layer for opacity
if let presentation = testButton.layer.presentation()?.animation(forKey: "opacity"){
print("the animation is already added so normal clicks won't work")
}
}
#objc func buttonClicked(){
print("clicked")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
if let touch = touches.first{
let location = touch.location(in: self.view)
if self.testButton.frame.contains(location){
//but what you might not know is the animation is probably already running
//and so the check above misses this
if let buttonPres = testButton.layer.presentation(),
let _ = buttonPres.animation(forKey: "opacity"),
let opacity = buttonPres.value(forKey: "opacity") as? CGFloat{
if opacity > 0{
buttonClicked()
}
}
}
}
}
}
I animate a UISlider with this function:
func animateSlider(){
slider.value = Float(min)
UIView.animate(withDuration: 2.0, animations: {
self.slider.setValue(Float(self.max), animated:true)
})
When I tap the screen I want to get the current value but it only returns the maximum value. What can I do to solve that problem?
When you schedule an animation Core Animation sets the model layer's properties immediately, and the creates a presentation layer which you may inspect. This presentation layer is what you actually see on screen, and is valid to query its properties such as its frame, bounds, position, etc.
You were asking for the model layer's value which is set immediately in your animation block, when instead what you want is what is displayed through the presentation layer.
You can get the currently displayed presentation value from the label when there is an in-progress animation by using this extension on UISlider:
extension UISlider {
var currentPresentationValue: Float {
guard let presentation = layer.presentation(),
let thumbSublayer = presentation.sublayers?.max(by: {
$0.frame.height < $1.frame.height
})
else { return self.value }
let bounds = self.bounds
let trackRect = self.trackRect(forBounds: bounds)
let minRect = self.thumbRect(forBounds: bounds, trackRect: trackRect, value: 0)
let maxRect = self.thumbRect(forBounds: bounds, trackRect: trackRect, value: 1)
let value = (thumbSublayer.frame.minX - minRect.minX) / (maxRect.minX - minRect.minX)
return Float(value)
}
}
And its usage:
func animateSlider() {
slider.value = 0
DispatchQueue.main.async {
UIView.animate(withDuration: 2, delay: 0, options: .curveLinear, animations: {
self.slider.setValue(1, animated: true)
}, completion: nil)
}
// quarter done:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
print(self.slider.currentPresentationValue)
// prints 0.272757, expected 0.25 with some tolerance
}
// half done:
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
print(self.slider.currentPresentationValue)
// prints 0.547876, expected 0.5 with some tolerance
}
// three quarters done:
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
print(self.slider.currentPresentationValue)
// prints 0.769981, expected 0.75 with some tolerance
}
}
I don't think that a slider's value property can be animated using UIView.animate(). However, if you read the documentation on UISlider, you'll find the function
func setValue(Float, animated: Bool)
Use that to animate your slider to a new position.
If you want to animate a change over a longer time period like 2 seconds, though, you'll probably have to set up a repeating timer or a CADisplayLink timer that changes the slider value by small increments each time it fires.
As for your statement "When I tap the screen I want to get the current value but it only returns the maximum" That is the way Cocoa animation works. You can't interrogate an in-flight animation and get a value that's the value of the property you are animating at that instant. (You could do that if you used a repeating timer to animate the slider though.)
I have a animation that I want to be infinite
var rotateRock: Bool = true
func rotateRock() {
UIView.animate(withDuration: 2.7, delay: 2.7, options: [UIViewAnimationOptions.repeat, UIViewAnimationOptions.curveLinear], animations:
{
self.rockImg.transform.ty += 155
if self.rotateRock {
self.rotateRock = false
self.rockImg.transform = CGAffineTransform(rotationAngle: (180.0 * CGFloat(M_PI)) / 180.0)
} else {
self.rotateRock = true
self.rockImg.transform = CGAffineTransform(rotationAngle: 0)
}
},completion: nil)
}
Then I call rotateRock() inside viewDidLoad() this works, the animation never stops which is what I want.
If I now enter another VC that is presented modally then exit it so I end up in my first VC the animation is still running which I want.
But if I change VC by clicking on another tab in my tab bar and then go back the animation has stopped. Is there any way to check if the animation has stopped so that I can restart it?
You could override viewDidAppear() and call rotateRock there. That should work.
Edit:
What Matt says below is true, viewDidAppear would work but maybe it would be better viewWillAppear to make sure you don't see it before starting.
I am trying to get the current height of an animated UIView on my app.
I animate the UIView using:
function startFilling() {
UIView.animateWithDuration(1.5, delay: 0, options: CurveEaseIn, animations: { () -> Void in
self.filler.frame = CGRectMake(0, 0, self.frame.width, self.frame.height)
}) { (success) -> Void in
if success {
self.removeGestureRecognizer(self.tap)
self.delegate?.showSuccessLabel!()
} else if !success {
print("Not successful")
}
}
}
This animation is triggered using the .Begin state of a UILongPressGestureRecognizer like so:
if press.state == UIGestureRecognizerState.Began {
startFilling()
} else if press.state == UIGestureRecognizerState.Ended {
endFilling()
}
If the user stops pressing then the function endFilling is called and I need to get the current height of self.filler at the time they stop pressing.
The trouble is that the height always returns the expected end height in the animation (eg. self.frame.height), even if the press only lasted a portion of the duration time.
How can I get this height?
That's not really how core animation works. Once you set a animatable value in a UIView.animateWithDuration block that value is set, whether the animation is completed or not. Thus when you ask for self.filler.frame after you call startFilling it is equal to CGRectMake(0, 0, self.frame.width, self.frame.height) regardless of whether it's 0.5 seconds or 5.0 seconds after you call startFilling. If you want to get the current value of the frame for what is on the screen, you need to say self.filler.layer.presentationLayer.frame. This should be used as a readonly value. If you want to interrupt the animation and have it come to rest at this value, you should say something like:
UIView.animateWithDuration(1.5, delay: 0, options: BeginFromCurrentState, animations: {
self.filler.frame = self.layer.filler.presentationLayer.frame
})
In swift4
self.filler.layer.presentation().bounds
I'm expecting the completion closure on this UIView animation to be called after the specified duration, however it appears to be firing immediately...
UIView.animateWithDuration(
Double(0.2),
animations: {
self.frame = CGRectMake(0, -self.bounds.height, self.bounds.width, self.bounds.height)
},
completion: { finished in
if(finished) {
self.removeFromSuperview()
}
}
)
Has anyone else experienced this? I've read that others had more success using the center rather than the frame to move the view, however I had the same problems with this method too.
For anyone else that is having a problem with this, if anything is interrupting the animation, the completion closure is immediately called. In my case, this was due to a slight overlap with a modal transition of the view controller that the custom segue was unwinding from. Using the delay portion of UIView.animateWithDuration(0.3, delay: 0, options: UIViewAnimationOptions.CurveEaseInOut, animations:{} had no effect for me. I ended up using GCD to delay animation a fraction of a second.
// To avoid overlapping with the modal transiton
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(0.2 * Double(NSEC_PER_SEC))), dispatch_get_main_queue(), {
// Animate the transition
UIView.animateWithDuration(0.3, delay: 0, options: UIViewAnimationOptions.CurveEaseInOut, animations: {
// Animations
}, completion: { finished in
// remove the views
if finished {
blurView.removeFromSuperview()
snapshot.removeFromSuperview()
}
})
})
I resolved this in the end by moving the animation from hitTest() and into touchesBegan() in the UIView