58

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!

realph
  • 4,481
  • 13
  • 49
  • 104

7 Answers7

110

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

Matteo Pacini
  • 21,796
  • 7
  • 67
  • 74
  • 1
    Any idea how to hide the toast after 2 seconds? Is there some sort of timeout function in Swift? – realph Jun 11 '19 at 22:21
  • 2
    @realph added as the last section in the answer - you can simply set `isShowing` to false, using `DispatchQueue.asyncAfter` – Matteo Pacini Jun 11 '19 at 22:27
  • 2
    You're a star! I'm going to try and unpack this tomorrow, but glad to have it working at this point. Thanks again. – realph Jun 11 '19 at 22:31
  • So it looks like SwiftUI automatically applies a cross fade animation when switching between views, and it looks like the screen flickers white before the toast appears. Any idea how to turn that off? Sorry for so many questions, I've tried google-ing it but haven't been able to find a thing. – realph Jun 13 '19 at 21:41
  • Ahh. So it looks like they reference it in the tutorials here. https://developer.apple.com/tutorials/swiftui/animating-views-and-transitions > By default, views transition on- and offscreen by fading in and out. You can customize this transition by using the transition(_:) modifier. I'll see if I can play around with that `transition(_:)` modifier and report back – realph Jun 13 '19 at 21:54
  • 2
    @realph to disable the fade-in / fade-out animation, use `.transition(.identity)` – Matteo Pacini Jun 13 '19 at 22:15
  • Sorry for all the questions. Is there an easy way to not show the toast on the initial value? If I navigate to the view the toast is shown by default, and I guess I only want to the toast to show if the button is toggled. – realph Jun 22 '19 at 14:07
  • 1
    One little thing: In XCode 11 Beta4 the .onAppear() seems to get called only once, with its first appearance. Some kind of deinitialization needed after first toggle? – HelloTimo Jul 22 '19 at 13:32
  • 9
    Due to .onAppear() gets called only once on the first appearance, you need to replace it with: `var body: some View { if self.isShowing { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.isShowing = false } } return GeometryReader { ..........` – Ruslanas Kudriavcevas Nov 20 '19 at 12:23
  • 3
    How do I get this to work when I *do not have access* to a view that fills the entire screen? I want to do this, as the question postulates, from *any view*, namely a child view that only fills a fraction of the screen. How is this possible? With the given solution, you must have access to the base view for this to work. – Noah Wilder Dec 31 '19 at 03:57
  • @NoahWilder One way would be to put the toast on your root view, And then have children wired up so they could modify some state the root uses which would trigger the toast – abc123 Jan 07 '20 at 16:43
  • My problem is that I want to trigger the global overlay/alert from an isolated subview that has no knowledge nor access to its parent view @guptron. – Noah Wilder Jan 07 '20 at 17:26
  • Right this is an architectural question which can be solved in many ways. It’s probably worth a separate question. Redux is one pattern that can solve this. – abc123 Jan 08 '20 at 15:37
  • if a toast is made to show more than once , it does not automatically disappear @MatteoPacini – Akash Chaudhary Mar 28 '20 at 04:40
  • @MatteoPacini cloud you also build a banner system like [this](https://trailingclosure.com/notification-banner-using-swiftui/) based on your method? The view modifiers used there do not seem to work on list views or navigation views :( – p0fi Mar 31 '20 at 18:42
  • Why using `let presenting: () -> Presenting` instead of `let presenting: Presenting` – Sree Apr 18 '20 at 17:11
  • The View extension allowing this to be attached to the view with the postfix grammar is superb. – biomiker May 20 '20 at 07:16
  • This is a great way to show the toast. The view disappearing part was not working for me. But I added the same (Not .onAppear, but asyncAfter part only) in my main view after when I have changed the showToast boolean to true. – SKT Aug 06 '20 at 08:41
  • This solution comes with a precondition: the modifier must be under the page view. For example, it can not be put under a button or a small view on the screen. – LiangWang Jul 23 '21 at 06:17
  • Does this work on full screen covers also? Thanks! – Daniel Z. Jul 31 '22 at 11:34
  • Suppose you added it to a short bottom View, It will fail. – Aaban Tariq Murtaza Aug 11 '23 at 15:00
15

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

protasm
  • 1,209
  • 12
  • 20
5

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)
        }
    }
}
amin torabi
  • 281
  • 5
  • 8
3

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

Mojtaba Hosseini
  • 95,414
  • 31
  • 268
  • 278
1

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")
        }
    }
}
Xaxxus
  • 1,083
  • 12
  • 19
0

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)
}
RPatel99
  • 7,448
  • 2
  • 37
  • 45
  • That's for a native alert, right? How would I render a custom view for the overlay? – realph Jun 11 '19 at 19:29
  • @realph you would either want to provide presentation with a `Popover` or a `Modal`, both of which can be constructed with a custom `View`. `Popover` is probably what you are looking for based on your pictures. – RPatel99 Jun 11 '19 at 19:34
  • 1
    @realph edited my answer to help you out a bit more with using `Popover` – RPatel99 Jun 11 '19 at 19:46
0

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)
        }
    }
}
huynguyen
  • 7,616
  • 5
  • 35
  • 48