Make your UIButtons come to life

We previously added a shadow to our views and gave them round corners.

At the same time.

Which looks great!

But even when applied to buttons, those still looks pretty 'dead', right?

When we press them, the text slightly dims, an action does or does not take place, and... that's it.

Yeah, that's our button. It looks... goodish. But it's dead - Photo by t j / Unsplash

Even looking at most apps, most interfaces out there don't really seem to be anywhere close to 'coming to life'. Even when comparing them to something as simple as Apple's stock clock app.

Go on, take a minute, and open it.

Take a look at the timer.

Play with it.

Have you noticed how it litteraly makes your phone 'tick', when you spin these wheels? The sound it plays as each number goes by? The slight tap you feel in your hand, telling you something is happening? That's just one example.

Another one?

You probably have the Google Maps app installed. Open it. Switch your map type to 'Default'. See that shadow, at the bottom of the navigation bar, over the map? The one below the blue itinerary button in the bottom right corner of the map? Tap on it, keep your finger on it, and pay attention to what happens to it and it's shadow.

It's not much. But it reacts to what you do. Just likes when UIScrollViews and UITableViews bounce when you scroll past their content's end.

Now. Do you, too, want your interface feel more alive? If so, let's stop ranting, and let's get to work!

Where we left off

First, as a reminder, here's the protocol we came up with last time...

protocol ShadowableRoundableView {
    
    var cornerRadius: CGFloat { get set }
    var shadowColor: UIColor { get set }
    var shadowOffsetWidth: CGFloat { get set }
    var shadowOffsetHeight: CGFloat { get set }
    var shadowOpacity: Float { get set }
    var shadowRadius: CGFloat { get set }
    
    var shadowLayer: CAShapeLayer { get }
    
    func setCornerRadiusAndShadow()
}

extension ShadowableRoundableView where Self: UIView {
    func setCornerRadiusAndShadow() {
        layer.cornerRadius = cornerRadius
        shadowLayer.path = UIBezierPath(roundedRect: bounds,
                                            cornerRadius: cornerRadius ).cgPath
        shadowLayer.fillColor = backgroundColor?.cgColor
        shadowLayer.shadowColor = shadowColor.cgColor
        shadowLayer.shadowPath = shadowLayer.path
        shadowLayer.shadowOffset = CGSize(width: shadowOffsetWidth ,
                                          height: shadowOffsetHeight )
        shadowLayer.shadowOpacity = shadowOpacity 
        shadowLayer.shadowRadius = shadowRadius
    }
}

... and our view's class

import UIKit


@IBDesignable
class OurCustomView: UIView, ShadowableRoundableView {
    
    @IBInspectable var cornerRadius: CGFloat = 0 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    @IBInspectable var shadowColor: UIColor = UIColor.darkGray {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    @IBInspectable var shadowOffsetWidth: CGFloat = 3 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    
    @IBInspectable var shadowOffsetHeight: CGFloat = 3 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    
    @IBInspectable var shadowOpacity: Float = 0.4 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    
    @IBInspectable var shadowRadius: CGFloat = 4 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    private(set) lazy var shadowLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        self.layer.insertSublayer(layer, at: 0)
        self.setNeedsLayout()
        return layer
    }()
    
    override func layoutSubviews() {
        super.layoutSubviews()
        self.setCornerRadiusAndShadow()
    }
}

Now, let's get started, with... bouncing!

Bounce

Photo by Samuel-Elias / Unsplash

First things first. What should our button do when pressed? Behave like it was actually pressed! Ok. But what does it mean?

When we push things and, unlike a wall, they do give way, the get further away. Which makes them appear smaller! So let's do that!

Animate the button's size changing

When we push on something, something's got to give. And usually, it's the thing, not us. Pushed away, it appears smaller! So, if we want our button to appear physical, it needs to shrink when pushed and grow back up when released!

Making the button smaller

When the user taps on the button, the button has to grow smaller, down to a certain minimum size. This can be accomplished by applying a transformation to it, inside an animation, like so:

