Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create a formal dismissal mechanism for SwiftUI message #549

Open
wtmoose opened this issue May 24, 2024 · 5 comments
Open

Create a formal dismissal mechanism for SwiftUI message #549

wtmoose opened this issue May 24, 2024 · 5 comments

Comments

@wtmoose
Copy link
Member

wtmoose commented May 24, 2024

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

@mergesort
Copy link

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.

@wtmoose
Copy link
Member Author

wtmoose commented Jan 31, 2025

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?

@mergesort
Copy link

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!

@wtmoose
Copy link
Member Author

wtmoose commented Feb 4, 2025

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
Copy link
Member Author

wtmoose commented Feb 4, 2025

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants