Turns out I was using the wrong type of animation. The following code works:
// makes sure we animate from 0 to calculated width
float newWidth, newHeight = ...
widthConstraint.Constant = 0;
heightConstraint.Constant = 30;
var widthAnimation = new CABasicAnimation();
widthAnimation .TimingFunction = CAMediaTimingFunction.FromName(CAMediaTimingFunction.EaseInEaseOut);
widthAnimation .Duration = 0.25;
var heightAnimation = new CABasicAnimation();
widthAnimation .TimingFunction = CAMediaTimingFunction.FromName(CAMediaTimingFunction.EaseInEaseOut);
widthAnimation .Duration = 0.25;
widthConstraint.Animations = new NSDictionary("constant", widthAnimation);
heightConstraint.Animations = new NSDictionary("constant", heightAnimation);
NSAnimationContext.BeginGrouping();
NSAnimationContext.CurrentContext.Duration = widthAnimation.Duration;
NSAnimationContext.CurrentContext.CompletionHandler = new NSAction(() => ((NSLayoutConstraint)heightConstraint.Animator).Constant = newHeight);
((NSLayoutConstraint)widthConstraint.Animator).Constant = newWidth;
NSAnimationContext.EndGrouping();
This runs the width animation and then the height animation.