SwiftUI: Passing a variable from Content View to a Class - swift

In my menu bar macOS app, i'm trying to access a toggle that's in my Content View from my StatusBarController class. This switch is meant to keep the popover shown on screen even when a user clicks outside the app.
Content View
struct ContentView: View {
#State private var lock = false
var body: some View {
VStack{
Text("Hello World")
Toggle( isOn: $lock, label: {
})
.toggleStyle(SwitchToggleStyle())
}
.frame(width: 360.0, height: 160.0, alignment: .top)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
StatusBarController
class StatusBarController {
private var eventMonitor: EventMonitor?
private var content: ContentView?
func unlockpopover() {
eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown], handler: mouseEventHandler)
}
if lock {
// Popover window is locked if user clicks outside the app
}else {
unlockpopover() // Popover window is unlocked if user clicks outside the app
}
}
}
}
How do i access the 'lock' variable from Content View?
'Cannot find 'lock' in scope'
is the error that's currently showing.
MenuBar project template i'm using.
EDIT
#lorem ipsum's suggestion of using EnvironmentObject and observable object.
StatusBarController
class StatusBarController:ObservableObject {
private var eventMonitor: EventMonitor?
private var content: ContentView?
#Published var lock: Bool = false
func unlockpopover() {
eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown], handler: mouseEventHandler)
}
if lock {
// Popover window is locked if user clicks outside the app
}else {
unlockpopover() // Popover window is unlocked if user clicks outside the app
}
}
}
}
Content View
struct ContentView: View {
#EnvironmentObject var test: StatusBarController
var body: some View {
VStack{
Text("Hello World")
Toggle( isOn: $test.lock, label: {
})
.toggleStyle(SwitchToggleStyle())
}
.environmentObject(self.test)
.frame(width: 360.0, height: 160.0, alignment: .top)
}
}
App crashes with this error highlighted on $test.lock
Thread 1: Fatal error: No ObservableObject of type StatusBarController
found. A View.environmentObject(_:) for StatusBarController may be
missing as an ancestor of this view.
AppDelegate
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var popover = NSPopover.init()
var statusBar: StatusBarController?
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the contents
let contentView = ContentView()
// Set the SwiftUI's ContentView to the Popover's ContentViewController
popover.contentViewController = MainViewController()
popover.contentSize = NSSize(width: 360, height: 360)
popover.contentViewController?.view = NSHostingView(rootView: contentView)
// Create the Status Bar Item with the Popover
statusBar = StatusBarController.init(popover)
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}

Related

How could I pass StateObject to both main struct and AppDelegate(or just AppDelegate)?

I began to learn Swift four days ago and I met a problem.
#main
struct GmailNotificationApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#StateObject var authViewModel = AuthenticationViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authViewModel) // <-----first place
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let statusButton = statusItem.button {
statusButton.image = NSImage(systemSymbolName: "g.circle.fill", accessibilityDescription: "Gmail Notification")
statusButton.action = #selector(togglePopover)
}
self.popover = NSPopover()
self.popover.contentSize = NSSize(width: 210, height: 300)
self.popover.behavior = .transient
self.popover.contentViewController = NSHostingController(rootView: ContentView().environmentObject(AuthenticationViewModel())) // <-----second place
}
#objc func togglePopover(){
if let button = statusItem.button {
if popover.isShown {
self.popover.performClose(nil)
} else {
self.popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}
}
}
My question is: How could I pass StateObject inside AppDelegate?
Also:
I knew little about AppKit, it seems I have two "window", one is displayed just below menu bar and another shows when Google Auth calls back.
I wish the windows in the center of my screen never shows.
Now I use
NSApplication.shared.windows.first?.close()
var window: NSWindow!
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 0, height: 0),
styleMask: [.borderless],
backing: .buffered, defer: false)
window.center()
window.makeKeyAndOrderFront(nil)
to work around.
But I think it's very stupid.
So is there a way to never show the window in the screen center and just show the windows below menu bar?
I'm quite new to Swift/SwiftUI/AppKit and sorry for these dumb questions.
Notice that you don't give SwiftUI an instance of your AppDelegate. You only give it the class object, AppDelegate.self, and SwiftUI creates the instance once and keeps it around forever.
I think a better pattern is to create the AuthenticationViewModel in your AppDelegate. Then you don't need to use #StateObject to keep the model alive, because the app delegate will do that. So you can just use #ObservedObject:
class AppDelegate: NSObject, NSApplicationDelegate {
let authViewModel = AuthenticationViewModel()
// blah blah blah
}
#main
struct MacStudyApp: App {
#NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
#ObservedObject private var authViewModel: AuthenticationViewModel
init() {
authViewModel = _appDelegate.wrappedValue.authViewModel
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authViewModel)
}
}
}
But in your case, you don't even need to use #ObservedObject because MacStudyApp doesn't use any properties of authViewModel in its body. So you could in fact just do this:
class AppDelegate: NSObject, NSApplicationDelegate {
let authViewModel = AuthenticationViewModel()
// blah blah blah
}
#main
struct MacStudyApp: App {
#NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appDelegate.authViewModel)
}
}
}

