iosuiviewanimationintrinsic-content-size

How do I animate the height of an input accessory view?


I am experiencing strange behavior when animating the height of an input accessory view. What am I doing wrong?

I create a UIInputView subclass (InputView) with a single subview. The height of InputView and its intrinsicContentSize are controlled by the subview. InputView is 50 pixels tall when isVisible is true and 0 pixels tall when isVisible is false.

import UIKit

class InputView: UIInputView {
    private let someHeight: CGFloat = 50.0, zeroHeight: CGFloat = 0.0
    private let subView = UIView()
    private var hide: NSLayoutConstraint?, show: NSLayoutConstraint?

    var isVisible: Bool {
        get {
            return show!.isActive
        }
        set {
            // Always deactivate constraints before activating conflicting ones
            if newValue == true {
                hide?.isActive = false
                show?.isActive = true
            } else {
                show?.isActive = false
                hide?.isActive = true
            }
        }
    }

    // MARK: Sizing

    override func sizeThatFits(_ size: CGSize) -> CGSize {
        return CGSize(width: size.width, height: someHeight)
    }

    override var intrinsicContentSize: CGSize {
        return CGSize.init(width: bounds.size.width, height: subView.bounds.size.height)
    }

    // MARK: Initializers

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(frame: CGRect, inputViewStyle: UIInputViewStyle) {
        super.init(frame: frame, inputViewStyle: inputViewStyle)

        addSubview(subView)
        subView.backgroundColor = UIColor.purple

        translatesAutoresizingMaskIntoConstraints = false
        subView.translatesAutoresizingMaskIntoConstraints = false

        subView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor).isActive = true
        subView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        subView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        subView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor).isActive = true

        show = subView.heightAnchor.constraint(equalToConstant: someHeight)
        hide = subView.heightAnchor.constraint(equalToConstant: zeroHeight)
        hide?.isActive = true
    }
}

The host view controller toggles isVisible in a one-second animation block when a button is pressed.

import UIKit

class MainViewController: UIViewController {
    let testInputView = InputView.init(frame: .zero, inputViewStyle: .default)

    @IBAction func button(_ sender: AnyObject) {
        UIView.animate(withDuration: 1.0) {
            let isVisible = self.testInputView.isVisible
            self.testInputView.isVisible = !isVisible
            self.testInputView.layoutIfNeeded()
        }
    }

    override var canBecomeFirstResponder: Bool {
        return true
    }

    override var inputAccessoryView: UIView? {
        return testInputView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

I expect the input accessory view to smoothly grow from the botton of the screen when isVisible is set to true, and smoothly shrink to the button of the screen when isVisible is set to false. Instead, the keyboard background overlay appears at full 50-pixel height as soon as isVisible is true and the input accessory view grows from the center of its frame.

The purple box is not growing from the bottom of the screen and is surrounded by the keyboard overlay background

When shrinking, the input accessory view instantly loses some of its height before continuing the animation smoothly.

I created an input accessory view demonstration project that displays this unexpected behavior.


Solution

  • This will give you the correct animation:

        UIView.animate(withDuration: 1.0) {
            let isVisible = self.testInputView.isVisible
            self.testInputView.isVisible = !isVisible
            self.testInputView.superview?.superview?.layoutIfNeeded()
        }
    

    However, it's never a good practice to call superview if Apple changes the design. So there may be a better answer.

    This is what the superviews represent:

    print(testInputView.superview) // UIInputSetHostView
    
    print(testInputView.superview?.superview) // UIInputSetContainerView
    

    EDIT: ADDED A SAFER SOLUTION

    I'm not too familiar with the UIInputView. But one way of solving it without calling the superview would be to only animate the height change of the subview:

    Step 1:
    Move the isVisible outside the animation block.

    @IBAction func button(_ sender: AnyObject) {
        let isVisible = self.testInputView.isVisible
        self.testInputView.isVisible = !isVisible
        UIView.animate(withDuration: 1.0) {
            self.testInputView.layoutIfNeeded()
        }
    }
    

    Step 2: Create a new method in your InputView which changes the height constraint of the InputView instead of the intrinsicContentSize.

    private func updateHeightConstraint(height: CGFloat) {
        for constraint in constraints {
            if constraint.firstAttribute == .height {
                constraint.constant = height
            }
        }
        self.layoutIfNeeded()
    }
    

    Step 3: And call that method inside the setter.

    if newValue == true {
         updateHeightConstraint(height: someHeight)
         hide?.isActive = false
         show?.isActive = true
    } else {
         updateHeightConstraint(height: zeroHeight)
         show?.isActive = false
         hide?.isActive = true
    }
    

    Step 4: Lastly some changes in the init.

    override init(frame: CGRect, inputViewStyle: UIInputViewStyle) {
        super.init(frame: frame, inputViewStyle: inputViewStyle)
    
        addSubview(subView)
    
        backgroundColor = .clear
        subView.backgroundColor = UIColor.purple
    
        subView.translatesAutoresizingMaskIntoConstraints = false
    
        subView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        subView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        subView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor).isActive = true
    
        show = subView.heightAnchor.constraint(equalToConstant: someHeight)
        hide = subView.heightAnchor.constraint(equalToConstant: zeroHeight)
        hide?.isActive = true
    }
    

    Conclusion: This result in the InputView changes it's height before animating the height of the purple subview. The only downside is the UIInputView, which has some kind of gray background as default and cannot be changed to Clear. However, you can use the same backgroundColor as the VC.

    But if you instead should go with a regular UIView as InputAccessoryView it will be UIColor.clear as default. Than the first "jump" will not be noticed.