mutating func onTouchDown() {
    UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { [ weak self] in
        self?.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95)
        self?.layoutIfNeeded()
    })
}

But, just calling this method when the user taps on our button won't do. What will happen? Multiple animations will run at the same time, and the end result won't be what is expected. To solve that, we simply have to remove all animations from before running this one!

mutating func onTouchDown() {
    self.layer.removeAllAnimations()
    UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { [ weak self] in
        self?.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95)
        self?.layoutIfNeeded()
    })
}

Growing back on release

Now, we need to reverse our button's transformation. And the upside is that, sicne we used a CGAffineTransform, we can simply apply the reverse transformation to our button (CGAffineTransform.identity), without needing to store or calculate how to get it back to it's original size! Yes, it's a neat hack.

mutating func onTouchUp() {
    UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { [weak self] in
        self?.transform = CGAffineTransform.identity
        self?.layoutIfNeeded()
    })
}
Back to where we started!

Now, it seems a bit stiff, for a button, doesn't it? Usually, buttons, especially the kind we push, are a bit more... springy, right? The tend to pop back into their original position, right? Because they usually work with an actual spring. Now, our iPhones don't have any spring (that I know of) inside, but that's not going to stop us from giving our buttons their own springs!

mutating func onTouchUp() {
    UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.05, initialSpringVelocity: 1, options: [.allowUserInteraction, .curveEaseOut], animations: { [weak self] in
        self?.transform = CGAffineTransform.identity
        self?.layoutIfNeeded()
    })
}
With a spring!
Now, that's a bad idea. A client once told me the app I had made him looked as if it was on cocaine. Friends don't let their friends make apps that look and feel as if they (the apps) were high. I'm happy to say I have since learned to tone it down. 

(if you want to learn more about CGAffineTransforms, these are great resources and, of course, there's still Apple's own documentation).

Animate the shadow

Now, we are going to change our view's shadow's size.

Why?

Well, let's look at where the light is coming from in our current room. Probably, but not necessarily, the sun. Next, let's take a thick sheet of paper, a notebook, or really just about anything sufficiently big and opaque to cast a shadow. And now we'll put it between the lightsource and the groud (or our desk, as you prefer).

Close to the ground.

Let's slowly bring it closer to the lightsource. Carefully, though! I've been told getting too close to the sun can be dangerous. Kidding aside, what happens to our object's shadow as we move it (the object, not the shadow) away from the ground?

It gets harder to see.

It moves.

And it grows larger.

// INSERT_FANCY_ANIMATION

I'm afraid I'm too lazy for that. But it's really easy to try out it for ourselves in the real world

First, we'll need a way to easily save and re-set our shadow's initial state. My favorite way so far is this one:

lazy var resetProperties: (() -> ()) = self.getResetProperties()

func getResetProperties() -> (() -> ()) {
    let shadowOpacity = self.shadowOpacity
    let shadowOffsetHeight = self.shadowOffsetHeight
    let shadowOffsetWidth = self.shadowOffsetWidth
    let shadowRadius = self.shadowRadius
    return { [weak self] in
        self?.shadowOpacity = shadowOpacity
        self?.shadowOffsetHeight = shadowOffsetHeight
        self?.shadowOffsetWidth = shadowOffsetWidth
        self?.shadowRadius = shadowRadius
    }
}

init() {
    super.init()
    // Other init stuff...
    self.resetProperties()
}

And then we'll just have to call self.resetProperties in our code to get everything back to it's initial state!

Animating the shadow's size

It's quite easy! All we have to do is update our shadow's radius! Let's take our previous onTouchDown function, and add that to it:

mutating func onTouchDown() {
    self.layer.removeAllAnimations()
    let shadowRadius = self.shadowRadius
    UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { [ weak self] in
        self?.shadowRadius = shadowRadius * 0.2
        self?.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95)
        self?.layoutIfNeeded()
    })
}
And behold as the world behind The Button was covered in shadows!

