SwiftUI: Send email using MFMailComposeViewController - ios

I am currently trying to implement a "send email" button in my SwiftUI app, using SwiftUI lifecycle and targeting iOS 14.
I know there are quite some solutions presented online - here on stack overflow and elsewhere. However, I have-not been able to make anything work so far in simulator/on device.
My current solution looks like this (based on this question on stackoverflow:
import SwiftUI
import MessageUI
import Foundation
struct ContentView: View {
class MailComposeViewController: UIViewController, MFMailComposeViewControllerDelegate {
static let shared = MailComposeViewController()
func sendEmail() {
if MFMailComposeViewController.canSendMail() {
let mail = MFMailComposeViewController()
mail.mailComposeDelegate = self
mail.setToRecipients(["test#test.com"])
UIApplication.shared.windows.last?.rootViewController?.present(mail, animated: true, completion: nil)
} else {
// Alert
}
}
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true, completion: nil)
}
}
var body: some View {
Button(action: {
MailComposeViewController.shared.sendEmail()
}, label: {
Text("Send")
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The simulator does show the button and doesn't give me any errors. However, upon clicking the button, nothing happens at all - same thing when testing on device.
Any idea what might be wrong here?
Thanks!

Building up on the code snippet shared in my original question:
Based on the answer from #Arjun this is my current workaround to account for the edge case that someone might have deleted the Apple Mail app and is using another email app:
Button(action: {
if MailComposeViewController.shared.canSendMail() {
MailComposeViewController.shared.sendEmail()
} else {
openURL(URL(string: "mailto:someone#example.com?subject=This%20is%20the%20subject")!)
}
}, label: {
Text("Send")
})
It opens the in-app sheet as long as the user has set up apple mail and otherwise switches to any other email app using a mailto: link.

Your code works fine, the problem is that the iOS Simulator does not have the Mail app, thus MFMailComposeViewController.canSendMail() returns false. Try it on a physical device, it works. The reason you didn't see any errors is because of this block of code:
if MFMailComposeViewController.canSendMail() {
let mail = MFMailComposeViewController()
mail.mailComposeDelegate = self
mail.setToRecipients(["test#test.com"])
UIApplication.shared.windows.last?.rootViewController?.present(mail, animated: true, completion: nil)
} else {
// Alert
}
Inside the else block, instead of temporarily commenting // Alert, you should print something instead while debugging, it'll make your life a lot easier.

Related

Error creating the CFMessagePort needed to communicate with PPT

I try to present a CNContactPickerViewController inside a SwiftUI application using the UIViewControllerRepresentable protocol. As I already read, there seems to be a known issue for this not working, but I got it working quite ok using the workaround described here.
However, whenever the CNContactPickerViewController gets presented or dismissed resp., I get the following error in my output log:
[PPT] Error creating the CFMessagePort needed to communicate with PPT.
I tried to find explanations on this, but there seems to be no answer anywhere on the internet. Does someone know where this error comes from and what PPT is? Could this error have something to do with the CNContactPickerViewController not working properly with SwiftUI?
I noticed the error for the first time in the iOS 14 beta together with the Xcode 12 beta, and it is still present in iOS 14.2 with Xcode 12.2.
I don't know if the error appears on iOS 13 as well.
I already issued a feedback report about this.
I wrote a workaround using a hosting UINavigationController and here is my code:
import SwiftUI
import ContactsUI
struct ContactPickerView: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
func makeUIViewController(context: Context) -> UINavigationController {
let navController = UINavigationController()
let controller = CNContactPickerViewController()
controller.delegate = context.coordinator
navController.present(controller, animated: false, completion: nil)
return navController
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
print("Updating the contacts controller!")
}
// MARK: ViewController Representable delegate methods
func makeCoordinator() -> ContactsCoordinator {
return ContactsCoordinator(self)
}
class ContactsCoordinator : NSObject, UINavigationControllerDelegate, CNContactPickerDelegate {
let parent: ContactPickerView
public init(_ parent: ContactPickerView) {
self.parent = parent
}
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
print("Contact picked cancelled!")
parent.presentationMode.wrappedValue.dismiss()
}
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
print("Selected a contact")
parent.presentationMode.wrappedValue.dismiss()
}
}
}
And I use it like:
Button("Select a contact") {
openSelectContact.toggle()
}
.sheet(isPresented: $openSelectContact, onDismiss: nil) {
ContactPickerView()
}

