quanshousio / ToastUI

A simple way to show toast in SwiftUI.
MIT License
566 stars 45 forks source link

Hide toast when system alert occurs #29

Closed nepysb closed 5 months ago

nepysb commented 2 years ago

Pre-requisites:

Feature Suggestion

If a toast is currently shown and a system alert appears (e.g. contacts permission request), add a possibility to hide the toast so they do not overlap.

Context

  1. Start a new iOS Project with Lifecycle Storyboard
  2. In the ViewController.swift replace the code with the code below.
  3. Add NSContactsUsageDescription key in info.plist.
  4. Build the project
  5. Tap "show toast" button. After 5 seconds from start, a system alert will appear above the toast so that they are overlapping.

Is there a way to automatically hide toast when a system alert appears ? This is obviously just a minimum working example, at the end this can be any system alert. Another thing is, sometimes alerts will come from external libs making it even harder to manage.

import UIKit
import SwiftUI
import Contacts
import ToastUI

struct ContentView: View {
    @State var showToast = false

    var body: some View {
        VStack {
            Button {
                showToast = true
            } label: {
                Text("show toast")
            }
            .toast(isPresented: $showToast) {
                ZStack {
                    Color.black
                        .frame(width: 300, height: 300)
                    ProgressView()
                        .tint(.white)
                        .scaleEffect(4)

                }
            }
        }
    }
}

class ViewController: UIViewController {
    var contactStore = CNContactStore()
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
//
        let hostingController = UIHostingController(rootView: ContentView())
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in

            requestForAccess { val in
                print(val.description)
            }
        }

        self.navigationController?.pushViewController(hostingController, animated: false)
    }

    fileprivate func requestForAccess(completionHandler: @escaping (_ accessGranted: Bool) -> Void) {
            let authorizationStatus = CNContactStore.authorizationStatus(for: CNEntityType.contacts)

            switch authorizationStatus {
            case .authorized:
                completionHandler(true)

            case .notDetermined:
                self.contactStore.requestAccess(for: CNEntityType.contacts, completionHandler: { (access, accessError) -> Void in
                    if access {
                        completionHandler(access)
                    }
                    else {
                        completionHandler(false)
                    }
                })

            default:
                completionHandler(false)
            }
        }

}
quanshousio commented 2 years ago

Thanks for your detailed report. Unfortunately, there is no way to control the system alerts (e.g. asking permissions from users) programmatically in iOS. System alerts are presented from SpringBoard so they are outside control of our application.

If you are talking about alerts that is presented in the application, then it is correct that ToastUI will overlap with those. Specifically, views that are presented by ToastUI will always be the frontmost view in the application. Therefore, in this example, alert will always stay behind the toast no matter the order of appearance. This is an expected behavior.

struct ContentView: View {
  @State var presentToast = false
  @State var presentAlert = false

  var body: some View {
    VStack {
      Button {
        presentToast = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
          presentAlert = true
        }
      } label: {
        Text("Show alert then toast")
      }
      .padding()

      Button {
        presentAlert = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
          presentToast = true
        }
      } label: {
        Text("Show toast then alert")
      }
      .padding()
    }
    .toast(isPresented: $presentToast, dismissAfter: 4.0) {
      ToastView()
        .toastViewStyle(.icon(content: {
          ProgressView()
            .frame(width: 150, height: 150)
            .scaleEffect(4.0)
        }))
    }
    .alert("Alert", isPresented: $presentAlert) {
      Button("OK") {}
    }
  }
}

There are ways to handle this but it depends on the context. From my perspective, I think it is best to not show the toast when we know we will trigger system alerts (e.g. calling requestAccess()) or user alerts from external libraries. If you still want to show the progress indicator when calling such APIs, here's a workaround:

struct ContentView: View {
  @State var requestingAccess = false

  var body: some View {
    ZStack {
      Button {
        // shows the ProgressView
        requestingAccess = true

        requestAccess { result in
          print(result)

          // hides the ProgressView
          requestingAccess = false
        }
      } label: {
        Text("Request access")
      }
      .padding()

      if requestingAccess {
        ToastView()
          .toastViewStyle(.icon {
            ProgressView()
              .frame(width: 150, height: 150)
              .scaleEffect(4.0)
          })
      }
    }
  }

  // external API that are not in SwiftUI realm
  private func requestAccess(_ completion: @escaping (Bool) -> Void) {
    print("requesting access")

    // create a new alert
    let alertViewController = UIAlertController(
      title: "Request Access",
      message: "ToastUI would like to authorize on your behalf",
      preferredStyle: .alert
    )
    let okAction = UIAlertAction(title: "OK", style: .default) { _ in
      completion(true)
    }
    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
      completion(false)
    }
    alertViewController.addAction(okAction)
    alertViewController.addAction(cancelAction)

    // present the alert
    let rootViewController = UIApplication.shared.windows.first!.rootViewController! // deprecated
    rootViewController.present(alertViewController, animated: true)
  }
}
nepysb commented 2 years ago

Hey, thanks a lot for the comprehensive response and snippets. The suggested workaround is close to what I could use, but there is one major problem with it - it does not block the UI, so that other elements in the view can still be tapped. Since it is a progress indicator I need to prevent tapping other elements. Please see the snippet below.

struct ContentView: View {
  @State var showToast = false

  var body: some View {
    ZStack {
        VStack {
            Button {
                print("tapped")
            } label: {
                Text("tap to print")
            }.padding(.bottom, 200)

            Button {
                showToast = true
            } label: {
                Text("Show toast")
            }
            .padding()
            //this will block the UI
        }/*.toast(isPresented: $showToast, content: {
            ProgressView()
              .frame(width: 150, height: 150)
              .scaleEffect(4.0)
        })*/

        //this does not block the UI
      if showToast {
        ToastView()
          .toastViewStyle(.icon {
            ProgressView()
              .frame(width: 150, height: 150)
              .scaleEffect(4.0)
          })
      }
    }
  }
}
quanshousio commented 2 years ago

You can add a full screen Color background behind the ProgressView that blocks views behind it.

if showToast {
  ToastView()
    .toastViewStyle(.icon {
      ProgressView()
        .frame(width: 150, height: 150)
        .scaleEffect(4.0)
    })
    .background {
      Color.primary
        .opacity(0.3)
        .ignoresSafeArea()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}
quanshousio commented 5 months ago

This issue has been closed due to inactivity. Please feel free to reopen it if you have any further questions.