This tutorial will show you how to create your own custom badge and display on any UIView
.
You can build a simple badge, using UIKit controls and Auto Layout just like this.
ViewController.swift
import UIKit
class ViewController1: UIViewController {
let label = UILabel()
let button = UIButton()
let diameter: CGFloat = 30
override func viewDidLoad() {
super.viewDidLoad()
label.translatesAutoresizingMaskIntoConstraints = false
label.layer.cornerRadius = diameter / 2
label.backgroundColor = .systemRed
label.textAlignment = .center
label.textColor = .white
label.clipsToBounds = true
label.text = "99" // Good for 2 digits
button.translatesAutoresizingMaskIntoConstraints = false
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4)
button.layer.cornerRadius = diameter / 2
button.setTitleColor(.white, for: .normal)
button.backgroundColor = .systemBlue
button.setTitle("4444", for: .normal)
view.addSubview(label)
view.addSubview(button)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.heightAnchor.constraint(equalToConstant: diameter),
label.widthAnchor.constraint(greaterThanOrEqualToConstant: diameter),
button.topAnchor.constraint(equalToSystemSpacingBelow: label.bottomAnchor, multiplier: 2),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.heightAnchor.constraint(equalToConstant: diameter),
button.widthAnchor.constraint(greaterThanOrEqualToConstant: diameter),
])
}
}
If you only need two digit numbers, UILabel
with a rounded corner radius is all you might need. If you require three or more digits in your badge, try a UIButton
with UIEdgeInsets
as it enables you to add padding on the right and the left.
If you want to get a bit fancier, you can create a protocol
on UIView
and make so that any view can add a badge by going
view.addSubview(imageView)
imageView.addBadge()
ViewController.swift
import UIKit
protocol Badgeable {}
extension UIView: Badgeable {
func addBadge() {
translatesAutoresizingMaskIntoConstraints = false
let diameter: CGFloat = 30
let badge = UIButton()
badge.translatesAutoresizingMaskIntoConstraints = false
badge.contentEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4)
badge.layer.cornerRadius = diameter / 2
badge.setTitleColor(.white, for: .normal)
badge.backgroundColor = .systemBlue
badge.setTitle("4", for: .normal)
addSubview(badge)
NSLayoutConstraint.activate([
badge.heightAnchor.constraint(equalToConstant: diameter),
badge.widthAnchor.constraint(greaterThanOrEqualToConstant: diameter),
badge.leadingAnchor.constraint(equalTo: trailingAnchor, constant: -24),
badge.bottomAnchor.constraint(equalTo: topAnchor, constant: 16),
])
}
}
class ViewController2: UIViewController {
let imageView = UIImageView()
let diameter: CGFloat = 30
override func viewDidLoad() {
super.viewDidLoad()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.image = UIImage(named: "github_color")
imageView.addBadge()
view.addSubview(imageView)
imageView.addBadge()
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
}
If you need animations, and you'd like to reach for something more sophisticated and lower level, you can also solve this problem using Core Graphics and Core Animation by working directly with a UIViews
frame.
Example based on source code of BadgeHub.
ViewController.swift
import UIKit
class ViewController: UIViewController {
private var hub: BadgeHub?
private lazy var imageView: UIImageView = {
let iv = UIImageView()
iv.frame = CGRect(x: view.frame.size.width / 2 - 48,
y: 80, width: 96, height: 96)
iv.image = UIImage(named: "github_color")
return iv
}()
override func viewDidLoad() {
super.viewDidLoad()
setupImageView()
}
private func setupImageView() {
hub = BadgeHub(view: imageView)
hub?.setCount(200)
view.addSubview(imageView)
}
}
You basically create the view you want to add the badge to (in this case a UIImage
). And then you add the badge to the view by passing it into this object called BadgeHub
and it takes care of the rest.
It will look at the frame of the view you pass it, create a red circle and label offset up and to the right, and that's basically it.
The circle is placed in the upper-righthand-corner by taking the frame of the passed in view, and creating a new view for the circle
fileprivate class BadgeView: UIView {
func setBackgroundColor(_ backgroundColor: UIColor?) {
super.backgroundColor = backgroundColor
}
}
redCircle = BadgeView()
redCircle.backgroundColor = UIColor.red
And then setting that view's frame up and to the right by calculating a new x y coordinate.
let atFrame = CGRect(x: (frame?.size.width ?? 0.0) - ((Constants.notificHubDefaultDiameter) * 2 / 3),
y: (-Constants.notificHubDefaultDiameter) / 3,
width: CGFloat(Constants.notificHubDefaultDiameter),
height: CGFloat(Constants.notificHubDefaultDiameter))
setCircleAtFrame(atFrame)
The key to understanding this is that if we set the new circular frame to the origin
let atFrame = CGRect(x: 0,
y: 0,
width: ...,
height: ...)
Our badge would appear here.
So in order to move it up and to the right we need take the original frame size, and subtract from it 2/3 of the circles diameter, nudging it just in from the far right.
Here is the full source for just placing the circle.
import UIKit
import QuartzCore
fileprivate class BadgeView: UIView {
func setBackgroundColor(_ backgroundColor: UIColor?) {
super.backgroundColor = backgroundColor
}
}
public class BadgeHub: NSObject {
private var redCircle: BadgeView!
var hubView: UIView?
private struct Constants {
static let notificHubDefaultDiameter: CGFloat = 30
}
public init(view: UIView) {
super.init()
setView(view)
}
public func setView(_ view: UIView?) {
let frame: CGRect? = view?.frame
redCircle = BadgeView()
redCircle.backgroundColor = UIColor.red
let atFrame = CGRect(x: (frame?.size.width ?? 0.0) - ((Constants.notificHubDefaultDiameter) * 2 / 3),
y: (-Constants.notificHubDefaultDiameter) / 3,
width: CGFloat(Constants.notificHubDefaultDiameter),
height: CGFloat(Constants.notificHubDefaultDiameter))
setCircleAtFrame(atFrame)
view?.addSubview(redCircle)
view?.bringSubviewToFront(redCircle)
hubView = view
}
/// Set the frame of the notification circle relative to the view.
public func setCircleAtFrame(_ frame: CGRect) {
redCircle.frame = frame
redCircle.layer.cornerRadius = frame.size.height / 2
}
}
We can add a number to the badge by creating a UILabel
private var countLabel: UILabel? {
didSet {
countLabel?.text = "\(count)"
checkZero()
}
}
and then setting the label's frame to be the same as the badge.
public func setView(_ view: UIView?, andCount startCount: Int) {
// ...
let frame: CGRect? = view?.frame
redCircle = BadgeView()
countLabel = UILabel(frame: redCircle.frame)
countLabel?.textAlignment = .center
countLabel?.textColor = UIColor.white
countLabel?.backgroundColor = UIColor.clear
// ...
}
and then setting its frame to that of the badge.
So long as the label contains one or two digits, the circle around the label looks OK.
It's when we get to the third order of magnitude number or more that we need to adjust the width and x coordinate of our circle frame.
We can do that, by recalculating the width of the badge based on the number of digit's, as well as adjust its x-coordinate.
/// Resize the badge to fit the current digits.
/// This method is called everytime count value is changed.
func resizeToFitDigits() {
guard count > 0 else { return }
var orderOfMagnitude: Int = Int(log10(Double(count)))
orderOfMagnitude = (orderOfMagnitude >= 2) ? orderOfMagnitude : 1
var frame = initialFrame
let newFrameWidth = CGFloat(initialFrame.size.width * (1 + 0.3 * CGFloat(orderOfMagnitude - 1)))
frame.size.width = newFrameWidth
frame.origin.x = initialFrame.origin.x - (newFrameWidth - initialFrame.size.width) / 2
redCircle.frame = frame
countLabel?.frame = redCircle.frame
baseFrame = frame
curOrderMagnitude = orderOfMagnitude
}
ViewController.swift
import UIKit
class ViewController: UIViewController {
private var hub: BadgeHub?
private lazy var imageView: UIImageView = {
let iv = UIImageView()
iv.frame = CGRect(x: view.frame.size.width / 2 - 48,
y: 80, width: 96, height: 96)
iv.image = UIImage(named: "github_color")
return iv
}()
override func viewDidLoad() {
super.viewDidLoad()
setupImageView()
}
private func setupImageView() {
hub = BadgeHub(view: imageView)
hub?.setCount(99999)
view.addSubview(imageView)
}
}
BadgeHub.swift
import UIKit
import QuartzCore
fileprivate class BadgeView: UIView {
func setBackgroundColor(_ backgroundColor: UIColor?) {
super.backgroundColor = backgroundColor
}
}
public class BadgeHub: NSObject {
private var curOrderMagnitude: Int = 0
private var redCircle: BadgeView!
private var baseFrame = CGRect.zero
private var initialFrame = CGRect.zero
var hubView: UIView?
private struct Constants {
static let notificHubDefaultDiameter: CGFloat = 30
static let countMagnitudeAdaptationRatio: CGFloat = 0.3
}
var count: Int = 0 {
didSet {
countLabel?.text = "\(count)"
checkZero()
resizeToFitDigits()
}
}
private var countLabel: UILabel? {
didSet {
countLabel?.text = "\(count)"
checkZero()
}
}
public init(view: UIView) {
super.init()
setView(view, andCount: 0)
}
public func setView(_ view: UIView?, andCount startCount: Int) {
curOrderMagnitude = 0
let frame: CGRect? = view?.frame
redCircle = BadgeView()
redCircle?.isUserInteractionEnabled = false
redCircle.backgroundColor = UIColor.red
countLabel = UILabel(frame: redCircle.frame)
countLabel?.isUserInteractionEnabled = false
count = startCount
countLabel?.textAlignment = .center
countLabel?.textColor = UIColor.white
countLabel?.backgroundColor = UIColor.clear
let atFrame = CGRect(x: (frame?.size.width ?? 0.0) - ((Constants.notificHubDefaultDiameter) * 2 / 3),
y: (-Constants.notificHubDefaultDiameter) / 3,
width: CGFloat(Constants.notificHubDefaultDiameter),
height: CGFloat(Constants.notificHubDefaultDiameter))
setCircleAtFrame(atFrame)
view?.addSubview(redCircle)
view?.addSubview(countLabel!)
view?.bringSubviewToFront(redCircle)
view?.bringSubviewToFront(countLabel!)
hubView = view
checkZero()
}
/// Set the frame of the notification circle relative to the view.
public func setCircleAtFrame(_ frame: CGRect) {
redCircle.frame = frame
baseFrame = frame
initialFrame = frame
countLabel?.frame = redCircle.frame
redCircle.layer.cornerRadius = frame.size.height / 2
countLabel?.font = UIFont.systemFont(ofSize: frame.size.width / 2)
}
public func moveCircleBy(x: CGFloat, y: CGFloat) {
var frame: CGRect = redCircle.frame
frame.origin.x += x
frame.origin.y += y
self.setCircleAtFrame(frame)
}
public func setCount(_ newCount: Int) {
self.count = newCount
countLabel?.text = "\(count)"
checkZero()
}
public func checkZero() {
if count <= 0 {
redCircle.isHidden = true
countLabel?.isHidden = true
} else {
redCircle.isHidden = false
countLabel?.isHidden = false
}
}
/// Resize the badge to fit the current digits.
/// This method is called everytime count value is changed.
func resizeToFitDigits() {
guard count > 0 else { return }
var orderOfMagnitude: Int = Int(log10(Double(count)))
orderOfMagnitude = (orderOfMagnitude >= 2) ? orderOfMagnitude : 1
var frame = initialFrame
let newFrameWidth = CGFloat(initialFrame.size.width * (1 + 0.3 * CGFloat(orderOfMagnitude - 1)))
frame.size.width = newFrameWidth
frame.origin.x = initialFrame.origin.x - (newFrameWidth - initialFrame.size.width) / 2
redCircle.frame = frame
countLabel?.frame = redCircle.frame
baseFrame = frame
curOrderMagnitude = orderOfMagnitude
}
}
Animations can be added to badges using Core Animation along with Core Graphics.
BadgeHub.swift
public func pop() {
let height = baseFrame.size.height
let width = baseFrame.size.width
let popStartHeight: Float = Float(height * Constants.popStartRatio)
let popStartWidth: Float = Float(width * Constants.popStartRatio)
let timeStart: Float = 0.05
let popOutHeight: Float = Float(height * Constants.popOutRatio)
let popOutWidth: Float = Float(width * Constants.popOutRatio)
let timeOut: Float = 0.2
let popInHeight: Float = Float(height * Constants.popInRatio)
let popInWidth: Float = Float(width * Constants.popInRatio)
let timeIn: Float = 0.05
let popEndHeight: Float = Float(height)
let popEndWidth: Float = Float(width)
let timeEnd: Float = 0.05
let startSize = CABasicAnimation(keyPath: "cornerRadius")
startSize.duration = CFTimeInterval(timeStart)
startSize.beginTime = 0
startSize.fromValue = NSNumber(value: popEndHeight / 2)
startSize.toValue = NSNumber(value: popStartHeight / 2)
startSize.isRemovedOnCompletion = false
let outSize = CABasicAnimation(keyPath: "cornerRadius")
outSize.duration = CFTimeInterval(timeOut)
outSize.beginTime = CFTimeInterval(timeStart)
outSize.fromValue = startSize.toValue
outSize.toValue = NSNumber(value: popOutHeight / 2)
outSize.isRemovedOnCompletion = false
let inSize = CABasicAnimation(keyPath: "cornerRadius")
inSize.duration = CFTimeInterval(timeIn)
inSize.beginTime = CFTimeInterval(timeStart + timeOut)
inSize.fromValue = outSize.toValue
inSize.toValue = NSNumber(value: popInHeight / 2)
inSize.isRemovedOnCompletion = false
let endSize = CABasicAnimation(keyPath: "cornerRadius")
endSize.duration = CFTimeInterval(timeEnd)
endSize.beginTime = CFTimeInterval(timeIn + timeOut + timeStart)
endSize.fromValue = inSize.toValue
endSize.toValue = NSNumber(value: popEndHeight / 2)
endSize.isRemovedOnCompletion = false
let group = CAAnimationGroup()
group.duration = CFTimeInterval(timeStart + timeOut + timeIn + timeEnd)
group.animations = [startSize, outSize, inSize, endSize]
redCircle.layer.add(group, forKey: nil)
UIView.animate(withDuration: TimeInterval(timeStart), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popStartHeight)
frame.size.width = CGFloat(popStartWidth)
self.redCircle.frame = frame
self.redCircle.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeOut), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popOutHeight)
frame.size.width = CGFloat(popOutWidth)
self.redCircle.frame = frame
self.redCircle.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeIn), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popInHeight)
frame.size.width = CGFloat(popInWidth)
self.redCircle.frame = frame
self.redCircle.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeEnd), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popEndHeight)
frame.size.width = CGFloat(popEndWidth)
self.redCircle.frame = frame
self.redCircle.center = center
})
}
}
}
}
BadgeHub.swift
/// Animation that jumps similar to macOS dock icons.
public func bump() {
if !initialCenter.equalTo(redCircle.center) {
// cancel previous animation
}
bumpCenterY(yVal: 0)
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds), animations: {
self.bumpCenterY(yVal: Constants.firstBumpDistance)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds), animations: {
self.bumpCenterY(yVal: 0)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds2), animations: {
self.bumpCenterY(yVal: Constants.secondBumpDist)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds2), animations: {
self.bumpCenterY(yVal: 0)
})
}
}
}
}
ViewController.swift
import UIKit
class ViewController2: UIViewController {
private var hub: BadgeHub?
private lazy var imageView: UIImageView = {
let iv = UIImageView()
iv.frame = CGRect(x: view.frame.size.width / 2 - 48,
y: 80, width: 96, height: 96)
iv.image = UIImage(named: "github_color")
return iv
}()
private lazy var decrementButton: UIButton = {
let button = UIButton(type: .system)
button.frame = CGRect(x: 16, y: 200, width: 130, height: 44)
button.setTitle("Decrement (-1)", for: .normal)
button.backgroundColor = .white
button.layer.cornerRadius = 8
button.setTitleColor(.black, for: .normal)
return button
}()
private lazy var incrementButton: UIButton = {
let button = UIButton(type: .system)
button.frame = CGRect(x: UIScreen.main.bounds.width - 16 - 130,
y: 200, width: 130, height: 44)
button.setTitle("Increment (+1)", for: .normal)
button.backgroundColor = .white
button.layer.cornerRadius = 8
button.setTitleColor(.black, for: .normal)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
setupButtons()
setupImageView()
}
private func setupButtons() {
view.addSubview(incrementButton)
view.addSubview(decrementButton)
incrementButton.addTarget(self,
action: #selector(self.testIncrement),
for: .touchUpInside)
decrementButton.addTarget(self,
action: #selector(self.testDecrement),
for: .touchUpInside)
}
private func setupImageView() {
hub = BadgeHub(view: imageView)
hub?.moveCircleBy(x: -15, y: 15)
view.addSubview(imageView)
}
@objc private func testIncrement() {
hub?.increment()
hub?.pop()
// hub?.bump()
}
@objc private func testDecrement() {
hub?.decrement()
}
}
BadgeHub.swift
import UIKit
import QuartzCore
fileprivate class BadgeView: UIView {
func setBackgroundColor(_ backgroundColor: UIColor?) {
super.backgroundColor = backgroundColor
}
}
public class BadgeHub: NSObject {
private var curOrderMagnitude: Int = 0
private var redCircle: BadgeView!
private var initialCenter = CGPoint.zero
private var baseFrame = CGRect.zero
private var initialFrame = CGRect.zero
var hubView: UIView?
private struct Constants {
static let notificHubDefaultDiameter: CGFloat = 30
static let countMagnitudeAdaptationRatio: CGFloat = 0.3
// Pop values
static let popStartRatio: CGFloat = 0.85
static let popOutRatio: CGFloat = 1.05
static let popInRatio: CGFloat = 0.95
// Blink values
static let blinkDuration: CGFloat = 0.1
static let blinkAlpha: CGFloat = 0.1
// Bump values
static let firstBumpDistance: CGFloat = 8.0
static let bumpTimeSeconds: CGFloat = 0.13
static let secondBumpDist: CGFloat = 4.0
static let bumpTimeSeconds2: CGFloat = 0.1
}
var count: Int = 0 {
didSet {
countLabel?.text = "\(count)"
checkZero()
resizeToFitDigits()
}
}
private var countLabel: UILabel? {
didSet {
countLabel?.text = "\(count)"
checkZero()
}
}
public init(view: UIView) {
super.init()
setView(view, andCount: 0)
}
public func setView(_ view: UIView?, andCount startCount: Int) {
curOrderMagnitude = 0
let frame: CGRect? = view?.frame
redCircle = BadgeView()
redCircle?.isUserInteractionEnabled = false
redCircle.backgroundColor = UIColor.red
countLabel = UILabel(frame: redCircle.frame)
countLabel?.isUserInteractionEnabled = false
count = startCount
countLabel?.textAlignment = .center
countLabel?.textColor = UIColor.white
countLabel?.backgroundColor = UIColor.clear
let atFrame = CGRect(x: (frame?.size.width ?? 0.0) - ((Constants.notificHubDefaultDiameter) * 2 / 3),
y: (-Constants.notificHubDefaultDiameter) / 3,
width: CGFloat(Constants.notificHubDefaultDiameter),
height: CGFloat(Constants.notificHubDefaultDiameter))
setCircleAtFrame(atFrame)
view?.addSubview(redCircle)
view?.addSubview(countLabel!)
view?.bringSubviewToFront(redCircle)
view?.bringSubviewToFront(countLabel!)
hubView = view
checkZero()
}
/// Set the frame of the notification circle relative to the view.
public func setCircleAtFrame(_ frame: CGRect) {
redCircle.frame = frame
baseFrame = frame
initialFrame = frame
countLabel?.frame = redCircle.frame
redCircle.layer.cornerRadius = frame.size.height / 2
countLabel?.font = UIFont.systemFont(ofSize: frame.size.width / 2)
}
public func moveCircleBy(x: CGFloat, y: CGFloat) {
var frame: CGRect = redCircle.frame
frame.origin.x += x
frame.origin.y += y
self.setCircleAtFrame(frame)
}
public func setCount(_ newCount: Int) {
self.count = newCount
countLabel?.text = "\(count)"
checkZero()
}
public func checkZero() {
if count <= 0 {
redCircle.isHidden = true
countLabel?.isHidden = true
} else {
redCircle.isHidden = false
countLabel?.isHidden = false
}
}
/// Resize the badge to fit the current digits.
/// This method is called everytime count value is changed.
func resizeToFitDigits() {
guard count > 0 else { return }
var orderOfMagnitude: Int = Int(log10(Double(count)))
orderOfMagnitude = (orderOfMagnitude >= 2) ? orderOfMagnitude : 1
var frame = initialFrame
print("frame before: \(frame)")
frame.size.width = CGFloat(initialFrame.size.width * (1 + 0.3 * CGFloat(orderOfMagnitude - 1)))
frame.origin.x = initialFrame.origin.x - (frame.size.width - initialFrame.size.width) / 2
print("frame after: \(frame)")
redCircle.frame = frame
baseFrame = frame
countLabel?.frame = redCircle.frame
curOrderMagnitude = orderOfMagnitude
}
func setAlpha(alpha: CGFloat) {
redCircle.alpha = alpha
countLabel?.alpha = alpha
}
/// Bump badge up or down.
/// - Parameter yVal: `Y` coordinate for bumps.
func bumpCenterY(yVal: CGFloat) {
var center: CGPoint = redCircle.center
center.y = initialCenter.y - yVal
redCircle.center = center
countLabel?.center = center
}
func increment() {
increment(by: 1)
}
func increment(by amount: Int) {
count += amount
}
func decrement() {
decrement(by: 1)
}
func decrement(by amount: Int) {
if amount >= count {
count = 0
return
}
count -= amount
checkZero()
}
public func pop() {
let height = baseFrame.size.height
let width = baseFrame.size.width
let popStartHeight: Float = Float(height * Constants.popStartRatio)
let popStartWidth: Float = Float(width * Constants.popStartRatio)
let timeStart: Float = 0.05
let popOutHeight: Float = Float(height * Constants.popOutRatio)
let popOutWidth: Float = Float(width * Constants.popOutRatio)
let timeOut: Float = 0.2
let popInHeight: Float = Float(height * Constants.popInRatio)
let popInWidth: Float = Float(width * Constants.popInRatio)
let timeIn: Float = 0.05
let popEndHeight: Float = Float(height)
let popEndWidth: Float = Float(width)
let timeEnd: Float = 0.05
let startSize = CABasicAnimation(keyPath: "cornerRadius")
startSize.duration = CFTimeInterval(timeStart)
startSize.beginTime = 0
startSize.fromValue = NSNumber(value: popEndHeight / 2)
startSize.toValue = NSNumber(value: popStartHeight / 2)
startSize.isRemovedOnCompletion = false
let outSize = CABasicAnimation(keyPath: "cornerRadius")
outSize.duration = CFTimeInterval(timeOut)
outSize.beginTime = CFTimeInterval(timeStart)
outSize.fromValue = startSize.toValue
outSize.toValue = NSNumber(value: popOutHeight / 2)
outSize.isRemovedOnCompletion = false
let inSize = CABasicAnimation(keyPath: "cornerRadius")
inSize.duration = CFTimeInterval(timeIn)
inSize.beginTime = CFTimeInterval(timeStart + timeOut)
inSize.fromValue = outSize.toValue
inSize.toValue = NSNumber(value: popInHeight / 2)
inSize.isRemovedOnCompletion = false
let endSize = CABasicAnimation(keyPath: "cornerRadius")
endSize.duration = CFTimeInterval(timeEnd)
endSize.beginTime = CFTimeInterval(timeIn + timeOut + timeStart)
endSize.fromValue = inSize.toValue
endSize.toValue = NSNumber(value: popEndHeight / 2)
endSize.isRemovedOnCompletion = false
let group = CAAnimationGroup()
group.duration = CFTimeInterval(timeStart + timeOut + timeIn + timeEnd)
group.animations = [startSize, outSize, inSize, endSize]
redCircle.layer.add(group, forKey: nil)
UIView.animate(withDuration: TimeInterval(timeStart), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popStartHeight)
frame.size.width = CGFloat(popStartWidth)
self.redCircle.frame = frame
self.redCircle.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeOut), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popOutHeight)
frame.size.width = CGFloat(popOutWidth)
self.redCircle.frame = frame
self.redCircle.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeIn), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popInHeight)
frame.size.width = CGFloat(popInWidth)
self.redCircle.frame = frame
self.redCircle.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeEnd), animations: {
var frame: CGRect = self.redCircle.frame
let center: CGPoint = self.redCircle.center
frame.size.height = CGFloat(popEndHeight)
frame.size.width = CGFloat(popEndWidth)
self.redCircle.frame = frame
self.redCircle.center = center
})
}
}
}
}
/// Animation that jumps similar to macOS dock icons.
public func bump() {
if !initialCenter.equalTo(redCircle.center) {
// cancel previous animation
}
bumpCenterY(yVal: 0)
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds), animations: {
self.bumpCenterY(yVal: Constants.firstBumpDistance)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds), animations: {
self.bumpCenterY(yVal: 0)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds2), animations: {
self.bumpCenterY(yVal: Constants.secondBumpDist)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds2), animations: {
self.bumpCenterY(yVal: 0)
})
}
}
}
}
}
Here is a simple working example of a custom bade view with some animations.
BadgeView.swift
import Foundation
import UIKit
protocol BadgeViewDelegate: AnyObject {
func didTapBadge(_ sender: BadgeView)
}
class BadgeView: UIView {
let button = UIButton()
let imageView = UIImageView()
weak var delegate: BadgeViewDelegate?
let diameter: CGFloat = 16 // 30
override init(frame: CGRect) {
super.init(frame: frame)
style()
layout()
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
return CGSize(width: 44, height: 30)
}
}
extension BadgeView {
func style() {
translatesAutoresizingMaskIntoConstraints = false
button.translatesAutoresizingMaskIntoConstraints = false
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4)
button.layer.cornerRadius = diameter / 2
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 13)
button.backgroundColor = .systemRed
button.setTitle("44", for: .normal)
button.isUserInteractionEnabled = true
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .primaryActionTriggered)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.image = UIImage(systemName: "bell.fill")
}
func layout() {
addSubview(imageView)
addSubview(button)
NSLayoutConstraint.activate([
button.heightAnchor.constraint(equalToConstant: diameter),
button.widthAnchor.constraint(greaterThanOrEqualToConstant: diameter),
imageView.heightAnchor.constraint(equalToConstant: 24),
imageView.widthAnchor.constraint(equalToConstant: 24),
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
button.topAnchor.constraint(equalTo: imageView.topAnchor, constant: -8),
button.trailingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8),
])
}
func setup() {
let singleTap = UITapGestureRecognizer(target: self, action: #selector(imageViewTapped(_: )))
imageView.addGestureRecognizer(singleTap)
imageView.isUserInteractionEnabled = true
}
@objc func imageViewTapped(_ recognizer: UITapGestureRecognizer) {
if(recognizer.state == UIGestureRecognizer.State.ended){
print("Image tapped")
delegate?.didTapBadge(self)
}
}
}
// MARK: - Actions
extension BadgeView {
@objc func buttonTapped(_ sender: UIButton) {
print("Button tapped")
delegate?.didTapBadge(self)
}
}
ViewController.swift
import UIKit
class ViewController: UIViewController {
let stackView = UIStackView()
let badgeView = BadgeView()
let bumpButton = makeButton(title: "Bump")
let popButton = makeButton(title: "Pop")
let shakeButton = makeButton(title: "Shake")
// Animations
private var baseFrame = CGRect.zero
private var initialFrame = CGRect.zero
private var initialCenter = CGPoint(x: 22, y: 15)
private struct Constants {
static let notificHubDefaultDiameter: CGFloat = 30
static let countMagnitudeAdaptationRatio: CGFloat = 0.3
// Pop values
static let popStartRatio: CGFloat = 0.85
static let popOutRatio: CGFloat = 1.05
static let popInRatio: CGFloat = 0.95
// Blink values
static let blinkDuration: CGFloat = 0.1
static let blinkAlpha: CGFloat = 0.1
// Bump values
static let firstBumpDistance: CGFloat = 8.0
static let bumpTimeSeconds: CGFloat = 0.13
static let secondBumpDist: CGFloat = 4.0
static let bumpTimeSeconds2: CGFloat = 0.1
}
override func viewDidLoad() {
super.viewDidLoad()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
badgeView.translatesAutoresizingMaskIntoConstraints = false
badgeView.delegate = self
bumpButton.addTarget(self, action: #selector(bumpTapped), for: .primaryActionTriggered)
popButton.addTarget(self, action: #selector(popTapped), for: .primaryActionTriggered)
shakeButton.addTarget(self, action: #selector(shakeTapped), for: .primaryActionTriggered)
stackView.addArrangedSubview(badgeView)
stackView.addArrangedSubview(bumpButton)
stackView.addArrangedSubview(popButton)
stackView.addArrangedSubview(shakeButton)
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
badgeView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
baseFrame = badgeView.frame
initialFrame = badgeView.frame
}
}
// MARK: - Actions
extension ViewController {
@objc func bumpTapped(_ sender: UIButton) {
bump()
}
@objc func popTapped(_ sender: UIButton) {
pop()
}
@objc func shakeTapped(_ sender: UIButton) {
shake()
}
}
extension ViewController: BadgeViewDelegate {
func didTapBadge(_ sender: BadgeView) {
print("ViewController tapped")
}
}
func makeButton(title: String) -> UIButton {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle(title, for: .normal)
button.setTitleColor(.systemBlue, for: .normal)
return button
}
// MARK: - Animations
extension ViewController {
func bumpCenterY(yVal: CGFloat) {
var center: CGPoint = badgeView.center
center.y = initialCenter.y - yVal
badgeView.center = center
}
func bump() {
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds), animations: {
self.bumpCenterY(yVal: Constants.firstBumpDistance)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds), animations: {
self.bumpCenterY(yVal: 0)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds2), animations: {
self.bumpCenterY(yVal: Constants.secondBumpDist)
}) { complete in
UIView.animate(withDuration: TimeInterval(Constants.bumpTimeSeconds2), animations: {
self.bumpCenterY(yVal: 0)
})
}
}
}
}
func pop() {
let height = baseFrame.size.height
let width = baseFrame.size.width
let popStartHeight: Float = Float(height * Constants.popStartRatio)
let popStartWidth: Float = Float(width * Constants.popStartRatio)
let timeStart: Float = 0.05
let popOutHeight: Float = Float(height * Constants.popOutRatio)
let popOutWidth: Float = Float(width * Constants.popOutRatio)
let timeOut: Float = 0.2
let popInHeight: Float = Float(height * Constants.popInRatio)
let popInWidth: Float = Float(width * Constants.popInRatio)
let timeIn: Float = 0.05
let popEndHeight: Float = Float(height)
let popEndWidth: Float = Float(width)
let timeEnd: Float = 0.05
let startSize = CABasicAnimation(keyPath: "cornerRadius")
startSize.duration = CFTimeInterval(timeStart)
startSize.beginTime = 0
startSize.fromValue = NSNumber(value: popEndHeight / 2)
startSize.toValue = NSNumber(value: popStartHeight / 2)
startSize.isRemovedOnCompletion = false
let outSize = CABasicAnimation(keyPath: "cornerRadius")
outSize.duration = CFTimeInterval(timeOut)
outSize.beginTime = CFTimeInterval(timeStart)
outSize.fromValue = startSize.toValue
outSize.toValue = NSNumber(value: popOutHeight / 2)
outSize.isRemovedOnCompletion = false
let inSize = CABasicAnimation(keyPath: "cornerRadius")
inSize.duration = CFTimeInterval(timeIn)
inSize.beginTime = CFTimeInterval(timeStart + timeOut)
inSize.fromValue = outSize.toValue
inSize.toValue = NSNumber(value: popInHeight / 2)
inSize.isRemovedOnCompletion = false
let endSize = CABasicAnimation(keyPath: "cornerRadius")
endSize.duration = CFTimeInterval(timeEnd)
endSize.beginTime = CFTimeInterval(timeIn + timeOut + timeStart)
endSize.fromValue = inSize.toValue
endSize.toValue = NSNumber(value: popEndHeight / 2)
endSize.isRemovedOnCompletion = false
let group = CAAnimationGroup()
group.duration = CFTimeInterval(timeStart + timeOut + timeIn + timeEnd)
group.animations = [startSize, outSize, inSize, endSize]
badgeView.layer.add(group, forKey: nil)
UIView.animate(withDuration: TimeInterval(timeStart), animations: {
var frame: CGRect = self.badgeView.frame
let center: CGPoint = self.badgeView.center
frame.size.height = CGFloat(popStartHeight)
frame.size.width = CGFloat(popStartWidth)
self.badgeView.frame = frame
self.badgeView.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeOut), animations: {
var frame: CGRect = self.badgeView.frame
let center: CGPoint = self.badgeView.center
frame.size.height = CGFloat(popOutHeight)
frame.size.width = CGFloat(popOutWidth)
self.badgeView.frame = frame
self.badgeView.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeIn), animations: {
var frame: CGRect = self.badgeView.frame
let center: CGPoint = self.badgeView.center
frame.size.height = CGFloat(popInHeight)
frame.size.width = CGFloat(popInWidth)
self.badgeView.frame = frame
self.badgeView.center = center
}) { complete in
UIView.animate(withDuration: TimeInterval(timeEnd), animations: {
var frame: CGRect = self.badgeView.frame
let center: CGPoint = self.badgeView.center
frame.size.height = CGFloat(popEndHeight)
frame.size.width = CGFloat(popEndWidth)
self.badgeView.frame = frame
self.badgeView.center = center
})
}
}
}
}
func shake() {
let dx = 4
let animation = CAKeyframeAnimation()
animation.keyPath = "position.x"
animation.values = [0, dx, -dx, dx, 0]
animation.keyTimes = [0, 0.16, 0.5, 0.83, 1]
animation.duration = 0.4
animation.isAdditive = true
badgeView.layer.add(animation, forKey: "shake")
}
}