SwiftUI: Catalyst Alert Showing Duplicate Buttons and Not Triggering Action

For some reason, the following code is displaying an Alert with three instances of the same button, none of which trigger the action (just a simple console output for an example) as expected:
Has anyone else experienced this? Any suggestions on a fix?
It's building on Xcode 11.2.1, for an iOS 13.0 target, then running on macOS (10.15.1) via Catalyst.
Update 1: This appears to be an issue specific to Catalyst. When the same code is run on an iPhone simulator, it shows one button and executes the action, as expected.
Update 2: The issue also wasn't fixed by updating to Xcode 11.3.1 and macOS 10.15.3.
public struct ContactUsView: View {
#ObservedObject private var contactUsVM: ContactUsViewModel
private var successAlert: Alert {
Alert(
title: Text("Email Sent"),
message: Text("Thanks for taking the time to reach out to us. We appreciate it!"),
dismissButton: .default(Text("OK")) {
self.dismissSelf()
}
)
}
public var body: some View {
Form {
// ...
}
.alert(isPresented: self.$contactUsVM.contactAttemptSucceeded) {
self.successAlert
}
}
public init() {
self.contactUsVM = ContactUsViewModel()
}
private func dismissSelf() {
print("Dismissing!")
}
}
class ContactUsViewModel: ObservableObject {
#Published var contactAttemptSucceeded: Bool = true
}
It seems that your code works fine on xCode 11.5 MacOs 0.15.4. If you run your example (I've just filled the hole in your code):
import SwiftUI
public struct ContactUsView: View {
#ObservedObject private var contactUsVM: ContactUsViewModel
private var successAlert: Alert {
Alert(
title: Text("Email Sent"),
message: Text("Thanks for taking the time to reach out to us. We appreciate it!"),
dismissButton: .default(Text("OK")) {
self.dismissSelf()
}
)
}
public var body: some View {
Form {
Text("Hello World")
}
.alert(isPresented: self.$contactUsVM.contactAttemptSucceeded) {
self.successAlert
}
}
public init() {
self.contactUsVM = ContactUsViewModel()
}
private func dismissSelf() {
print("Dismissing!")
}
}
class ContactUsViewModel: ObservableObject {
#Published var contactAttemptSucceeded: Bool = true
}
You'll see this:
This seems to be fixed on macOS Big Sur. Unfortunately, for those folks, who need to support macOS Catalina(me included), the only workaround is to create alert using UIAlertController.
The way I did that, is dispatching notification to SceneDelegate instance, and presenting UIAlertController on UIHostingController:
NotificationCenter.default.addObserver(forName: .showMailUnavailableAlert, object: nil, queue: nil) { [weak self] _ in
let controller = UIAlertController(title: "Default email client is not configured.", preferredStyle: .alert)
controller.addAction(.init(title: "Ok", style: .cancel, handler: nil))
self?.window?.rootViewController?.present(controller, animated: true, completion: nil)
}
extension NSNotification.Name {
static let showMailUnavailableAlert = NSNotification.Name("Email not configured.")
}
I don't know how to fix the duplicate buttons but to get the alert to dismiss you might need to add this line under the ObservedObject line:
#Environment(\.presentationMode) var presentationMode
and then add this:
presentationMode.wrappedValue.dismiss()
to your dismissSelf() func.
This is what I gleaned from a Hacking Swift video by Paul Hudson.

Cannot dismiss MFMailComposeViewController that I called from SKScene

I am trying to send an email from within my game app. In one of my SKScenes I have a sprite when you press it, it calls FeedbackVC().sendEmail(). This opens up the email viewController, but it does not dismiss properly. Here is my entire FeedbackVC class. I used the function getTopMostViewController because without it I was getting the error "Warning: Attempt to present on whose view is not in the window hierarchy!". My code will successfully open the MFMailComposeViewController with the prefilled fields and if I press the send button it actually will send to the email to my email, but it won't close and if I try to cancel the email it won't close either. Why won't my viewController close so it will continue back to my game after the email is sent or canceled?
import Foundation
import MessageUI
class FeedbackVC: UINavigationController, MFMailComposeViewControllerDelegate {
func getTopMostViewController() -> UIViewController? {
var topMostViewController = UIApplication.shared.keyWindow?.rootViewController
while let presentedViewController = topMostViewController?.presentedViewController {
topMostViewController = presentedViewController
}
return topMostViewController
}
func sendEmail() {
if MFMailComposeViewController.canSendMail() {
let mail = MFMailComposeViewController()
mail.mailComposeDelegate = self
mail.setToRecipients(["support#supportemail.com"])
mail.setSubject("In-App Feedback")
mail.setMessageBody("", isHTML: false)
self.getTopMostViewController()!.present(mail, animated: true, completion: nil)
} else {
print("Failed To Send Email!")
}
}
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true, completion: nil)
}
}
I have also tried setting the UINavigationControllerDelegate in the sendEmail() function.
mail.delegate = self as? UINavigationControllerDelegate
I have also tried things like popping the view controller and going back to the top most view controller in the mailComposeController.
popToRootViewContoller(animated: true)
getTopMostViewController()?.dismiss(animated: true, completion: nil)
I've tried following the guide on, https://developer.apple.com/documentation/messageui/mfmailcomposeviewcontroller, but it didn't work as I think my scenario is different since I am going from a SKScene to the MFMailCompose ViewController then back to a SKScene.
I'm one of the other developers working on this project. Posting in case someone has similar problems.
We were attempting to call our FeedbackVC in a way that looked like this:
if nodeTapped.name == "Feedback" {
let vc = FeedbackVC()
vc.emailButtonTapped(foo)
}
This would create the FeedbackVC class, call the emailButtonTapped method, and then deallocate the class from memory upon exiting the if statement. This means that clicking cancel or send would attempt to access the deallocated space, causing an EXC_BAD_ACCESS error. I fixed this by declaring vc as a class variable instead of declaring it inside the if statement.

