Using intents to leave the app

In an iOS application, we often have to redirect the user outside of the application for a variety of reasons: calling a number, sharing content, writing an email, etc.

UIKit comes with a number of built in ways to perform these actions but there is not an unified method to do it. And sometimes you even want to use third party schemes instead of native ones (using Google Maps instead of Maps for instance).

Let’s see with a few examples how we could hide this logic into more abstract types and simplify the calling site.

Mail

Let’s say we want to display the user a mail compose sheet. Thanks to the documentation, the right class to use is MFMailComposeViewController.

Use this view controller to display a standard email interface inside your app.

The detail here is that MFMailComposeViewController is a class of the MessageUI framework. And this detail bothers me because I try to keep the imported frameworks to the minimum. I don’t want to import MessageUI in my view controller class, and rather hide implementation details.

A solution in this case is to use the MFMailComposeViewController in a specific object that will not be visible to the external world.

For this, let’s borrow the intent naming of Android developers and create a MailIntent that will be used by the sender to inform its intent to display a mail interface, but without knowing how.

protocol MailIntent {
    func mail(to recipient: String)
}

The caller can use it like so:

class ContactViewController {

    private let mailIntent: MailIntent

    ...

    func requestEmail(to recipient: String) {
        mailIntent.mail(to: recipient)
    }

}

The implementation of MailIntent can import MessageUI and use MFMailComposeViewController. All the MessageUI code is constrained into this class. That also means we can use the intent in different places in the application, the display of the mail interface will always be the same and we won’t have to repeat ourselves.

import MessageUI

class NativeMailIntent: NSObject, MailIntent, MFMailComposeViewControllerDelegate {

    private let viewController: UIViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
    }

    // MARK: - MailIntent

    func mail(to recipient: String) {
        guard MFMailComposeViewController.canSendMail() else {
            return
        }
        let mailComposeViewController = MFMailComposeViewController()
        mailComposeViewController.mailComposeDelegate = self
        mailComposeViewController.setToRecipients([recipient])
        viewController.present(mailComposeViewController, animated: true)
    }

    // MARK: - MFMailComposeViewControllerDelegate

    func mailComposeController(_ controller: MFMailComposeViewController,
                               didFinishWith result: MFMailComposeResult,
                               error: Error?) {
        viewController.dismiss(animated: true)
    }

}

That also means that if we want to change the way we send an email, it’s simple. For example if we choose to redirect the user to the Mail app instead of opening a compose sheet, we could just rewrite the mail(to:) function like so:

class NativeMailIntent: MailIntent {

    private let application: UIApplication

    init(application: UIApplication) {
        self.application = application
    }

    // MARK: - MailIntent

    func mail(to recipient: String) {
        let mailURLString = "mailto:\(mailTo.removeWhitespaces())"
        guard let url = URL(string: mailURLString),
            application.canOpenURL(url) else {
                return
        }
        application.open(url, options: [:])
    }

}

What’s more, we all know that MFMailComposeViewController is not working on simulator. That means we can’t be sure our actions that display the compose sheet are well implemented when we test on simulator (either manually or with UI tests). In this case we can create a dummy implementation of MailIntent that will just display an alert controller with the recipient as the message.

class DebugMailIntent: MailIntent {

    private let viewController: UIViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
    }

    // MARK: - MailIntent

    func mail(to recipient: String) {
        let alert = UIAlertController(
            title: "DebugMailIntent",
            message: "Recipient \(recipient)",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Ok", style: .cancel))
        viewController.present(alert, animated: true)
    }

}

Map

We can apply the same technique for opening map items into Maps application and hide the import of the MapKit framework.

struct MapItem {
    let title: String
    let coordinate: CLLocationCoordinate2D
}

protocol MapIntent {
    func open(item: MapItem)
}

The implementation will be:

import MapKit

class NativeMapIntent: MapIntent {

    // MARK: - MapIntent

    func open(item: MapItem) {
        let placemark = MKPlacemark(
            coordinate: item.coordinate,
            addressDictionary: nil
        )
        let mapItem = MKMapItem(placemark: placemark)
        mapItem.name = item.title
        mapItem.openInMaps(launchOptions: [:])
    }

}

What’s interesting in this case is that we can create another implementation for Google Maps, a third party application.

class GoogleMapIntent: MapIntent {

    private let application: UIApplication

    init(application: UIApplication) {
        self.application = application
    }

    // MARK: - MapIntent

    func open(item: MapItem) {
        let urlString = "comgooglemaps://?daddr=\(item.coordinate.latitude),\(item.coordinate.longitude)"
        guard let url = URL(string: urlString),
            application.canOpenURL(url) else {
                return
        }
        application.open(url, options: [:])
    }

}

Phone Call

Even if there is no framework to hide in this case, the naming of the intent makes things very convenient to use.

protocol PhoneIntent {
    func call(phone: String)
}

The implementation will be:

class NativePhoneIntent: PhoneIntent {

    private let application: UIApplication

    init(application: UIApplication) {
        self.application = application
    }

    // MARK: - PhoneIntent

    func call(phone: String) {
        guard !phone.isEmpty else { return }
        let phoneURLString = "tel://\(phone.ad_removingWhitespaces())"
        guard let url = URL(string: phoneURLString),
            application.canOpenURL(url) else {
                return
        }
        application.open(url, options: [:])
    }

}

In this case we directly call the provided number, but we could imagine passing a view controller to the native intent, and display an alert before calling.

Conclusion

This simple technique allows two things: