realph
realph

Reputation: 4671

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!

Upvotes: 61

Views: 36088

Answers (8)

Kevin
Kevin

Reputation: 493

The answers here are overly complex with Geometry readers and ZStacks. The slide transition wasn't working for me either. I simplified it using an overlay.

I needed to show different types of bottom sheets so my solution allows you to pass in an enum like the .sheet modifier:

struct BottomOverlay<Item, Presented>: ViewModifier where Presented: View {
    @Binding var item: Item?
    let presented: (Item) -> Presented
    
    func body(content: Content) -> some View {
        content
            .overlay(alignment: .bottom) {
                if let item {
                    presented(item)
                        .transition(.move(edge: .bottom))
                }
            }
    }
}

extension View {
    func bottomOverlay<Item, Content>(for item: Binding<Item?>, @ViewBuilder content: @escaping (Item) -> Content) -> some View where Item : Identifiable, Content: View {
        modifier(BottomOverlay(item: item, presented: content))
    }
}
MainView()
     .bottomOverlay(for: $bottomBar) { bottomBar in
           // switch on bottomBar to show different types of overlays
           ToastView()
     }

Upvotes: 0

Mojtaba Hosseini
Mojtaba Hosseini

Reputation: 119128

App-wide View

If you want it to be app-wide, put in somewhere app-wide! For example, you can add it to the MyProjectApp.swift (or in sceneDelegate for UIKit/AppDelegate projects) file like this:

Note that the button and the State are just for more explanation and you may consider changing them in the way you like

@main
struct SwiftUIAppPlaygroundApp: App {  // <- Note that where we are!
    @State var showToast = false

    var body: some Scene {
        WindowGroup {
            Button("App-Wide Button") { showToast.toggle() }

            ZStack {
                ContentView() // <- The app flow

                if showToast {
                    MyCustomToastView().ignoresSafeArea(.all, edges: .all) // <- App-wide overlays
                }
            }
        }
    }
}

See? now you can add any sort of view on anywhere of the screen, without blocking animations. Just convert that @State to some sort of AppState like Observables or Environments and boom! 💥 you did it!

Note that it is a demo, you should use an environment variable or smt to be able for changing it from outside of this view's body

Upvotes: 6

Xaxxus
Xaxxus

Reputation: 1809

Apple does not currently provide any APIs that allow you to make global views similar to their own alert pop-ups.

In fact these views are actually still using UIKit under the hood.

