Home > Software engineering >  UIView.animatekeyframes skips all keyframes except first
UIView.animatekeyframes skips all keyframes except first

Time:07-16

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")
    }

}
  • Related