iosswiftuikitcore-animationuiviewanimation

UIView animation snaps into updated bounds before animation is done


Problem:

I am trying to create my own custom search field with a desired growing animation (if you click on it), and a shrinking animation when the user taps out.

The animation behaves weirdly since it moves out of the right screen bounds when shrinking, even though the text field/search bar's right anchor is not modified.

Like so:

animation of search field

Notice how the right side of the search bar briefly moves outside of the visible screen bounds during the animation.

Expected behavior:

The search bar should smoothly grow/shrink without moving the right edge position of the text field, i.e. have the right anchor stay pinned.

What you see in above gif is built using the following code (by subclassing a UITextField):

public class MySearchBar: UITextField {
    
    private var preAnimationWidth: NSLayoutConstraint?
    private var postAnimationWidth: NSLayoutConstraint?
        
    public override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.backgroundColor = Theme.GRAY800
        self.borderStyle = .roundedRect
        
        self.layer.masksToBounds = true
        self.clipsToBounds = true
        
        self.autocorrectionType = .no
        
        self.font = FontFamily.ProximaNova.regular.font(size: 16)
        self.textColor = .white
        self.attributedPlaceholder = NSAttributedString(string: "Search", attributes: [.foregroundColor : Theme.GRAY400, .font: FontFamily.ProximaNova.regular.font(size: 16)])
        
        // some further appearance configurations
    }
    
    
    public func setupGrowAnimation(initialWidth: NSLayoutConstraint, grownWidth: NSLayoutConstraint, height: CGFloat) {
        preAnimationWidth = initialWidth
        postAnimationWidth = grownWidth
        
        self.layer.borderWidth = 0
        self.layer.cornerRadius = height / 2
    }
    
    // growButton is called when the textfield becomes active, i.e. the user taps on it.
    public func growButton() {
        guard let preAnimationWidth = preAnimationWidth, let postAnimationWidth = postAnimationWidth else { return }
        
        UIView.animate(withDuration: 0.2) {
            preAnimationWidth.isActive = false
            postAnimationWidth.isActive = true
            
            self.layer.borderColor = Theme.GRAY600.cgColor
            self.layer.borderWidth = 2
            self.layer.cornerRadius = 8
            
            self.layoutIfNeeded()
        }
    }
    
    // shrinkButton is called whenever the textfield resigns its first responder state, i.e. the user clicks out of it.
    public func shrinkButton() {
        guard let preAnimationWidth = preAnimationWidth, let postAnimationWidth = postAnimationWidth else { return }
        
        UIView.animate(withDuration: 0.2) {
            postAnimationWidth.isActive = false
            preAnimationWidth.isActive = true
            
            self.layer.borderWidth = 0
            self.layer.borderColor = .none
            self.layer.cornerRadius = self.frame.height / 2
            
            self.layoutIfNeeded()
        }
    }
}

And this is how the search bar is initialized in my viewDidLoad:

override func viewDidLoad() {
        let containerView = UIView()
        
        let searchBar = MySearchBar()
        
        searchBar.addTarget(self, action: #selector(searchBarChangedEntry(_:)), for: .editingChanged)
        searchBar.addTarget(self, action: #selector(searchBarEndedEditing(_:)), for: .editingDidEnd)
        
        searchBar.translatesAutoresizingMaskIntoConstraints = false
        
        let initialWidth = searchBar.widthAnchor.constraint(equalToConstant: 100)
        let expandedWidth = searchBar.widthAnchor.constraint(equalTo: containerView.widthAnchor, constant: -32)
        
        searchBar.setupGrowAnimation(initialWidth: initialWidth, grownWidth: expandedWidth, height: 44)

        containerView.addSubview(searchBar)
        stackView.insertArrangedSubview(containerView, at: 0)

        NSLayoutConstraint.activate([
            containerView.heightAnchor.constraint(equalToConstant: 44),
            containerView.widthAnchor.constraint(equalTo: self.stackView.widthAnchor),
            searchBar.heightAnchor.constraint(equalTo: containerView.heightAnchor),
            initialWidth,
            searchBar.rightAnchor.constraint(equalTo: containerView.rightAnchor, constant: -16)
        ])
                
        self.stackView.setCustomSpacing(12, after: containerView)
}

The search bar is part of a container view which, in turn, is the first (top) arranged subview of a stack view covering the entire screen's safeAreaLayout rectangle

What I already tried:

I have to perform the animation using constraints, and I've tried to animate it without using the width anchor (e.g. by animating the leftAnchor's constant). Nothing worked so far.

Upon googling, I couldn't really find anything helpful that would help me find a solution to this problem, which is why I am trying my luck here.

I do have to admit that I am not proficient with animations of iOS at all - so please bear with me if this is a simple mistake to fix.

So, why does the search bar behave that way? And how can I fix this?


Solution

  • A little tough to say, because the code you posted is missing a lot of information (for example, you don't show the creation of the stackView, nor where its being added to the view hierarchy).

    However, you might fix your issue with this simple change...

    In both your growButton() and shrinkButton() funcs, change this line in the animation block:

    self.layoutIfNeeded()
    

    to this:

    self.superview?.layoutIfNeeded()
    

    Edit - a little explanation...

    To animate constraint changes, we want to call .layoutIfNeeded() on the top-most view that will be affected.

    When calling:

    UIView.animate(withDuration: 0.5) {
        self.someView.layoutIfNeeded()
    }
    

    we're telling auto-layout to calculate the changes and then generate and run an animation... but only for someView and its subviews.

    If our action is going to affect someView.superview - or, for example, the constraint change is going to move/size a sibling of someView or a sibling of someView.superview, we haven't told auto-layout to include those views in its layout calculations.

    I expect there are specific implementations / layout hierarchies where one would want to specifically exclude some views from the layout / animation... but...

    Personally, I do this:

    UIView.animate(withDuration: 0.5) {
        self.view.layoutIfNeeded()
    }
    

    because the constraint I want to animate might be on a subview deep in the view hierarchy - and could have 4 or 5 or 6 etc superviews - all of which could be affected by the change.