iosswiftuiviewuiviewcontrolleruiview-hierarchy

UIView.animateKeyFrames blocks Uibutton click - view hierarchy issue or nsLayoutConstraint?


I currently have a ToastView class that creates a view that contains a button, label, and image. The goal is to get this ToastView instance to slide up and down on the controller as a subview, like a notification bar. In LayoutOnController(), I add the bar below the visible part of the page using nslayoutconstraint(), using the margins of the superview passed in. Finally, I animate upwards using keyframes and transform the view upwards.

My problem is that once I animate the bar upwards, the button becomes non-interactive. I can click my button and trigger my @objc function if I only call LayoutOnController().

Im assuming my problem is either in the ToastViewController where I add the ToastView object as a subview (maybe misunderstanding my view hierarchy?), or that the NSLayoutConstraints do not behave well with UIView.AnimateKeyFrames. I have tried using layout constants
( self.bottom.constant = 50) instead of self?.transform = CGAffineTransform(translationX: 0, y: -40) , but the view doesnt show at all if I do that. Ive been stuck for a while so any insight is appreciated!

Here is my code:

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( type:  .custom)
        dismissButton.isUserInteractionEnabled = true
        dismissButton.setTitleColor( UIColor.white, for: .normal)
        dismissButton.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
        dismissButton.translatesAutoresizingMaskIntoConstraints = false
        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()
    }
   
    //animate upwards from bottom of screen position (configured in layoutOnController() )
    //CANNOT CLICK BUTTON HERE
    func animateToast(){
        UIView.animateKeyframes(withDuration: 4.6, delay: 0.0, options: [], animations: {
            UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.065, animations: { [weak self] in
                self?.transform = CGAffineTransform(translationX: 0, y: -40)
            })
            UIView.addKeyframe(withRelativeStartTime: 0.93, relativeDuration: 0.065, animations: { [weak self] in
                self?.transform = CGAffineTransform(translationX: 0, y: 50)
            })
            
        }) { (completed) in
            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: "checkmark.png")
        }
        else{
          self.backgroundColor = UIColor(red: 0.87, green: 0.28, blue: 0.44, alpha: 1.00)
          label.text = "Configuration deleted."
          dismissButton.setTitle("Undo", for: .normal)
          dismissButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
          imageView.image =  UIImage(named: "close2x.png")
        }
        dismissButton.addTarget(self, action: #selector(clickedToast), for: .touchUpInside)
    }
   
    //layout widget on the controller,using margins passed in via controller. start widget on bottom of screen.
    func layoutOnController(){
        
        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
        ])
    }
    
    //layout parameters internal to widget as subviews
    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: 20),
            imageView.widthAnchor.constraint(equalToConstant: 20),
            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.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -20.17),
          
            dismissButton.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 24),
            dismissButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16),
            dismissButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 16),
            dismissButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -16),

        ])
        self.layoutIfNeeded()
    }
    @objc func clickedToast(){
        print("you clicked the toast button")
        self.removeFromSuperview()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
 }

ToastViewController, where I am testing the bar animation:

import UIKit
import CoreGraphics
class ToastViewController: UIViewController {
  
    override func viewDidLoad() {
          super.viewDidLoad()
      }
     
      override func viewDidAppear(_ animated: Bool) {
          super.viewDidAppear(animated)
          self.view.backgroundColor = .white
          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() //button click works
          toastView.animateToast()       //button click ignored 
      }
    
}

Solution

  • You cannot tap the button because you've transformed the view. So the button is still below the bottom - only its visual representation is visible.

    You can either implement hitTest(...), calculate if the touch location is inside the transformed button, and call clickedToast() if so, or...

    What I would recommend:

    func animateToast(){
        // call this async on the main thread
        //  so auto-layout has time to set self's initial position
        DispatchQueue.main.async { [weak self] in
            guard let self = self, let sv = self.superview else { return }
            // decrement the top constant by self's height + 8 (for a little spacing below)
            self.top.constant -= (self.frame.height + 8)
            UIView.animate(withDuration: 0.5, delay: 0.0, options: [], animations: {
                sv.layoutIfNeeded()
            }, completion: { b in
                if b {
                    // animate back down after 4-seconds
                    DispatchQueue.main.asyncAfter(deadline: .now() + 4.0, execute: { [weak self] in
                        // we only want to execute this if the button was not tapped
                        guard let self = self, let sv = self.superview else { return }
                        // set the top constant back to where it was
                        self.top.constant += (self.frame.height + 8)
                        UIView.animate(withDuration: 0.5, delay: 0.0, options: [], animations: {
                            sv.layoutIfNeeded()
                        }, completion: { b in
                            self.removeFromSuperview()
                        })
                    })
                }
            })
        }
    }