How to show error message like Snack Bar in iOS

Issue #472

Build error view

Use convenient code from Omnia

To make view height dynamic, pin UILabel to edges and center

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import UIKit

final class ErrorMessageView: UIView {
let box: UIView = {
let view = UIView()
view.backgroundColor = R.color.primary
view.layer.cornerRadius = 6
return view
}()

let label: UILabel = {
let label = UILabel()
label.styleAsText()
label.textColor = R.color.darkText
label.numberOfLines = 0
return label
}()

override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

required init?(coder: NSCoder) {
fatalError()
}

private func setup() {
addSubviews([box, label])
NSLayoutConstraint.on([
box.pinEdges(view: self, inset: UIEdgeInsets.all(16)),
label.pinEdges(view: box, inset: UIEdgeInsets.all(8))
])

NSLayoutConstraint.on([
box.heightAnchor.constraint(greaterThanOrEqualToConstant: 48)
])

NSLayoutConstraint.on([
label.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
}

Show and hide

Use Auto Layout and basic UIView animation. Use debouncer to avoid hide gets called for the new show. Use debouncer instead of DispatchQueue.main.asyncAfter because it can cancel the previous DispatchWorkItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import UIKit

final class ErrorMessageHandler {
let view: UIView
let errorMessageView = ErrorMessageView()
let debouncer = Debouncer(delay: 0.5)

init(view: UIView) {
self.view = view
}

func show(text: String) {
self.errorMessageView.label.text = text
view.addSubview(errorMessageView)
NSLayoutConstraint.on([
errorMessageView.leftAnchor.constraint(equalTo: view.leftAnchor),
errorMessageView.rightAnchor.constraint(equalTo: view.rightAnchor),
errorMessageView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])

toggle(shows: true)
debouncer.run {
self.hide()
}
}

func hide() {
toggle(shows: false)
}

private func toggle(shows: Bool) {
self.errorMessageView.alpha = shows ? 0 : 1.0
UIView.animate(withDuration: 0.25, animations: {
self.errorMessageView.alpha = shows ? 1.0 : 0
}, completion: { _ in
if shows {
self.view.bringSubviewToFront(self.errorMessageView)
} else {
self.errorMessageView.removeFromSuperview()
}
})
}
}

Handle keyboard

If we add this error message on UIView in ViewController and we use KeyboardHandler to scroll the entire view, then this snack bar will move up as well

1
2
3
4
5
6
7
8
9
10
final class ErrorMessageHandler {
private let errorMessageView = ErrorMessageView()
private var view = UIView()
private var bottomOffset: CGFloat = 0

func on(view: UIView, bottomOffset: CGFloat) {
self.view = view
self.bottomOffset = bottomOffset
}
}

UIView animation completion

One tricky thing is that if we call hide and then show immediately, the completion of hide will be called after and then remove the view.

When we start animation again, the previous animation is not finished, so we need to check

Read UIView.animate

completion
A block object to be executed when the animation sequence ends. This block has no return value and takes a single Boolean argument that indicates whether or not the animations actually finished before the completion handler was called. If the duration of the animation is 0, this block is performed at the beginning of the next run loop cycle. This parameter may be NULL.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private func toggle(shows: Bool) {
self.errorMessageView.alpha = shows ? 0 : 1.0
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: {
self.errorMessageView.alpha = shows ? 1.0 : 0
}, completion: { finished in
if shows {
self.view.bringSubviewToFront(self.errorMessageView)
} else if finished {
self.errorMessageView.removeFromSuperview()
} else {
// No op
}
})
}

Comments