SwiftMessages icon indicating copy to clipboard operation
SwiftMessages copied to clipboard

Create a formal dismissal mechanism for SwiftUI message

Open wtmoose opened this issue 1 year ago • 8 comments

Make something like the dismiss environment value for SwiftMessages that will allow SwiftUI messages to self-dismiss.

wtmoose avatar May 24 '24 20:05 wtmoose

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.

mergesort avatar Jan 31 '25 04:01 mergesort

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?

wtmoose avatar Jan 31 '25 22:01 wtmoose

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!

mergesort avatar Feb 04 '25 16:02 mergesort

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.

wtmoose avatar Feb 04 '25 17:02 wtmoose

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:

  1. Make BannersController an observable object that publishes structures containing a Banner and a SwiftMessages.Config.
  2. Have the root view observe banners published by BannersController and present them using the swiftMessages() modifier.

wtmoose avatar Feb 04 '25 20:02 wtmoose

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.

mergesort avatar Feb 06 '25 19:02 mergesort

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.

wtmoose avatar Feb 07 '25 01:02 wtmoose

@mergesort do you have any feedback on #574?

wtmoose avatar Jun 05 '25 06:06 wtmoose

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!

mergesort avatar Jul 01 '25 03:07 mergesort

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

mergesort avatar Sep 03 '25 21:09 mergesort

No problem!

10.0.2

wtmoose avatar Sep 04 '25 02:09 wtmoose