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.)
Related
I am trying to animate a rotated label like this:
#IBOutlet fileprivate weak var loadingLabel: UILabel!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadingLabel.transform = CGAffineTransform(rotationAngle: CGFloat(0.2)) // rotation line
UIView.animate(withDuration: 2.0, animations: {
self.loadingLabel.transform = CGAffineTransform(translationX: 0, y: self.view.bounds.size.height)
})
}
When I comment out the rotation line of code (and keep the label unrotated), it works fine. But when I rotate it, the label starts off the screen at the beginning of the animation:
When I comment out the animation, the label is rotated perfectly fine (but no animation obviously):
How do I rotate the image and animate it, without having this weird placement?
Edit: To clarify: I want the label to start off rotated in the center of the screen, and just simply move the label. I do not want to rotate the image during the animation.
The correct answer is that you are supposed to concatenate the transformation matrices. If you don't want to do linear algebra then the easy way is that you use the transform to set the rotation and don't animate it, then animate the view's frame/center instead.
import UIKit
class V: UIViewController {
#IBOutlet var label: UILabel!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
label.transform = CGAffineTransform(rotationAngle: CGFloat.pi / 6)
label.center.x += 300
UIView.animate(withDuration: 2) {
self.label.center.x -= 300
}
}
}
You can perform the animation with CABasicAnimation as it will give you more control on the animation and it has a completion block on which you can hide your label as well upon your requirement.
loadingLabel.transform = CGAffineTransform(rotationAngle: CGFloat(0.2)) // rotation line
let animationKey = "position.y"
CATransaction.begin()
let moveYAnimation = CABasicAnimation( keyPath: animationKey)
moveYAnimation.fromValue = loadingLabel.frame.origin.y
moveYAnimation.toValue = self.view.bounds.size.height
moveYAnimation.duration = 2
loadingLabel.layer.add( moveYAnimation, forKey: animationKey )
// Callback function
CATransaction.setCompletionBlock {
print("end animation")
self.loadingLabel.isHidden = true
}
// Do the actual animation and commit the transaction
loadingLabel.layer.add(moveYAnimation, forKey: animationKey)
CATransaction.commit()
Hope it will help you.
The first transform is ot of the animation block, it's why it begin out of the screen.
You should move it in the animation block, and use a completion handler to animate again.
UIView.animate(withDuration: 2.0, animations: {
//
}, completion: { (result) in
//
})
Be carefull, the angle is in radians.
Is there any possibility to interrupt a UIView.transition at its current state on iOS?
To give you a bit of context: I have a UIButton where I need to animate the title color - the animation can have different delays attached to it, though. Since UIView.transition does not support delays, I simply delayed execution of the entire block:
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
UIView.transition(with: button, duration: 0.4, options: [.beginFromCurrentState, .allowUserInteraction, .transitionCrossDissolve], animations: {
button.setTitleColor(selected ? .red : .black, for: .normal)
}, completion: nil)
}
The issue here is that, if this code is executed in quick succession with different delays, the outcome can be unexpected. So for example this could be called with selected=true, delay=0.5 and immediately after that with selected=false, delay=0.0. In this scenario, the button would end up red even though it should be black.
Therefore, I am looking for a method to either have UIView.transform with a delay, interrupt a UIView.transform or make setTitleColor() animatable through UIView.animate.
I actually created a framework to do this specifically, you can trigger animations relative to time progress, or value interpolation progress. Framework and documentation can be found here:
https://github.com/AntonTheDev/FlightAnimator
The following examples assume you a CATextLayer backed view, as this is not a question about how to set that up, here is a small example to get you started:
class CircleProgressView: UIView {
.....
override class func layerClass() -> AnyClass {
return CircleProgressLayer.self
}
}
Example 1:
Assuming you have a CATextLayer backed view, we can animate the it's foreground color as follows, and trigger another animation relative to time:
var midPointColor = UIColor.orange.cgColor
var finalColor = UIColor.orange.cgColor
var keyPath = "foregroundColor"
textView.animate { [unowned self] (a) in
a.value(midPointColor, forKeyPath : keyPath).duration(1.0).easing(.OutCubic)
// This will trigger the interruption
// at 0.5 seconds into the animation
animator.triggerOnProgress(0.5, onView: self.textView, animator: { (a) in
a.value(finalColor, forKeyPath : keyPath).duration(1.0).easing(.OutCubic)
})
}
Example 2:
Just as in the prior example, once you have a CATextLayer backed view, we can animate it's foregroundColor relative to the progress matrix of the RBG values, calculated based on the magnitude progress of the 3 values:
var midPointColor = UIColor.orange.cgColor
var finalColor = UIColor.orange.cgColor
var keyPath = "foregroundColor"
textView.animate { [unowned self] (a) in
a.value(midPointColor, forKeyPath : keyPath).duration(1.0).easing(.OutCubic)
// This will trigger the interruption
// when the magnitude of the starting point
// is equal to 0.5 relative to the final point
a.triggerOnValueProgress(0.5, onView: self.textView, animator: { (a) in
a.value(finalColor, forKeyPath : keyPath).duration(1.0).easing(.OutCubic)
})
}
Hope this helps :)
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()
}
}
}
}
}
}
Sometimes animation perform without animation effect(zero time).
In below code sometimes code print "Time = 0.0000002"
let date = Date()
UIView.animate(withDuration: 0.2, animations: {
}) { (finish) in
print("Time = \(Date().timeIntervalSince(date))")
}
It will because you are not animating anything inside the animations block so this is a normal behavior. You can verify with below code,
let date = Date()
self.view.layer.opacity = 0.5
UIView.animate(withDuration: 2.0, animations:
self.view.layer.opacity = 1.0
}) { (finish) in
print("Time = \(Date().timeIntervalSince(date))")
}
The purpose of animations block is to animate the property's start and end values difference on the given time. Once the property value is reached to the given value in animations block it will immediately call finish block.
If you have nothing to animate and just want a callback after few seconds better to use Timer or DispatchQueue e.g,
Timer(timeInterval: 0.2, repeats: false) { timer in
// Do whatever you want
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
// Do whatever you want
}
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")
}
}
)
}
}