Creating a feedback component

Tue, 15 Nov 2022 00:00:00 +0000

How to leverage SwiftUI and Combine capabilities to build a feedback UI component.

It 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:

1. First by analysing our needs

2. Then by building a dumb message UI

3. Then by adding logic show it locally in a view

4. 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

1. Display a message when a publisher changes

2. Determine whether the message should be success or error depending on the type

3. 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:

1. The message will be provided by the developer through a closure

2. 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:

1. FeedbackContainerModifier as the container listening to feedback

2. FeedbackSenderModifier 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:

1. 🔀 DispatchQueue

2. ⏲ 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 to timer

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:

1. 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.

2. 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!