SwiftUI: Changing values in a Class

This is a menubar macOS app. I'm trying to change the dropdown window size with a button from the content view. This is my attempt so far.
struct ContentView: View {
#ObservedObject var app = AppDelegate()
var body: some View {
VStack{
Text("Hello World")
.padding(.top, 10)
Button {
biggerwindow()
} label: {
Image("tv")
}
}
}
func biggerwindow(){
app.popover.contentSize = NSSize(width: 800, height: 800 )
}
}
Nothing happens when clicking on the button.
This is the AppDelegate class that contains the default values
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
var popover = NSPopover.init()
var statusBar: StatusBarController?
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the contents
let contentView = ContentView()
// Set the SwiftUI's ContentView to the Popover's ContentViewController
popover.contentViewController = MainViewController()
//default size of the menu bar window
popover.contentSize = NSSize(width: 360, height: 360 )
popover.contentViewController?.view = NSHostingView(rootView: contentView)
// Create the Status Bar Item with the Popover
statusBar = StatusBarController.init(popover)
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
The problem is here:
#ObservedObject var app = AppDelegate()
You're creating a new instance of AppDelegate here, with its own instance of popover. You need to use the existing instance of AppDelegate that was created at app launch. You can access the existing instance through the global NSApplication.shared object.
struct ContentView: View {
var app: AppDelegate { NSApplication.shared.delegate as! AppDelegate }

Change background color of TextEditor in SwiftUI

TextEditor seems to have a default white background. So the following is not working and it displayed as white instead of defined red:
var body: some View {
TextEditor(text: .constant("Placeholder"))
.background(Color.red)
}
Is it possible to change the color to a custom one?
iOS 16
You should hide the default background to see your desired one:
TextEditor(text: .constant("Placeholder"))
.scrollContentBackground(.hidden) // <- Hide it
.background(.red) // To see this
iOS 15 and below
TextEditor is backed by UITextView. So you need to get rid of the UITextView's backgroundColor first and then you can set any View to the background.
struct ContentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
List {
TextEditor(text: .constant("Placeholder"))
.background(.red)
}
}
}
Demo
You can find my simple trick for growing TextEditor here in this answer
Pure SwiftUI solution on iOS and macOS
colorMultiply is your friend.
struct ContentView: View {
#State private var editingText: String = ""
var body: some View {
TextEditor(text: $editingText)
.frame(width: 400, height: 100, alignment: .center)
.cornerRadius(3.0)
.colorMultiply(.gray)
}
}
Update iOS 16 / SwiftUI 4.0
You need to use .scrollContentBackground(.hidden) instead of UITextView.appearance().backgroundColor = .clear
https://twitter.com/StuFFmc/status/1556561422431174656
Warning: This is an iOS 16 only so you'll probably need some if #available and potentially two different TextEditor component.
extension View {
/// Layers the given views behind this ``TextEditor``.
func textEditorBackground<V>(#ViewBuilder _ content: () -> V) -> some View where V : View {
self
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
.background(content())
}
}
Custom Background color with SwiftUI on macOS
On macOS, unfortunately, you have to fallback to AppKit and wrap NSTextView.
You need to declare a view that conforms to NSViewRepresentable
This should give you pretty much the same behaviour as SwiftUI's TextEditor-View and since the wrapped NSTextView does not draw its background, you can use the .background-ViewModifier to change the background
struct CustomizableTextEditor: View {
#Binding var text: String
var body: some View {
GeometryReader { geometry in
NSScrollableTextViewRepresentable(text: $text, size: geometry.size)
}
}
}
struct NSScrollableTextViewRepresentable: NSViewRepresentable {
typealias Representable = Self
// Hook this binding up with the parent View
#Binding var text: String
var size: CGSize
// Get the UndoManager
#Environment(\.undoManager) var undoManger
// create an NSTextView
func makeNSView(context: Context) -> NSScrollView {
// create NSTextView inside NSScrollView
let scrollView = NSTextView.scrollableTextView()
let nsTextView = scrollView.documentView as! NSTextView
// use SwiftUI Coordinator as the delegate
nsTextView.delegate = context.coordinator
// set drawsBackground to false (=> clear Background)
// use .background-modifier later with SwiftUI-View
nsTextView.drawsBackground = false
// allow undo/redo
nsTextView.allowsUndo = true
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
// get wrapped nsTextView
guard let nsTextView = scrollView.documentView as? NSTextView else {
return
}
// fill entire given size
nsTextView.minSize = size
// set NSTextView string from SwiftUI-Binding
nsTextView.string = text
}
// Create Coordinator for this View
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
// Declare nested Coordinator class which conforms to NSTextViewDelegate
class Coordinator: NSObject, NSTextViewDelegate {
var parent: Representable // store reference to parent
init(_ textEditor: Representable) {
self.parent = textEditor
}
// delegate method to retrieve changed text
func textDidChange(_ notification: Notification) {
// check that Notification.name is of expected notification
// cast Notification.object as NSTextView
guard notification.name == NSText.didChangeNotification,
let nsTextView = notification.object as? NSTextView else {
return
}
// set SwiftUI-Binding
parent.text = nsTextView.string
}
// Pass SwiftUI UndoManager to NSTextView
func undoManager(for view: NSTextView) -> UndoManager? {
parent.undoManger
}
// feel free to implement more delegate methods...
}
}
Usage
ContenView: View {
#State private var text: String
var body: some View {
VStack {
Text("Enter your text here:")
CustomizableTextEditor(text: $text)
.background(Color.red)
}
.frame(minWidth: 600, minHeight: 400)
}
}
Edit:
Pass reference to SwiftUI UndoManager so that default undo/redo actions are available.
Wrap NSTextView in NSScrollView so that it is scrollable. Set minSize property of NSTextView to enclosing SwiftUIView-Size so that it fills the entire allowed space.
Caveat: Only first line of this custom TextEditor is clickable to enable text editing.
This works for me on macOS
extension NSTextView {
open override var frame: CGRect {
didSet {
backgroundColor = .clear
drawsBackground = true
}
}
}
struct ContentView: View {
#State var text = ""
var body: some View {
TextEditor(text: $text)
.background(Color.red)
}
Reference this answer
To achieve this visual design here is the code I used.
iOS 16
TextField(
"free_form",
text: $comment,
prompt: Text("Type your feedback..."),
axis: .vertical
)
.lineSpacing(10.0)
.lineLimit(10...)
.padding(16)
.background(Color.themeSeashell)
.cornerRadius(16)
iOS 15
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 16)
.foregroundColor(.gray)
TextEditor(text: $comment)
.padding()
.focused($isFocused)
if !isFocused {
Text("Type your feedback...")
.padding()
}
}
.frame(height: 132)
.onAppear() {
UITextView.appearance().backgroundColor = .clear
}
You can use Mojtaba's answer (the approved answer). It works in most cases. However, if you run into this error:
"Return from initializer without initializing all stored properties"
when trying to use the init{ ... } method, try adding UITextView.appearance().backgroundColor = .clear to .onAppear{ ... } instead.
Example:
var body: some View {
VStack(alignment: .leading) {
...
}
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
Using the Introspect library, you can use .introspectTextView for changing the background color.
TextEditor(text: .constant("Placeholder"))
.cornerRadius(8)
.frame(height: 100)
.introspectTextView { textView in
textView.backgroundColor = UIColor(Color.red)
}
Result
import SwiftUI
struct AddCommentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
VStack {
if #available(iOS 16.0, *) {
TextEditor(text: $viewModel.commentText)
.scrollContentBackground(.hidden)
} else {
TextEditor(text: $viewModel.commentText)
}
}
.background(Color.blue)
.frame(height: UIScreen.main.bounds.width / 2)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.red, lineWidth: 1)
)
}
}
It appears the UITextView.appearance().backgroundColor = .clear trick in IOS 16,
only works for the first time you open the view and the effect disappear when the second time it loads.
So we need to provide both ways in the app. Answer from StuFF mc works.
var body: some View {
if #available(iOS 16.0, *) {
mainView.scrollContentBackground(.hidden)
} else {
mainView.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
}
// rename body to mainView
var mainView: some View {
TextEditor(text: $notes).background(Color.red)
}

