Search code examples
swiftswiftui

SwiftUI: Global Overlay That Can Be Triggered From Any View


I'm quite new to the SwiftUI framework and I haven't wrapped my head around all of it yet so please bear with me.

Is there a way to trigger an "overlay view" from inside "another view" when its binding changes? See illustration below:

enter image description here

I figure this "overlay view" would wrap all my views. I'm not sure how to do this yet - maybe using ZIndex. I also guess I'd need some sort of callback when the binding changes, but I'm also not sure how to do that either.

This is what I've got so far:

ContentView

struct ContentView : View {
    @State private var liked: Bool = false

    var body: some View {
        VStack {
            LikeButton(liked: $liked)
        }
    }
}

LikeButton

struct LikeButton : View {
    @Binding var liked: Bool

    var body: some View {
        Button(action: { self.toggleLiked() }) {
            Image(systemName: liked ? "heart" : "heart.fill")
        }
    }

    private func toggleLiked() {
        self.liked = !self.liked
        // NEED SOME SORT OF TOAST CALLBACK HERE
    }
}

I feel like I need some sort of callback inside my LikeButton, but I'm not sure how this all works in Swift.

Any help with this would be appreciated. Thanks in advance!


Solution

  • It's quite easy - and entertaining - to build a "toast" in SwiftUI!

    Let's do it!

    struct Toast<Presenting>: View where Presenting: View {
    
        /// The binding that decides the appropriate drawing in the body.
        @Binding var isShowing: Bool
        /// The view that will be "presenting" this toast
        let presenting: () -> Presenting
        /// The text to show
        let text: Text
    
        var body: some View {
    
            GeometryReader { geometry in
    
                ZStack(alignment: .center) {
    
                    self.presenting()
                        .blur(radius: self.isShowing ? 1 : 0)
    
                    VStack {
                        self.text
                    }
                    .frame(width: geometry.size.width / 2,
                           height: geometry.size.height / 5)
                    .background(Color.secondary.colorInvert())
                    .foregroundColor(Color.primary)
                    .cornerRadius(20)
                    .transition(.slide)
                    .opacity(self.isShowing ? 1 : 0)
    
                }
    
            }
    
        }
    
    }
    

    Explanation of the body:

    • GeometryReader gives us the preferred size of the superview , thus allowing the perfect sizing for our Toast.
    • ZStack stacks views on top of each other.
    • The logic is trivial: if the toast is not supposed to be seen (isShowing == false), then we render the presenting view. If the toast has to be presented (isShowing == true), then we render the presenting view with a little bit of blur - because we can - and we create our toast next.
    • The toast is just a VStack with a Text, with custom frame sizing, some design bells and whistles (colors and corner radius), and a default slide transition.

    I added this method on View to make the Toast creation easier:

    extension View {
    
        func toast(isShowing: Binding<Bool>, text: Text) -> some View {
            Toast(isShowing: isShowing,
                  presenting: { self },
                  text: text)
        }
    
    }
    

    And a little demo on how to use it:

    struct ContentView: View {
    
        @State var showToast: Bool = false
    
        var body: some View {
            NavigationView {
                List(0..<100) { item in
                    Text("\(item)")
                }
                .navigationBarTitle(Text("A List"), displayMode: .large)
                .navigationBarItems(trailing: Button(action: {
                    withAnimation {
                        self.showToast.toggle()
                    }
                }){
                    Text("Toggle toast")
                })
            }
            .toast(isShowing: $showToast, text: Text("Hello toast!"))
        }
    
    }
    

    I used a NavigationView to make sure the view fills the entire screen, so the Toast is sized and positioned correctly.

    The withAnimation block ensures the Toast transition is applied.


    How it looks:

    enter image description here

    It's easy to extend the Toast with the power of SwiftUI DSL.

    The Text property can easily become a @ViewBuilder closure to accomodate the most extravagant of the layouts.


    To add it to your content view:

    struct ContentView : View {
        @State private var liked: Bool = false
    
        var body: some View {
            VStack {
                LikeButton(liked: $liked)
            }
            // make it bigger by using "frame" or wrapping it in "NavigationView"
            .toast(isShowing: $liked, text: Text("Hello toast!"))
        }
    }
    

    How to hide the toast afte 2 seconds (as requested):

    Append this code after .transition(.slide) in the toast VStack.

    .onAppear {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
          withAnimation {
            self.isShowing = false
          }
        }
    }
    

    Tested on Xcode 11.1