Animator with draw(rect)

I have a custom implementation of UIButton with some simple animations based on various touch events.


      addTarget(self, action: #selector(touchDown), for: [.touchDown, .touchDragEnter])
      addTarget(self, action: #selector(touchUp), for: [.touchUpInside, .touchDragExit, .touchCancel]


      @objc private func touchDown() {
        animator.stopAnimation(true)
        backgroundColor = delegate.highlightColor.background
        setTitleColor(delegate.highlightColor.foreground, for: .normal)
        setTitleColor(delegate.highlightColor.foreground, for: .highlighted)
      }
     
      @objc private func touchUp() {
        animator = UIViewPropertyAnimator(duration: 0.5, curve: .easeOut, animations: {
          self.backgroundColor = self.buttonColor.background
          self.setTitleColor(self.buttonColor.foreground, for: .normal)
        })
        animator.startAnimation()
      }


Everything works fine for most of my buttons except for a few where I have to do some custom drawing by using draw(rect). It would appear that you can't use draw(rect) if animating things like backgroundColor.


Any clue how to get this to work?

Why do you stop animation in touchDown and start in touchUp ? Shouldn't it be the reverse ?


Could you post your complete UIButton class definition ?

Yes, you can animate background. The code above works fine as long as I don't override draw(rect). This was inspired by Apple's WWDC18 talk on "Designing Fluid Interfaces". The example video shows the animation on Apple's iPhone calculator button.


import UIKit

typealias ButtonColor = (foreground:UIColor, background:UIColor)

protocol CalculatorButtonAttributes {
  
  var borderWidth: CGFloat { get }
  var borderColor: UIColor { get }
  
  var spacing: (dx:CGFloat,dy:CGFloat) { get }

  func cornerRound( for size: CGSize) -> CGFloat
  
  var digitColor: ButtonColor  { get }
  
  var equalColor: ButtonColor { get }
  
  var functionColor: ButtonColor { get }

  var editColor: ButtonColor { get }

  var operatorColor: ButtonColor { get }
  
  var highlightColor: ButtonColor { get }
}

protocol CalculatorButtonTitleAttributes {
  func range(for tag:Int) -> NSRange?

  func attributedTitle(with pointSize:CGFloat, for tag:Int, and foregroundColor: UIColor) -> NSAttributedString
  
  var animationTime: TimeInterval { get }
  
  var scriptFontScale: CGFloat { get }

  func pointSize( for tag:Int) -> CGFloat
}

typealias CalculatorButtonData = CalculatorButtonAttributes & CalculatorButtonTitleAttributes

@objcMembers public class CalculatorButton: UIButton {

  static var buttonDataDelegate: CalculatorButtonData!
  
  private var animator = UIViewPropertyAnimator()
  
  internal var delegate: CalculatorButtonData {
    guard let delegate = CalculatorButton.buttonDataDelegate else {
      fatalError("CalculatorButtonData delegate hasn't been defined. Use initDelegate()")
    }
    
    return delegate
  }

  @objc class func initDelegate(_ delegate:Any) {
    CalculatorButton.buttonDataDelegate = delegate as? CalculatorButtonData
  }
  
  public override init(frame: CGRect) {
    super.init(frame: frame)
    sharedInit()
  }
  
  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    sharedInit()
  }
  
  private func sharedInit() {
    layer.masksToBounds = true
    
    addTarget(self, action: #selector(touchDown), for: [.touchDown, .touchDragEnter])
    addTarget(self, action: #selector(touchUp), for: [.touchUpInside, .touchDragExit, .touchCancel])

    dPrint("sharedInit: \(description)")
  }
  
  @objc private func touchDown() {
    animator.stopAnimation(true)
    backgroundColor = delegate.highlightColor.background
    setTitleColor(delegate.highlightColor.foreground, for: .normal)
    setTitleColor(delegate.highlightColor.foreground, for: .highlighted)
    dPrint("animator stop: \(description)")
  }
  
  @objc private func touchUp() {
    animator = UIViewPropertyAnimator(duration: 1.5, curve: .easeOut, animations: {
      self.backgroundColor = self.buttonColor.background
      self.setTitleColor(self.buttonColor.foreground, for: .normal)
      dPrint("animator start: \(self.description)")
    })
    animator.startAnimation()
  }

  /// This method applies general theme properties to buttons. The properties are provided via the CalculatorButtonData delegate.
  ///
  /// *Note*: The source frame is inset and cornerRadius applied; so, typically, this should be called with an initial frame; as oppose
  /// to one that has already been inset.
  func applyThemeProperties() {
    layer.borderWidth = delegate.borderWidth
    layer.borderColor = delegate.borderColor.cgColor
    
    layer.borderColor = delegate.borderColor.cgColor // [appTheme.buttonBorderColor CGColor];
    
    layer.borderWidth = delegate.borderWidth
    //    frame = frame.insetBy(dx: appTheme.buttonInset.left + appTheme.buttonInset.right, dy: appTheme.buttonInset.top + appTheme.buttonInset.bottom)
    frame = frame.insetBy(dx: delegate.spacing.dx, dy: delegate.spacing.dy)
    layer.cornerRadius = delegate.cornerRound(for: frame.size)
    
    backgroundColor = buttonColor.background
    titleLabel?.textColor = buttonColor.foreground
  }
  
  public override var tag: Int {
    get {
      return super.tag
    }
    set {
      super.tag = newValue
      refresh()
    }
  }
  
  func refresh() {
    superscriptRange = delegate.range(for: tag)
    setTitleColor(buttonColor.foreground, for: .normal)
    setTitleColor(buttonColor.foreground, for: .highlighted)
//    setTitleColor(delegate.highlightColor.foreground, for: .highlighted)
    backgroundColor = buttonColor.background
    applyThemeProperties()
  }

}

extension CalculatorButton {
  open override var description: String {
    let buttonTitle = title(for: .normal) ?? "undefined"
    let accLabel = accessibilityLabel ?? "undefined"
    return "CalculatorButton: " + "Tag: \(tag) Title: '\(buttonTitle)' accLabel '\(accLabel)'"
  }
}

Its all somewhat experimental so documentation is a practically nothing. Most of the properties come from delegates which makes theming a bit easier; of course, you can substitue hard-coded values if you intend to run the code. All buttons are created in IB. If I subclass CalculatorButton and add even an empty draw(rect) my touch event targets get called but the animator does nothing.


I've seen other implimentations use a UIControl with a UILabel for button text. It would be slighly easier for me to stay with a UIButton. But the draw(rect) issue may also exist with UIControl implementation.

Thanks. But I get a few errors: dPrint does not exist (probably will replace by just a print).


In several places as

self.backgroundColor = self.buttonColor.background


get error : Value of type 'CalculatorButton' has no member 'buttonColor'

Sorry. I tried to strip out a few things from frameworks that I use. You're right, dPrint() just replaces print().


buttonColor is computed property that determines a button's color based on its type: digit, operator, function, etc.


Easiest thing to do is to replace it some hard code values:


var buttonColor: ButtonColor { return (UIColor.black, UIColor.yellow) }


This should default to black foreground and a yellow background.


You can also hard-code the values in touchDown()


@objc privatefunc touchDown() {
    animator.stopAnimation(true)
    backgroundColor = UIColor.white
//    setTitleColor(UIColor.blue, for: .normal)
//    setTitleColor(UIColor.blue, for: .highlighted)
    dPrint("animator stop: \(self.description)")
  }


Background will turn white and slowly animate back to the original yellow.


Hopefully, that's it.

So it compiles now ; but there are many protocol properties to implement in the class. Too much in fact.


Could you show how and where you set all the delegates involved in thebutton class ?

You're right ... you shouldna't have to deal with that...


https://github.com/Phantom-59/AnimatorTest


I've created a public repo with a complete project. The animator should work as is. Uncomment out draw(rect:) to see my original point tht overriding draw(rect) prvents the animator from working.

BTW, I realized I didn't answer a question you had above as to why the animator starts on touchUp. The reason is that I want the animation effect to be seeing when the user lifts the finger from the button. If for whatever reason they drag away from the button then there's no touchUp and thus no animation. Also, a short animation on TouchDown (as I intend) could be missed.


Is the problem I'm seeing just an animatot limitation? Is there a way to get it to run when overriding draw(rect:)?

Animator with draw(rect)
 
 
Q