Create a formal dismissal mechanism for SwiftUI message
Make something like the dismiss environment value for SwiftMessages that will allow SwiftUI messages to self-dismiss.
I'm running into this issue now, and would like to upvote this feature!
I've created a Banner that's displayed in a BannerView by conforming to MessageViewConvertible, but I have no way inside of the BannerView to dismiss the banner if a user taps on the BannerView, activating the action the associated action.
(Model)
public struct Banner: Equatable, Identifiable {
public let style: Style
public let title: LocalizedStringKey
public let description: LocalizedStringKey?
public let image: Image?
public let action: Banner.Action?
}
(View)
extension Banner: MessageViewConvertible {
public func asMessageView() -> some View {
BannerView(banner: self)
.padding(.large)
.padding(.vertical, .huge + .large)
}
}
As always, thank you so much for all of the hard work.
The recommended solution for dismissal is to use the view builder version of the swiftMessages modifier as follows:
struct ContentView: View {
private struct Message: Equatable, Identifiable {
var text: String
var id: String { text }
}
@State private var message: Message?
var body: some View {
Button("Tap") {
message = Message(text: "Testing")
}
.swiftMessage(message: $message) { message in
// The message view
VStack {
Text(message.text)
Button("OK") {
self.message = nil
}
.buttonStyle(.bordered)
}
.padding(50)
.background(.yellow)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
The use of MessageViewConvertible is officially frowned upon by management. @mergesort thoughts on this approach?
Thanks for the suggestion @wtmoose! As you requested, below is a rough version of how displaying notifications in my app works currently. I'm trying to think of the best way to port this over to the modifier style, with some constraints I have in the way I built this for the pre-SwiftUI approach. Right now the solution is rather decoupled, or I should say, the main dependency is a singular BannersController that is passed around as a dependency.
The BannersController hides away knowledge of any View layer, due to the global-nature of the SwiftMessages.show API.
public extension BannersController {
func present(_ banner: Banner, direction: BannerDirection = .bottom, duration: BannerDuration = .automatic) {
// We use this variant of SwiftMessages.show to ensure that the View is properly
// dispatched onto the main queue, guarantees not provided by other variants.
SwiftMessages.show(config: self.configuration(direction: direction, duration: duration)) {
MessageHostingView(message: banner)
}
}
func dismiss() {
SwiftMessages.hide()
}
}
That BannersController presents a Banner, which is all of the data needed to render a BannerView.
public struct Banner: Equatable, Identifiable {
public let style: Style
public let title: LocalizedStringKey
public let description: LocalizedStringKey?
public let image: Image?
public let action: Banner.Action?
public init(style: Style, title: LocalizedStringKey, description: LocalizedStringKey?, image: Image?, action: Banner.Action?) {
self.style = style
self.title = title
self.description = description
self.image = image
self.action = action
}
public var id: String {
UUID().uuidString
}
}
public extension Banner {
enum Style {
case success
case info
case accent
case error
}
}
Eschewing some details for the sake of space.
public struct BannerView: View {
@Environment(\.preferredColorPalette) private var palette
@Environment(\.colorScheme) private var colorScheme
private let banner: Banner
public init(banner: Banner) {
self.banner = banner
}
public var body: some View {
HStack(alignment: .top) {
if let bannerAction = banner.action {
Button(action: bannerAction.action, label: {
self.contentView
self.button(for: bannerAction)
})
} else {
self.contentView
}
}
.conditionallyApplyWidthConstraints()
.padding(.regular)
.padding(.horizontal, .regular)
.background(self.background)
.background(.ultraThinMaterial.standardDropShadow())
.containerShape(.rect(cornerRadius: .regular))
.borderOverlay(withShape: .rect(cornerRadius: .regular), border: palette.alternativeBackground, width: .hairline)
.padding(.horizontal, self.horizontalPadding)
}
}
And the system is tied together by conforming Banner to MessageViewConvertible, so it can be presented.
extension Banner: MessageViewConvertible {
public func asMessageView() -> some View {
BannerView(banner: self)
.padding(.large)
.padding(.vertical, .huge + .large)
}
}
Now whenever I want to present a banner, I pass in one of the pre-defined banners, like so.
// Called from anywhere in my app
self.bannersController.present(.linkCopied)
// Predefined banners
public extension Banner {
static let linkCopied = Banner.info(
title: LocalizedStringKey("BANNER_LINK_COPIED_TITLE", bundle: .module),
image: Image.icons.link
)
}
I believe I can port this over to the .swiftMessage(…) modifier, but it would be a big change by creating some global state or passing up state through Preferences, so any child can let the root view know that we have a message to display. Would appreciate any advice if you have a simpler solution!
With non-modifier approach, my initial thought is to put something in the environment your banner can use to dismiss, similar to Apple's dismiss environment value.
public struct BannerView: View {
@Environment(\.swiftmessagesHide) private var hide
private let banner: Banner
public init(banner: Banner) {
self.banner = banner
}
public var body: some View {
//...
Button("OK") {
hide()
}
}
}
However, with this approach I think you'd need to also configure the environment value with a modifier in your root view:
public struct RootView: View {
public var body: some View {
Whatever()
.installSwiftMessages()
}
}
This will only allow you to dismiss the currently displayed banner. However, since presumably the user is tapping a button in the currently displayed banner, it seems sufficient. This is a pretty small change I could make in the next week or so.
I thought of another option that seems preferable to me and would require any SwiftMessages changes. There could be a flaw in this thinking, but:
- Make
BannersControlleran observable object that publishes structures containing aBannerand aSwiftMessages.Config. - Have the root view observe banners published by
BannersControllerand present them using theswiftMessages()modifier.
Thanks for the suggestions @wtmoose! I think both of these would work pretty well. In terms of an API, and I can use the latter for now, but I actually think the first would be a more idiomatic SwiftUI API, if you feel like it would be a worthwhile addition to the library.
I'm happy to add this, but IMO it is pseudo idiomatic and an approach I don't use myself.
With dismiss, the presented view doesn't need to know how it was presented, e.g. push navigation, sheet, etc. However, there's no way to make dismiss work with SwiftMessages, so the closest approximation is to introduce a similar value swiftmessagesHide. However, the presented view must be aware that it was presented with SwiftMessages.
@mergesort do you have any feedback on #574?
Sorry for the very late reply, I haven't been working in this part of the codebase at all! While I was waiting for a solution I ended up adding my own concept of a conditionally displayed banner, which has been working great for me. I'm not saying it's worth stealing, but it's actually been very nice, and is reasonably similar to the .sheet(isPresented) API. I'll just include the snippet in case it's valuable to anyone else, but basically I have my own wrapper around durations like this.
public enum BannerDuration {
case automatic
case time(Duration)
case indefinite
case condition(() -> Bool)
}
A present method that checks for the duration and presents the banner conditionally or based on a traditional duration.
public extension BannersController {
func present(_ banner: Banner, direction: BannerDirection = .bottom, duration: BannerDuration = .automatic) {
switch duration {
case .condition(let condition):
self.presentConditionalBanner(
banner: banner,
direction: direction,
condition: condition
)
case .time, .automatic, .indefinite:
self.present(
banner,
configuration: self.configuration(direction: direction, duration: duration)
)
}
}
}
And then I have a simple mechanism for observing conditionally presented banners to show or dismiss them.
func presentConditionalBanner(banner: Banner, direction: BannerDirection, condition: @escaping () -> Bool) {
let bannerID = "conditionally-presented-banner-\(banner.title)"
self.presentBannerConditionally(
bannerID: bannerID,
direction: direction,
condition: condition,
banner: banner,
contentProvider: {
BannerView(banner: banner)
}
)
}
func presentBannerConditionally<ContentView: View>(bannerID: String, direction: BannerDirection = .bottom, condition: @escaping () -> Bool, banner: Banner? = nil, contentProvider: @escaping () -> ContentView) {
// Cancel any existing monitoring for this banner ID
self.dismissConditionalBanner(withID: bannerID)
// Create and store monitoring task for cleanup
let monitoringTask = Task {
await self.observeBannerCondition(
condition,
bannerID: bannerID,
direction: direction,
contentProvider: contentProvider
)
}
self.conditionalBannerTasks[bannerID] = monitoringTask
}
func observeBannerCondition<ContentView: View>(_ condition: @escaping () -> Bool, bannerID: String, direction: BannerDirection, contentProvider: @escaping () -> ContentView) async {
func trackChanges() {
let isConditionMet = withObservationTracking({
condition()
}, onChange: {
Task { @MainActor in
if !Task.isCancelled {
trackChanges() // Recursively set the changes to track again
}
}
})
Task {
await self.updateBannerVisibility(
isConditionMet,
bannerID: bannerID,
direction: direction,
contentProvider: contentProvider
)
}
}
// Kick off the initial change tracking
trackChanges()
}
func updateBannerVisibility<ContentView: View>(_ isConditionMet: Bool, bannerID: String, direction: BannerDirection, contentProvider: @escaping () -> ContentView) async {
let isCurrentlyShowing = SwiftMessages.count(id: bannerID) > 0
if isConditionMet && !isCurrentlyShowing {
let config = self.configuration(direction: direction, duration: .indefinite)
let content = contentProvider()
SwiftMessages.show(config: config, view: MessageHostingView(id: bannerID, content: content))
} else if !isConditionMet && isCurrentlyShowing {
SwiftMessages.hide(id: bannerID)
}
}
func dismissConditionalBanner(withID bannerID: String) {
// Cancel monitoring task and remove from tracking
self.conditionalBannerTasks[bannerID]?.cancel()
self.conditionalBannerTasks.removeValue(forKey: bannerID)
// Hide banner if currently showing (query SwiftMessages directly)
if SwiftMessages.count(id: bannerID) > 0 {
SwiftMessages.hide(id: bannerID)
}
}
}
There's a little more to it if you find that concept valuable, happy to share. Sorry I didn't get a chance to try this out, but the just by looking at the API itself everything looks pretty reasonable to me!
I just wanted to share a very belated thank you! I didn't replace my current show/dismiss functionality with this new mechanism, but I did ended up using it in a new piece of code and it helped me improve a long-standing user experience annoyance. :)
No problem!
10.0.2