My goal is to have my UIview notification slide up from the bottom of the controller page, and then slide down again after a few seconds.
Inside the animateUp()
func, I use UIView.animateKeyFrames
function to add 2 keyframes. One keyframe brings the view into visibility, and the second brings it back down to where it started. When I execute with only the first keyframe added,the view visibly moves. However, when I add the second keyframe, the view does not animate and only shows itself at the endpoint. I have made sure that the relative duration and relative start times are between 0 and 1 as the documentation mentions. I have also tried to use uiview.animate(withduration:)
as an alternative but get the same behavior there.
Code below (skip to bottom for just the animateUp() fn:
import UIKit
import Alamofire
import CoreGraphics
class ToastView: UIView {
let toastVM: ToastViewModel
let toastController: UIViewController
//constraints to specify layout on controller
var top = NSLayoutConstraint()
var bottom = NSLayoutConstraint()
var width = NSLayoutConstraint()
var height = NSLayoutConstraint()
var trailing = NSLayoutConstraint()
var leading = NSLayoutConstraint()
private let label: UILabel = {
let label = UILabel()
label.textAlignment = .left
label.font = .systemFont(ofSize: 15)
label.textColor = UIColor.white
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let dismissButton = UIButton()
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
init(toastVM: ToastViewModel, toastController: UIViewController, frame: CGRect) {
self.toastVM = toastVM
self.toastController = toastController
super.init(frame: frame)
self.translatesAutoresizingMaskIntoConstraints = false
config()
layoutToastConstraints()
}
//layout parameters internal to widget
func layoutToastConstraints(){
layer.masksToBounds = true
layer.cornerRadius = 8
addSubview(label)
addSubview(imageView)
addSubview(dismissButton)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 48),
label.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -69),
label.heightAnchor.constraint(equalToConstant: 20),
label.widthAnchor.constraint(equalToConstant: 226),
label.topAnchor.constraint(equalTo: self.topAnchor, constant: 14),
label.bottomAnchor.constraint(equalTo: self.topAnchor, constant: -14),
imageView.heightAnchor.constraint(equalToConstant: 7.33), //change to 7.33 later
imageView.widthAnchor.constraint(equalToConstant: 10.67), //10.67
imageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 27),
imageView.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -10.33),
imageView.topAnchor.constraint(equalTo: self.topAnchor, constant: 20.5),
imageView.topAnchor.constraint(equalTo: label.topAnchor),
imageView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -20.17),
dismissButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 298),
dismissButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 16),
dismissButton.bottomAnchor.constraint(equalTo: self.topAnchor, constant: -16),
dismissButton.heightAnchor.constraint(equalToConstant: 16),
dismissButton.widthAnchor.constraint(equalToConstant: 21)
])
}
//layout widget on the controller, using margins passed in. start widget on bottom of screen.
func layoutOnController(){
self.translatesAutoresizingMaskIntoConstraints = false
let margins = self.toastController.view.layoutMarginsGuide
self.top = self.topAnchor.constraint(equalTo: margins.bottomAnchor, constant: 0)
self.width = self.widthAnchor.constraint(equalTo: margins.widthAnchor, multiplier: 0.92)
self.height = self.heightAnchor.constraint(equalToConstant: 48)
self.leading = self.leadingAnchor.constraint(equalTo: margins.leadingAnchor, constant: 16)
self.trailing = self.trailingAnchor.constraint(equalTo: margins.trailingAnchor, constant: 16)
NSLayoutConstraint.activate([
self.top,
self.width,
self.height,
self.leading,
self.trailing
])
}
func animateUp(){
UIView.animateKeyframes(withDuration: 1.0, delay: 0.0, options: [], animations: {
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.50, animations: {
self.transform = CGAffineTransform(translationX: 0, y: -50)
})
UIView.addKeyframe(withRelativeStartTime: 0.50, relativeDuration: 0.50, animations: {
self.transform = CGAffineTransform(translationX: 0, y: 0)
})
}) { (completed) in
print("done")
}
}
EDIT:Based on your comment, I have now realized the issue is most likely my ViewController. I have done some refactoring and no longer use a 'ToastViewModel' (which was an unnecessary class used to hold data) Below is my controller and updated code.
import UIKit
class ToastViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
toastTest()
}
//the toastRenderType specifies whether the notif bar is red or green
func toastTest() -> () {
let toastView = ToastView(toastRenderType: "Ok",toastController: self, frame: .zero)
view.addSubview(toastView)
toastView.layoutOnController()
toastView.animateUp()
}
}
Updated View:
import UIKit
import Alamofire
import CoreGraphics
class ToastView: UIView {
let toastSuperviewMargins: UILayoutGuide
let toastRenderType: String
var top = NSLayoutConstraint()
var bottom = NSLayoutConstraint()
var width = NSLayoutConstraint()
var height = NSLayoutConstraint()
var trailing = NSLayoutConstraint()
var leading = NSLayoutConstraint()
private let label: UILabel = {
let label = UILabel()
label.textAlignment = .left
label.font = .systemFont(ofSize: 15)
label.textColor = UIColor.white
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let dismissButton: UIButton = {
let dismissButton = UIButton()
dismissButton.addTarget(target: self, action: #selector(self.clickedToast), for: .allTouchEvents)
dismissButton.addTarget(ToastView.self, action: #selector(clickedToast), for: .allTouchEvents)
dismissButton.isUserInteractionEnabled = true
dismissButton.isOpaque = true
dismissButton.setTitleColor( UIColor.white, for: .normal)
dismissButton.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
return dismissButton
}()
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
init(toastRenderType: String, toastController: UIViewController, frame: CGRect) {
self.toastSuperviewMargins = toastController.view.layoutMarginsGuide
self.toastRenderType = toastRenderType
super.init(frame: frame)
configureToastType()
layoutToastConstraints()
}
//layout parameters internal to widget
func layoutToastConstraints(){
self.translatesAutoresizingMaskIntoConstraints = false
layer.masksToBounds = true
layer.cornerRadius = 8
addSubview(label)
addSubview(imageView)
addSubview(dismissButton)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 48),
label.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -69),
label.heightAnchor.constraint(equalToConstant: 20),
label.widthAnchor.constraint(equalToConstant: 226),
label.topAnchor.constraint(equalTo: self.topAnchor, constant: 14),
label.bottomAnchor.constraint(equalTo: self.topAnchor, constant: -14),
imageView.heightAnchor.constraint(equalToConstant: 7.33),
imageView.widthAnchor.constraint(equalToConstant: 10.67),
imageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 27),
imageView.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -10.33),
imageView.topAnchor.constraint(equalTo: self.topAnchor, constant: 20.5),
imageView.topAnchor.constraint(equalTo: label.topAnchor),
imageView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -20.17),
dismissButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 298),
dismissButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 0),
dismissButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 16),
dismissButton.bottomAnchor.constraint(equalTo: self.topAnchor, constant: -16),
dismissButton.heightAnchor.constraint(equalToConstant: 16),
dismissButton.widthAnchor.constraint(equalToConstant: 21)
])
self.layoutIfNeeded()
}
//layout widget on the controller,using margins passed in. start widget on bottom of screen.
func layoutOnController(){
self.translatesAutoresizingMaskIntoConstraints = false
let margins = self.toastSuperviewMargins
self.top = self.topAnchor.constraint(equalTo: margins.bottomAnchor, constant: 0)
self.width = self.widthAnchor.constraint(equalTo: margins.widthAnchor, multiplier: 0.92)
self.height = self.heightAnchor.constraint(equalToConstant: 48)
self.leading = self.leadingAnchor.constraint(equalTo: margins.leadingAnchor, constant: 16)
self.trailing = self.trailingAnchor.constraint(equalTo: margins.trailingAnchor, constant: 16)
NSLayoutConstraint.activate([
self.top,
self.width,
self.height,
self.leading,
self.trailing
])
}
//now using a longer duration
func animateUp(){
UIView.animateKeyframes(withDuration: 10.0, delay: 0.0, options: .allowUserInteraction , animations: {
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.1, animations: {
self.transform = CGAffineTransform(translationX: 0, y: -50)
})
UIView.addKeyframe(withRelativeStartTime: 0.9, relativeDuration: 0.1, animations: {
self.transform = CGAffineTransform(translationX: 0, y: 50)
})
}) { (completed) in
print("done")
self.removeFromSuperview()
}
}
//configure internal text and color scheme
func configureToastType(){
if self.toastRenderType == "Ok" {
self.backgroundColor = UIColor(hue: 0.4222, saturation: 0.6, brightness: 0.78, alpha: 1.0)
label.text = "Configuration saved!"
dismissButton.setTitle("OK", for: .normal)
imageView.image = UIImage(named: "[email protected]")
}
else{
self.backgroundColor = UIColor(red: 0.87, green: 0.28, blue: 0.44, alpha: 1.00)
label.text = "Critical"
dismissButton.setTitle("Undo", for: .normal)
imageView.image = UIImage(named: "icon-16-close.png")
}
}
@objc func clickedToast(){
print("you clicked the toast button")
self.removeFromSuperview()
// delegate?.toastButtonClicked(sender)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
CodePudding user response:
You have some very confusing constraints, and we're missing your config()
func and ToastViewModel
and an example view controller showing how you set things up...
But, after taking some guesses to fill in the missing pieces, your animation code works for me - although it animates the view up and then *immediately down.
As is obvious, animateKeyframes
uses relative times. You can think of those start and duration values as percentages of the total duration.
So, if you wanted a 1-second UP animation, then pause for 8-seconds, then a 1-second DOWN animation, it would look like this:
// total duration is 10-seconds
// 1-second UP is 1/10th of total, or 0.1
// 8-second PAUSE is 8/10ths of total, or 0.8
// 1-second DOWN is 1/10th of total, or 0.1
// so the DOWN animation should start at 9-seconds
// 0.1 0.8 = 0.9
UIView.animateKeyframes(withDuration: 10.0, delay: 0.0, options: [], animations: {
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.1, animations: {
self.transform = CGAffineTransform(translationX: 0, y: -50)
})
UIView.addKeyframe(withRelativeStartTime: 0.9, relativeDuration: 0.1, animations: {
self.transform = CGAffineTransform(translationX: 0, y: 0)
})
}) { (completed) in
print("done")
}
Since we generally want to make code flexible, we could write it like this - very, very verbose to make things clear:
func animateUp(){
// we want both UP and DOWN animations to take 0.3 seconds each
let upAnimDuration: TimeInterval = 0.3
let downAnimDuration: TimeInterval = 0.3
// we want to pause 3-seconds while view is "up"
let pauseDuration: TimeInterval = 3.0
// so, total duration is up pause down
let totalDuration: TimeInterval = upAnimDuration pauseDuration downAnimDuration
// animateKeyframes uses *relative* times, so
// let's convert those to percentages of total
let upPCT: TimeInterval = upAnimDuration / totalDuration
let downPCT: TimeInterval = downAnimDuration / totalDuration
let pausePCT: TimeInterval = pauseDuration / totalDuration
// now let's calculate the start times
let upStart: TimeInterval = 0.0
let pauseStart: TimeInterval = upStart upPCT
let downStart: TimeInterval = pauseStart pausePCT
// let's transform the view UP by self's height 20-points
let yOffset: CGFloat = self.frame.height 20.0
// now we'll do the animation with those values
UIView.animateKeyframes(withDuration: totalDuration, delay: 0.0, options: [], animations: {
UIView.addKeyframe(withRelativeStartTime: upStart, relativeDuration: upPCT, animations: {
self.transform = CGAffineTransform(translationX: 0, y: -yOffset)
})
UIView.addKeyframe(withRelativeStartTime: downStart, relativeDuration: downPCT, animations: {
self.transform = CGAffineTransform(translationX: 0, y: 0)
})
}) { (completed) in
print("done")
}
}