CNContactViewController Cancel Button Not Working

I'm trying to use the built-in new contact UI and am getting unexpected behavior with the cancel button. The code below works and calls up the new contact screen but the cancel button will only clear the screen entries not cancel out of the new contact screen. In the built in contacts app hitting cancel returns to the contact list screen. I would like the cancel button to close out the window.
#IBAction func newTwo(sender: AnyObject) {
AppDelegate.getAppDelegate().requestForAccess { (accessGranted) -> Void in
if accessGranted {
let npvc = CNContactViewController(forNewContact: nil)
npvc.delegate = self
self.navigationController?.pushViewController(npvc, animated: true)
}
}
}
did you implement CNContactViewControllerDelegate methods?
Here's a link to documentation
for example:
func contactViewController(viewController: CNContactViewController, didCompleteWithContact contact: CNContact?) {
self.dismissViewControllerAnimated(true, completion: nil)
}
It worked for me using the following code:
Swift 3
func contactViewController(_ vc: CNContactViewController, didCompleteWith con: CNContact?) {
vc.dismiss(animated: true)
}
Also I changed the way I was calling the controller:
Instead of:
self.navigationController?.pushViewController(contactViewController, animated: true)
the solution was:
self.present(UINavigationController(rootViewController: contactViewController), animated:true)
I found the solution using the example code Programming-iOS-Book-Examples written by Matt Neuburg:
Better way to do the dismissing would be to check if the contact is nil and then dismiss it. The dismiss doesn't work if you've pushed the view controller from a navigation controller. You might have to do the following:
func contactViewController(viewController: CNContactViewController, didCompleteWithContact contact: CNContact?) {
if let contactCreated = contact
{
}
else
{
_ = self.navigationController?.popViewController(animated: true)
}
}

Present GameCenter authenticationVC again