If you want your own global pop-ups you can sort of hack your own (note this isn't tested, but something very similar should work for global presentation of toasts):

import SwiftUI
import Foundation

/// Global class that will manage toasts
class ToastPresenter: ObservableObject {
    // This static property probably isn't even needed as you can inject via @EnvironmentObject
    static let shared: ToastPresenter = ToastPresenter()
    
    private init() {}
    
    @Published private(set) var isPresented: Bool = false
    private(set) var text: String?
    private var timer: Timer?
    
    /// Call this function to present toasts
    func presentToast(text: String, duration: TimeInterval = 5) {
        // reset the toast if one is currently being presented.
        isPresented = false
        self.text = nil
        timer?.invalidate()
        
        self.text = text
        isPresented = true
        timer = Timer(timeInterval: duration, repeats: false) { [weak self] _ in
            self?.isPresented = false
        }
    }
}


/// The UI for a toast
struct Toast: View {
    var text: String
    
    var body: some View {
        Text(text)
            .padding()
            .background(Capsule().fill(Color.gray))
            .shadow(radius: 6)
            .transition(AnyTransition.opacity.animation(.default))
    }
}

extension View {
    /// ViewModifier that will present a toast when its binding changes
    @ViewBuilder func toast(presented: Binding<Bool>, text: String) -> some View {
        ZStack {
            self
        
            if presented.wrappedValue {
                Toast(text: text)
            }
        }
        .ignoresSafeArea(.all, edges: .all)
    }
}

/// The first view in your app's view hierarchy
struct RootView: View {
    @StateObject var toastPresenter = ToastPresenter.shared
    
    var body: some View {
        MyAppMainView()
            .toast(presented: $toastPresenter.isPresented, text: toastPresenter.text)
            // Inject the toast presenter into the view hierarchy
            .environmentObject(toastPresenter)
    }
}

/// Some view later on in the app
struct SomeViewDeepInTheHierarchy: View {
    @EnvironmentObject var toastPresenter: ToastPresenter
    
    var body: some View {
        Button {
            toastPresenter.presentToast(text: "Hello World")
        } label: {
            Text("Show Toast")
        }
    }
}

Upvotes: 2

amin torabi
amin torabi

Reputation: 299

here is the how to overlay on all of your views including NavigationView!

create a class model to store your views!

class ParentView:ObservableObject {
    
        @Published var view:AnyView = AnyView(EmptyView())
        
    }

create the model in your parrent view and call it in your view hierarchy pass this class to your environment object of your parent view

struct Example: View {
    @StateObject var parentView = ParentView()

    var body: some View {
        ZStack{
            NavigationView{
                ChildView()
                    .environmentObject(parentView)
                    .navigationTitle("dynamic parent view")
            }
            parentView.view
        }
    }
}

from now on you can call parentview in your child view by

@EnvironmentObject var parentView:ParentView

then for example in your tap gesture, you can change the parent view and show a pop up that covers everything including your navigationviews

@StateObject var parentView = ParentView()

here is the full solution copy and play with it in your preview!

import SwiftUI

class ParentView:ObservableObject {
        @Published var view:AnyView = AnyView(EmptyView())
    }


struct example: View {
    @StateObject var parentView = ParentView()

    var body: some View {
        ZStack{
            NavigationView{
                ChildView()
                    .environmentObject(parentView)
                    .navigationTitle("dynamic parent view")
            }
            parentView.view
        }
    }
}
struct ChildView: View {
    @EnvironmentObject var parentView:ParentView

    var body: some View {
        ZStack{
            Text("hello")
                .onTapGesture {
                    parentView.view = AnyView(Color.red.opacity(0.4).ignoresSafeArea())
                }
        }
    }
}

struct example_Previews: PreviewProvider {
    static var previews: some View {
        example()
    }
}

also you can improve this dramatically like this...!

struct ParentViewModifire:ViewModifier {
    @EnvironmentObject var parentView:ParentView

    @Binding var presented:Bool
    let anyView:AnyView
    func body(content: Content) -> some View {
        content
            .onChange(of: presented, perform: { value in
                if value {
                    parentView.view = anyView
                }
            })
    }
}
extension View {
     func overlayAll<Overlay>(_ overlay: Overlay, presented: Binding<Bool>) -> some View where Overlay : View {
        self
        .modifier(ParentViewModifire(presented: presented, anyView: AnyView(overlay)))
    }
}

now in your child view you can call this modifier on your view

struct ChildView: View {
    @State var newItemPopUp:Bool = false
    var body: some View {
        ZStack{
            Text("hello")
               .overlayAll(newCardPopup, presented: $newItemPopUp)
        }
    }
}

Upvotes: 4

huynguyen
huynguyen

Reputation: 7760

I am using this open source: https://github.com/huynguyencong/ToastSwiftUI . It is very simple to use.

struct ContentView: View {
    @State private var isShowingToast = false
    
    var body: some View {
        VStack(spacing: 20) {
            Button("Show toast") {
                self.isShowingToast = true
            }
            
            Spacer()
        }
        .padding()

        // Just add a modifier to show a toast, with binding variable to control
        .toast(isPresenting: $isShowingToast, dismissType: .after(3)) {
            ToastView(message: "Hello world!", icon: .info)
        }
    }
}

Upvotes: 0

protasm
protasm

Reputation: 1297

I modified Matteo Pacini's great answer, above, incorporating comments to have the Toast fade in and fade out after a delay. I also modified the View extension to be a bit more generic, and to accept a trailing closure similar to the way .sheet works.

ContentView.swift:

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

    var body: some View {
        VStack {
            Button(action: {
                if (!self.showToast) {
                    self.lightsOn.toggle()

                    withAnimation {
                        self.showToast = true
                    }
                }
            }){
                Text("switch")
            } //Button
            .padding(.top)

            Image(systemName: self.lightsOn ? "lightbulb" : "lightbulb.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .padding(.all)
                .toast(isPresented: self.$showToast) {
                    HStack {
                        Text("Lights: \(self.lightsOn ? "ON" : "OFF")")
                        Image(systemName: self.lightsOn ? "lightbulb" : "lightbulb.fill")
                    } //HStack
                } //toast
        } //VStack
    } //body
} //ContentView

View+Toast.swift:

extension View {
    func toast<Content>(isPresented: Binding<Bool>, content: @escaping () -> Content) -> some View where Content: View {
        Toast(
            isPresented: isPresented,
            presenter: { self },
            content: content
        )
    }
}

Toast.swift:

struct Toast<Presenting, Content>: View where Presenting: View, Content: View {
    @Binding var isPresented: Bool
    let presenter: () -> Presenting
    let content: () -> Content
    let delay: TimeInterval = 2

    var body: some View {
        if self.isPresented {
            DispatchQueue.main.asyncAfter(deadline: .now() + self.delay) {
                withAnimation {
                    self.isPresented = false
                }
            }
        }

        return GeometryReader { geometry in
            ZStack(alignment: .bottom) {
                self.presenter()

                ZStack {
                    Capsule()
                        .fill(Color.gray)

                    self.content()
                } //ZStack (inner)
                .frame(width: geometry.size.width / 1.25, height: geometry.size.height / 10)
                .opacity(self.isPresented ? 1 : 0)
            } //ZStack (outer)
            .padding(.bottom)
        } //GeometryReader
    } //body
} //Toast

With this you could toast Text, or an Image (or both, as shown below), or any other View.

enter image description here

Upvotes: 15

Matteo Pacini
Matteo Pacini

Reputation: 22846

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

Upvotes: 114

RPatel99
RPatel99

Reputation: 8096

Use .presentation() to show an alert when the button is tapped.

In LikeButton:

@Binding var liked: Bool

var body: some View {
    Button(action: {self.liked = !self.liked}, label: {
        Image(systemName: liked ? "heart.fill" : "heart")
    }).presentation($liked) { () -> Alert in
        Alert.init(title: Text("Thanks for liking!"))
    }
}

You can also use .presentation() to present other Modal views, like a Popover or ActionSheet. See here and the "See Also" section on that page in Apple's SwiftUI documentation for info on the different .presentation() options.

Edit: Example of what you want with a custom view using Popover:

@State var liked = false
let popover = Popover(content: Text("Thanks for liking!").frame(width: 200, height: 100).background(Color.white), dismissHandler: {})
var body: some View {
    Button(action: {self.liked = !self.liked}, label: {
        Image(systemName: liked ? "heart.fill" : "heart")
    }).presentation(liked ? popover : nil)
}

Upvotes: 1

Related Questions