Creating a feedback component
November 15, 2022It is a good practice to provide a visual feedback to user whenever he/she performs an action, whether to inform the operation was performed successfully or an error happened.
I saw a few systems in past projects I worked on but most of them not only required lot of boilerplate code but also tend to expose UI information into business layers such as a ViewModel.
As it is very important to keep a Separation of Concern let's see another way of doing it! We'll do it step by step:
- First by analysing our needs
- Then by building a dumb message UI
- Then by adding logic show it locally in a view
- Finally by making it available to the whole application
Analysing
Before diving into code it is a good thing to first analyse a little bit what we want to achieve.
Our goal is to display messages whenever user perform actions such as when rating a movie.
This is what we know for sure:
- We use an
ObservableObject
as storage - We store domain only attributes inside it
- We want to compute our feedback UI messages from our domain attributes
In other words giving a state:
class GameViewModel: ObservableObject {
@Published var lastUserRating: Int = 0
@Published var error: Error?
}
I want to be able to display "Rate updated! Thank you" or "An error occurred". Meaning whenever $lastUserRating
change we'll display "Rate updated!" while showing "An error occurred" when $error
send a new value.
Sometimes we may also use Result
instead (to avoid being in success and failure at the same time) but end goal should be the same:
class GameViewModel: ObservableObject {
@Published var lastUserRating: Result<Int, Error>?
}
So we'll try to build a component that
- Display a message when a publisher changes
- Determine whether the message should be success or error depending on the type
- Deal with both
Result
and other types
Feedback UI
Let's start by creating a dumb view to displaying a feedback. Its only role is to:
- Show a text
- Add style to the overall text/feedback (font, color, etc...)
/// Decides which kind of message we display (a success or an error)
enum FeedbackType {
case success
case error
}
struct FeedbackView: View {
let message: LocalizedStringKey
let type: FeedbackType
private var backgroundColor: Color {
switch type {
case .success:
return .green
case .error:
return .red
}
}
var body: some View {
Text(message)
.padding()
.background(backgroundColor)
.foregroundColor(.white)
.cornerRadius(8)
.frame(maxWidth: .infinity, minHeight: 40)
.padding(.horizontal, 16)
}
}
Nothing too fancy here except we can display a success/error message. Now let's see how to effectively use it when receiving a value from a Publisher
.
Local feedback
You may wonder why local first? Because we're building block by block 🧱. No need to rush on displaying feedback on global application if we can't even display it on a single screen!
Let's create a view modifier (FeedbackModifier
) listening to a publisher and displaying an overlay feedback on a view:
- The message will be provided by the developer through a closure
- We also introduce a Feedback alias to simplify the closure type
typealias Feedback = (message: LocalizedStringKey, type: FeedbackType)
struct FeedbackModifier<P: Publisher>: ViewModifier where P.Failure == Never {
/// the publisher sending values over time
let publisher: P
/// the feedback message to display to the user
let message: (P.Output) -> Feedback?
/// the actual feedback value we'll display to the user
@State private var feedback: Feedback?
func body(content: Content) -> some View {
content
.overlay(alignment: .top) {
if let feedback {
FeedbackView(message: feedback.message, type: feedback.type)
}
}
.onReceive(publisher) {
feedback = message($0)
}
}
}
Let me explain you what we did:
- We listen to the publisher changes using
onReceive
- Every time we receive a new value, we update our internal state
feedback
- When
feedback
changes, the view is redrawn (thanks to@State
) and we display it if not nil
Our code works with any kind of publisher but is not very practical for the cases we listed at the beginning of the article. Let's add some helper to View
to deal with those cases:
extension View {
/// General helper: just forward to FeedbackModifier
func sendFeedback<P: Publisher>(
publisher: P,
message: @escaping (P.Output) -> Feedback?
) -> some View where P.Failure == Never {
modifier(FeedbackModifier(publisher: publisher, message: message))
}
/// Show an error message when Publisher output is Error
func sendFeedback<P: Publisher>(publisher: P) -> some View where P.Output: Error, P.Failure == Never {
sendFeedback(publisher: publisher) { error in
switch error {
case is LocalizedError:
return (message: LocalizedStringKey(error.localizedDescription), type: .error)
default:
return (message: "error_default", type: .error)
}
}
}
/// Show a success message for a Publisher output
func sendFeedback<P: Publisher>(
publisher: P,
message: @escaping (P.Output) -> LocalizedStringKey?
) -> some View where P.Failure == Never {
sendFeedback(publisher: publisher) { message($0).map { (message: $0, type: .success) } }
}
/// Show a message when Publisher is Result
func sendFeedback<P: Publisher, Success, Failure: Error>(
publisher: P,
message: @escaping (Success) -> LocalizedStringKey?
) -> some View where P.Output == Result<Success, Failure>, P.Failure == Never {
sendFeedback(publisher: publisher) {
switch $0 {
case let .success(output):
return message(output).map { (message: $0, type: .success) }
case let .failure(error) where error is LocalizedError:
return (message: LocalizedStringKey(error.localizedDescription), type: .error)
case .failure:
return (message: "error_default", type: .error)
}
}
}
}
We can now use them into our code and see the result:
struct GameView: View {
@StateObject var viewModel: GameViewModel
var body: some View {
...
.sendFeedback(publisher: viewModel.$lastUserRating) { _ in "Thanks for your rating!" }
.sendFeedback(error: viewModel.$error)
}
}
As you can see it's working but not perfectly: last modifier wins over the previous one. This is because both method (sendFeedback
) manage their own state. Let's fix this by lifting state up.
Application-wide feedback
Building an application-wide feedback system requires some container mechanism like how NavigationView
and NavigationLink
work: whenever a feedback is sent from child component the container will display it.
Therefore we'll need two components:
FeedbackContainerModifier
as the container listening to feedbackFeedbackSenderModifier
sending the feedback
To communicate between these two we will still use a publisher but stored in EnvironmentObject
. However EnvironmentObject
works only with concrete types: we cannot just say "give me a Publisher".
So we'll create a wrapper, FeedbackNotifier
, which will hold our feedback value and use it instead.
FeedbackSenderModifier
This modifier does only one thing: listening to a publisher to forward its value to FeedbackNotifier
:
@MainActor
class FeedbackNotifier: ObservableObject {
@Published var feedback: Feedback?
}
struct FeedbackSenderModifier<P: Publisher>: ViewModifier where P.Failure == Never {
let publisher: P
let message: (P.Output) -> Feedback?
@EnvironmentObject var notifier: FeedbackNotifier
func body(content: Content) -> some View {
content
.onReceive(publisher) { output in
guard let feedback = message(output) else {
return
}
notifier.feedback = feedback
}
}
}
FeedbackModifier
FeedbackContainerModifier
is actually FeedbackModifier
renamed and with a small change:
struct FeedbackContainerModifier: ViewModifier {
@StateObject private var notifier = FeedbackNotifier()
@State private var feedback: Feedback?
func body(content: Content) -> some View {
content
.environmentObject(notifier)
.overlay(alignment: .top) {
if let feedback = feedback {
FeedbackView(message: feedback.message, type: feedback.type)
}
}
.onReceive(notifier.$feedback) { newFeedback in
feedback = newFeedback
}
}
}
Instead of listening to a publisher we now listen to FeedbackNotifier
. The object is also set as EnvironmentObject
so that FeedbackSenderModifier
can access it.
Doing this we can test again our code.
extension View {
func feedbackContainer() -> some View {
modifier(FeedbackContainerModifier())
}
}
struct MyApp: App {
var body: some View {
...
.feedbackContainer()
}
}
This time one feedback is displayed at a time replacing the previous one 💪. Only one thing missing: dismissing the feedback.
Auto-dismiss
Dismissing our view after a few seconds can be done using:
- 🔀
DispatchQueue
- ⏲
Timer
DispatchQueue
is easy to use but it's a "fire and forget" solution: once planned you cannot cancel it.
On our side we want to cancel and plan again our dismiss if publisher changes in the meantime. So we'll rely on Timer instead.
struct FeedbackContainerModifier: ViewModifier {
@StateObject private var notifier = FeedbackNotifier()
@State private var timer = Timer.publish(every: 4, on: .main, in: .common)
@State private var feedback: Feedback?
@State private var cancellable: Cancellable?
func body(content: Content) -> some View {
content
.environmentObject(notifier)
.overlay(alignment: .top) {
if let feedback = feedback {
FeedbackView(message: feedback.message, type: feedback.type)
}
}
.onReceive(notifier.$feedback) { newFeedback in
timer = Timer.publish(every: 4, on: .main, in: .common)
cancellable = timer.connect()
feedback = newFeedback
}
.onReceive(timer) { _ in
feedback = nil
cancellable?.cancel()
}
}
}
Here's the changes we did:
- Whenever
publisher
changes we connect totimer
- When
timer
fires (here after 4 seconds) we set feedback back to nil (and clear our timer), effectively hiding it
Caveat: modals
The code is working great but there is one case where it doesn't play as expected: modals.
When showing a modal and trying to display a feedback the later actually displays behind the modal. This is due to view hierarchy: our FeedbackContainerModifier
is attached to the presenting view.
There are two ways of resolving this issue:
- First one is to add a new
.feedback()
on the presented view. This is simple and working well in most cases. However you need to remember doing it.
- Second is to move feedback into a dedicated window. This is the best solution however it requires falling back to UIKit and adding a bunch of code into your
AppDelegate
.
Conclusion
With very lines of code we now have a fully functional component providing feedback to user.
Last but not least we can rely on some Combine powerful operators to achieve some great behaviour!
You can find the code along a sample project on Github. Enjoy!