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.
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
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.
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!
(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
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:
And reverse it on our onTouchUp
by calling resetProperties
!
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:
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.
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.
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!