Get a UIViewControllerRepresentableContext environment value?

Ok,
so this might be trivial, but I am not sure how to go about this.
I have a UIViewController that gets created when the SwiftUI view calls:
func makeUIViewController(context: Context) -> MyViewController
The View that makes that call was given an environment object in the SceneDelegate like we have seen in the tutorials:
window.rootViewController = UIHostingController(rootView: ContentView().environmentObject(MyData()))
What I am trying to do is to use that environment object (MyData()) within my UIViewController logic. The ViewController would read/write on MyData's instance as needed, and from what I understand that should cause the SwiftUI view to react accordingly since MyData conforms to BindableObject...
So in the makeUIViewController call I get the UIViewControllerRepresentableContext. I can see the environment in the context:
context.environment
and if I print it in the console during debug I see this:
context.environment: [EnvironmentPropertyKey<PreferenceBridgeKey> = Value(value: Optional(SwiftUI.PreferenceBridge)), EnvironmentPropertyKey<FontKey> = Optional(SwiftUI.Font(provider: SwiftUI.(unknown context at $1c652cbec).FontBox<SwiftUI.Font.(unknown context at $1c656e2cc).TextStyleProvider>)), .......
In the print I see the MyData environmentObject instance:
EnvironmentPropertyKey<StoreKey<MyData>> = Optional(MyApp.MyData), ...
I am not sure how to get MyData out of the environment values given to me in the context.environment....
I have tried to figure out how to get the proper EnvironmentKey for MyData so I could try access it view subscript ... context.environment[myKey...]
How can I get MyData back from the environment values given to me by the context?
Using #EnvironmentObject now works (but not in Xcode Preview). Used Xcode 11.1/Swift 5.1. For simplicity it was used UIViewRepresentable, but the same should work for UIViewControllerRepresentable, because it is also SwiftUI View
Here is complete demo
import SwiftUI
import Combine
import UIKit
class AppState: ObservableObject {
#Published var simpleFlag = false
}
struct CustomUIView: UIViewRepresentable {
typealias UIViewType = UIButton
#EnvironmentObject var settings: AppState
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: UIButton.ButtonType.roundedRect)
button.setTitle("Tap UIButton", for: .normal)
button.actionHandler(controlEvents: UIControl.Event.touchUpInside) {
self.settings.simpleFlag.toggle()
}
return button
}
func updateUIView(_ uiView: UIButton, context: UIViewRepresentableContext<CustomUIView>) {
}
}
struct ContentView: View {
#ObservedObject var settings: AppState = AppState()
var body: some View {
VStack(alignment: .center) {
Spacer()
CustomUIView()
.environmentObject(self.settings)
.frame(width: 100, height: 40)
.border(Color.blue)
Spacer()
if self.settings.simpleFlag {
Text("Activated").padding().background(Color.red)
}
Button(action: {
self.settings.simpleFlag.toggle()
}) {
Text("SwiftUI Button")
}
.padding()
.border(Color.blue)
}
.edgesIgnoringSafeArea(.all)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(AppState())
}
}
/// Just utility below
extension UIButton {
private func actionHandler(action:(() -> Void)? = nil) {
struct __ { static var action :(() -> Void)? }
if action != nil { __.action = action }
else { __.action?() }
}
#objc private func triggerActionHandler() {
self.actionHandler()
}
func actionHandler(controlEvents control :UIControl.Event, for action:#escaping () -> Void) {
self.actionHandler(action: action)
self.addTarget(self, action: #selector(triggerActionHandler), for: control)
}
}
I had the same question and it was answered by Apples excellent tutorial https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit.
What you do basically is pass a binding into your ViewController.
import SwiftUI
import UIKit
struct PageViewController<Page: View>: UIViewControllerRepresentable {
var pages: [Page]
#Binding var currentPage: Int
...
Which is then passed the binding like so:
import SwiftUI
struct PageView<Page: View>: View {
var pages: [Page]
#State private var currentPage = 0
var body: some View {
ZStack(alignment: .bottomTrailing) {
PageViewController(pages: pages, currentPage: $currentPage)
// Here is the binding -^
PageControl(numberOfPages: pages.count, currentPage: $currentPage)
.frame(width: CGFloat(pages.count * 18))
.padding(.trailing)
}
}
}
This also works of course with #EnvironmentObject instead of #State

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:
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!
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:
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
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.
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)
}
}
}
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
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")
}
}
}
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)
}
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)
}
}
}