I'm facing a little issue here and I hope someone will help me figure out what is wrong.
*The test project presented below can be find here : http://goo.gl/wz84aA (FR) or https://goo.gl/0m8LrZ (Mega.NZ) *
I'm trying to present to the user the authentification view controller proposed by apple for the GameCenter feature. More precisely, re-present it if he canceled it on the first time.
I have a game with a storyboard like that :
GameNavigationController :
class GameNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("showAuthenticationViewController"), name: PresentAuthenticationViewController, object: nil)
GameKitHelper.sharedInstance.authenticateLocalPlayer()
}
func showAuthenticationViewController() {
let gameKitHelper = GameKitHelper.sharedInstance
if let authenticationViewController = gameKitHelper.authenticationViewController {
self.topViewController.presentViewController(authenticationViewController, animated: true, completion: nil)
}
}
deinit {
NSNotificationCenter.defaultCenter().removeObserver(self)
}
}
MenuViewController :
class MenuViewController: UIViewController {
#IBAction func didTapLeaderboardBTN() {
// TRY 2
//if ( !GameKitHelper.sharedInstance.gameCenterEnabled) {
GameKitHelper.sharedInstance.authenticateLocalPlayer()
//} else {
GameKitHelper.sharedInstance.showGKGameCenterViewController(self)
//}
}
}
GameKitHelper :
import GameKit
import Foundation
let PresentAuthenticationViewController = "PresentAuthenticationViewController"
let singleton = GameKitHelper()
class GameKitHelper: NSObject, GKGameCenterControllerDelegate {
var authenticationViewController: UIViewController?
var lastError: NSError?
var gameCenterEnabled: Bool
class var sharedInstance: GameKitHelper {
return singleton
}
override init() {
gameCenterEnabled = true
super.init()
}
func authenticateLocalPlayer () {
let localPlayer = GKLocalPlayer.localPlayer()
localPlayer.authenticateHandler = { (viewController, error) in
self.lastError = error
if viewController != nil {
self.authenticationViewController = viewController
NSNotificationCenter.defaultCenter().postNotificationName(PresentAuthenticationViewController, object: self)
} else if localPlayer.authenticated {
self.gameCenterEnabled = true
} else {
self.gameCenterEnabled = false
}
}
}
func showGKGameCenterViewController(viewController: UIViewController!) {
if ( !self.gameCenterEnabled ) {
println("Local player is not authenticated")
// TRY 1
//self.authenticateLocalPlayer()
return
}
let gameCenterViewController = GKGameCenterViewController()
gameCenterViewController.gameCenterDelegate = self
gameCenterViewController.viewState = .Leaderboards
viewController.presentViewController(gameCenterViewController, animated: true, completion: nil)
}
func gameCenterViewControllerDidFinish(gameCenterViewController: GKGameCenterViewController!) {
gameCenterViewController.dismissViewControllerAnimated(true, completion: nil)
}
}
What is currently working :
if the user is previously logged-in (within the GameCenter app) then he's able to open the leaderboard view ;
if the user wasn't logged-in then he's prompted to log-in when the game navigation controller is loaded (and then open the leaderboard).
What is NOT currently working :
if he cancel three time the authentification, then the authentification won't appear anymore (even at launch) ; // Apparently a known "problem", not "fixable"
if the user cancel his authentification, when he tries to load the leaderboard the authentification won't appear again.
I tried 2-3 things as you can see in the commented code above, but none of them is working ; I can't make the authentification view appear again.
PS : My code is written in Swift, but help in Objective-C is welcomed as well.
As you have found out, if the Game Center authentication dialog is canceled 3 times, then you can't bring it back without resetting the device.
There is another "security feature" built into Game Center which does not allow an app to re-authenticate if the user has already canceled the dialog without leaving the app. So for your authentication dialog to show, the user must leave and then re-enter your app.
There is really no way around it. What I've done in a couple of projects is to display a message to the user:
Game Center not available. Please make sure you are signed in through the Game Center app
I will show that message after trying to authenticate and if Game Center isn't available or the user is not signed in.
If you want to be able to re-present this to your user then go to settings -> General -> Reset -> -> Reset Location & Privacy.
This will force iOS to forget preferences for apps for example whether they can use location services, send you push notifications and also game centre preferences. Keep in mind this will reset privacy settings for all apps.

Resources