And reverse it on our onTouchUp by calling resetProperties !

mutating func onTouchUp() {
    UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.05, initialSpringVelocity: 1, options: [.allowUserInteraction, .curveEaseOut], animations: { [weak self] in
        self?.resetProperties()
        self?.transform = CGAffineTransform.identity
        self?.layoutIfNeeded()
    })
}
Now, we can't exactly let our buttons go to the Dark Side, right?

Animating the shadow's position

To change the shadow's position, we have to change it's offset from it's parent view. Like so:

mutating func onTouchDown() {
    let shadowOffsetWidth = self.shadowOffsetWidth
    let shadowOffsetHeight = self.shadowOffsetHeight
    let shadowRadius = self.shadowRadius
    self.layer.removeAllAnimations()
    UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { [ weak self] in
        self?.shadowOffsetWidth = shadowOffsetWidth * 0.5
        self?.shadowOffsetHeight = shadowOffsetHeight * 0.5
        self?.shadowRadius = shadowRadius * 0.2
        self?.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95)
        self?.layoutIfNeeded()
    })
}
You can, off course, replace any of the coefficients by something that suits your (or your UI desginer's) tastes better.

How do we reverse it? Well; there's nothing to do! We're already calling resetProperties in our onTouchUp handler, and it already takes care of reverting our shadow's offset to it's original state.

Animating the shadow's opacity

And last, we'll increase the shadow's opacity when our user presses our button.

mutating func onTouchDown() {
    let shadowOffsetWidth = self.shadowOffsetWidth
    let shadowOffsetHeight = self.shadowOffsetHeight
    let shadowRadius = self.shadowRadius
    self.layer.removeAllAnimations()
    UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { [ weak self] in
        self?.shadowOffsetWidth = shadowOffsetWidth * 0.5
        self?.shadowOffsetHeight = shadowOffsetHeight * 0.5
        self?.shadowRadius = shadowRadius * 0.2
        self?.shadowOpacity = 0.8
        self?.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95)
        self?.layoutIfNeeded()
    })
}
You can also, if you prefer, figure out a coefficient to apply to the shadow's opacity. Doesn't have to be hardset value.

Putting it all together

Protocols

We'll introduce a new protocol: AnimatableButton, which will have most of the logic for these animations, and will need to extend ShadowableRoundable view to access our UIButtons' shadows' properties.

protocol ShadowableRoundableView {
    
    var cornerRadius: CGFloat { get set }
    var shadowColor: UIColor { get set }
    var shadowOffsetWidth: CGFloat { get set }
    var shadowOffsetHeight: CGFloat { get set }
    var shadowOpacity: Float { get set }
    var shadowRadius: CGFloat { get set }
    var resetProperties: (() -> ()) { get }
    var shadowLayer: CAShapeLayer { get }
    
    func setCornerRadiusAndShadow()
}

extension ShadowableRoundableView where Self: UIView {

    func setCornerRadiusAndShadow() {
        layer.cornerRadius = cornerRadius
        shadowLayer.path = UIBezierPath(roundedRect: bounds,
                                        cornerRadius: cornerRadius ).cgPath
        shadowLayer.fillColor = backgroundColor?.cgColor
        shadowLayer.shadowColor = shadowColor.cgColor
        shadowLayer.shadowPath = shadowLayer.path
        shadowLayer.shadowOffset = CGSize(width: shadowOffsetWidth ,
                                          height: shadowOffsetHeight )
        shadowLayer.shadowOpacity = shadowOpacity
        shadowLayer.shadowRadius = shadowRadius
    }
}

protocol AnimatableButton: ShadowableRoundableView {
    func getResetProperties() -> (() -> ())
    mutating func onTouchUp()
    mutating func onTouchDown()
}

