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.
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.
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.