extension AnimatableButton where Self: UIButton {
    func getResetProperties() -> (() -> ()) {
        let shadowOpacity = self.shadowOpacity
        let shadowOffsetHeight = self.shadowOffsetHeight
        let shadowOffsetWidth = self.shadowOffsetWidth
        let shadowRadius = self.shadowRadius
        return { [weak self] in
            self?.shadowOpacity = shadowOpacity
            self?.shadowOffsetHeight = shadowOffsetHeight
            self?.shadowOffsetWidth = shadowOffsetWidth
            self?.shadowRadius = shadowRadius
        }
    }
    mutating func onTouchUp() {
        UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.05, initialSpringVelocity: 1, options: [.allowUserInteraction, .curveEaseOut], animations: { [weak self] in
            self?.resetProperties()
            self?.transform = CGAffineTransform.identity
            self?.layoutIfNeeded()
        })
    }
        
    mutating func onTouchDown() {
        let shadowOffsetWidth = self.shadowOffsetWidth
        let shadowOffsetHeight = self.shadowOffsetHeight
        let shadowRadius = self.shadowRadius
        self.layer.removeAllAnimations()
        UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { [ weak self] in
            self?.shadowOffsetWidth = shadowOffsetWidth * 0.5
            self?.shadowOffsetHeight = shadowOffsetHeight * 0.5
            self?.shadowRadius = shadowRadius * 0.2
            self?.shadowOpacity = 0.8
            self?.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95)
            self?.layoutIfNeeded()
        })
    }
}
Why protocols? Because we can't inherit from multiple classes, and I'd much rather compose protocols than pile-up class inheritances. Even though it means a bit more boilerplate when conforming multiple custom classes to the same protocols.

Custom UIButton subclass

We also have to subclass UIButton and make it call the right animation methods on the right event. Let's also make it use @IBDesignable and @IBInspectable, even though those rarely render correctly in Xcode's own Storyboards.

@IBDesignable
class OurCustomButton: UIButton, AnimatableButton {
    lazy var resetProperties: (() -> ()) = self.getResetProperties()
    
    
    @IBInspectable var cornerRadius: CGFloat = 0 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    @IBInspectable var shadowColor: UIColor = UIColor.darkGray {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    @IBInspectable var shadowOffsetWidth: CGFloat = 3 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    
    @IBInspectable var shadowOffsetHeight: CGFloat = 3 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    
    @IBInspectable var shadowOpacity: Float = 0.6 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    
    @IBInspectable var shadowRadius: CGFloat = 20 {
        didSet {
            self.setNeedsLayout()
        }
    }
    
    private(set) lazy var shadowLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        self.layer.insertSublayer(layer, at: 0)
        self.setNeedsLayout()
        return layer
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.customInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.customInit()
    }
    
    //  Do custom initialization here
    private func customInit() {
        self.addAction(UIAction(handler: { [weak self] _ in self?.onTouchDown() }), for: .touchDown)
        self.addAction(UIAction(handler: { [weak self] _ in self?.onTouchDown() }), for: .touchDragEnter)
        self.addAction(UIAction(handler: { [weak self] _ in self?.onTouchUp() }), for: .touchDragExit)
        self.addAction(UIAction(handler: { [weak self] _ in self?.onTouchUp() }), for: .touchCancel)
        self.addAction(UIAction(handler: { [weak self] _ in self?.onTouchUp() }), for: .touchUpInside)
        self.addAction(UIAction(handler: { [weak self] _ in self?.onTouchUp() }), for: .touchUpOutside)
        self.resetProperties()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        self.setCornerRadiusAndShadow()
    }
}

Epilogue

Our button is now visually convincing, but could it be even better? Definitely!

By providing making our users actually feel our buttons "clicking" in their hands. That's what we call haptic feedback. Haptic feedback is at least as hold as the original Playstation's DualShock, it's come a long way since, and it is an awesome to communicate to our users what is going on in our.

But that's for a next time; I'm tired, it's more than time to go to bed, and this is quite long to go through already.

And, as usual, should you have any questions, noticed something wrong, know of a better and / or simpler way to get it done, or simply have some feedback, hit me up on Twitter!

Sleep is important. Especially for koalas - Photo by David Clode / Unsplash