Всего пару недель назад я обсуждал в этом предыдущем посте MFMailComposeViewController; класс, который позволяет представить и использовать предоставленный системой контроллер представления для создания и отправки электронных писем через наши собственные приложения. Обсуждение в этом сообщении было вокруг приложения на основе UIKit, объясняющего, как работает этот класс.

Сегодня в этом посте я расскажу о том, как интегрировать MFMailComposeViewController в проекты SwiftUI, поскольку нативной альтернативы SwiftUI нет. Но это будет только половина контента; вторая половина представляет собой пошаговое руководство, как скрыть все за настраиваемым модификатором представления, превратив таким образом составление электронной почты в очень похожую на SwiftUI и простую задачу.

Чтобы получить представление, вот что у нас получится:

Button("Send Email") {
    showEmailComposer = true
}
.emailComposer(isPresented: $showEmailComposer,
               emailData: emailData) { result in
    // Handle send results.
}

Обратите внимание, что в этом посте я не буду подробно рассматривать MFMailComposeViewController. Я рассказал об этом в первом посте, поэтому здесь я просто продемонстрирую, как интегрировать его в SwiftUI.

Тип EmailData

При представлении экземпляра MFMailComposeViewController мы можем дополнительно предоставить ему содержимое по умолчанию. Этот контент может включать тему, одного или нескольких получателей, текст электронного письма, вложения и многое другое.

Чтобы со всем этим легко справиться, мы начнем с создания пользовательского типа, который будет содержать любые данные, которые мы можем установить, прежде чем представить компоновщик электронной почты. Это простая структура с несколькими свойствами и внутренним пользовательским типом для представления данных вложения:

struct EmailData {
    var subject: String = ""
    var recipients: [String]?
    var body: String = ""
    var isBodyHTML = false
    var attachments = [AttachmentData]()
    
    struct AttachmentData {
        var data: Data
        var mimeType: String
        var fileName: String
    }
}

Обратите внимание, что тип данных каждого свойства соответствует типу данных соответствующего свойства в классе MFMailComposeViewController. EmailData будет очень удобен, и мы задействуем его в следующей части.

EmailComposerView

MFMailComposeViewController — это контроллер представления UIKit, поэтому мы не можем использовать его просто так в SwiftUI. Чтобы перенести его в SwiftUI, необходимо работать с ним в пользовательском типе, соответствующем протоколу UIViewControllerRepresentable. Экземпляр этого типа — это то, что мы будем использовать в представлениях SwiftUI.

Мы назовем этот пользовательский тип EmailComposerView. В соответствии с требованиями протокола UIViewControllerRepresentable необходимо реализовать следующие два метода:

struct EmailComposerView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> MFMailComposeViewController {
        
    }
    
    func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) { }
}

Примечание. Импортируйте структуру MessageUI, чтобы получить доступ к классу MFMailComposeViewController.

Первый метод — это место для инициализации, настройки и возврата экземпляра MFMailComposeViewController. Во-вторых, изменения, поступающие из SwiftUI, вызывают обновления контроллера представления. Здесь он нам не понадобится, поэтому оставим его пустым.

Прежде чем мы перейдем к коду makeUIViewController(context:), давайте объявим следующие свойства для EmailComposerView:

struct EmailComposerView: UIViewControllerRepresentable {
    @Environment(\.presentationMode) private var presentationMode
    let emailData: EmailData
    var result: (Result<MFMailComposeResult, Error>) -> Void
    
    ...
}

Вот их назначение:

  • Экземпляр EmailComposerView будет представлен на модальном листе, а свойство среды presentationMode позволит нам легко его закрыть.
  • emailData будет содержать любые предопределенные значения и данные для передачи экземпляру MFMailComposeViewController.
  • Свойство result — это замыкание, которое будет указывать либо результат отправки электронной почты, либо любую потенциально возникшую ошибку. Обратите внимание, что MFMailComposeResult — это перечисление со следующими случаями, касающимися электронной почты: отправлено, сохранено, отменено, не удалось. Все они являются значениями Int.

Теперь, когда все это доступно, давайте сосредоточимся на первом методе. Мы начнем с инициализации объекта MFMailComposeViewController:

func makeUIViewController(context: Context) -> MFMailComposeViewController {
    let emailComposer = MFMailComposeViewController()
}

Далее мы установим объект делегата, через который мы будем получать сообщения от MFMailComposeViewController. Это не может быть экземпляр self; EmailComposerView — это тип значения, а объект делегата должен быть ссылочным типом или, другими словами, экземпляром класса.

Чтобы управлять этим в типах UIViewControllerRepresentable, необходимо определить класс Coordinator, который соответствует протоколам делегатов и реализует различные методы делегатов. Мы сделаем это через мгновение.

Доступ к объекту этого класса в методе makeUIViewController(context:) осуществляется через значение параметра context, и мы будем использовать его прямо сейчас, чтобы установить mailComposeDelegate в экземпляре emailComposer:

func makeUIViewController(context: Context) -> MFMailComposeViewController {
    ...
    
    emailComposer.mailComposeDelegate = context.coordinator
}

Прежде чем мы вернем объект emailComposer из метода, мы предоставим ему весь потенциальный предопределенный контент, используя объект emailData следующим образом:

func makeUIViewController(context: Context) -> MFMailComposeViewController {
    ...
    
    emailComposer.setSubject(emailData.subject)
    emailComposer.setToRecipients(emailData.recipients)
    emailComposer.setMessageBody(emailData.body, isHTML: emailData.isBodyHTML)
    for attachment in emailData.attachments {
        emailComposer.addAttachmentData(attachment.data, mimeType: attachment.mimeType, fileName: attachment.fileName)
    }
}

Наконец, мы вернем объект emailComposer. Весь метод таков:

func makeUIViewController(context: Context) -> MFMailComposeViewController {
    let emailComposer = MFMailComposeViewController()
    emailComposer.mailComposeDelegate = context.coordinator
    emailComposer.setSubject(emailData.subject)
    emailComposer.setToRecipients(emailData.recipients)
    emailComposer.setMessageBody(emailData.body, isHTML: emailData.isBodyHTML)
    for attachment in emailData.attachments {
        emailComposer.addAttachmentData(attachment.data, mimeType: attachment.mimeType, fileName: attachment.fileName)
    }
    return emailComposer
}

Прежде чем двигаться дальше, давайте также определим статический метод, который будет указывать, действительно ли устройство может отправлять электронные письма или нет. Это необязательный шаг, который поможет сохранить все вокруг нашего пользовательского типа:

struct EmailComposerView: UIViewControllerRepresentable {
    ...
    
    static func canSendEmail() -> Bool {
        MFMailComposeViewController.canSendMail()
    }
}

Класс координатора

Следующим шагом является определение класса Coordinator внутри EmailComposerView, который будет иметь дело с сообщениями делегата, поступающими из части UIKit.

struct EmailComposerView: UIViewControllerRepresentable {
    ...
        
    class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
        var parent: EmailComposerView
        
        init(_ parent: EmailComposerView) {
            self.parent = parent
        }
    }
}

Обратите внимание, что класс Coordinator наследуется от NSObject и принимает MFMailComposeViewControllerDelegate. Свойство parent — это экземпляр EmailComposerView, который предоставляется классу при инициализации.

Существует только один метод делегата для реализации. В нем мы проверим, произошла ошибка или нет. Если это так, то мы вызовем result закрытие parent, указав на сбой и передав ошибку. В противном случае мы снова вызовем result, но на этот раз укажем успех и передадим результат компоновки. В любом случае мы закроем лист, содержащий экземпляр компоновщика почты, используя свойство среды presentationMode.

Вот все это:

class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
    ...
    
    func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
        
        if let error = error {
            parent.result(.failure(error))
            return
        }
        
        parent.result(.success(result))
        
        parent.presentationMode.wrappedValue.dismiss()
    }
}

Класс координатора завершен. В EmailComposerView отсутствует еще одна вещь; для создания экземпляра координатора. Мы делаем это в другом методе, специфичном для этой цели:

struct EmailComposerView: UIViewControllerRepresentable {
    ...
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
}

Настраиваемый тип EmailComposerView готов! Давайте продолжим, чтобы сначала использовать его, а затем посмотрим, как создать пользовательский модификатор представления, чтобы сделать вещи короче, проще и элегантнее.

Примечание. Пожалуйста, прочитайте о протоколе UIViewControllerRepresentable, если вы не знакомы с каким-либо из вышеперечисленных шагов.

Использование EmailComposerView

Предположим, что у нас есть следующее представление SwiftUI с некоторой начальной реализацией:

struct ContentView: View {
    @State private var showEmailComposer = false
    @State private var showAlert = false
    @State private var alertMessage: String = ""
    
    let emailData = EmailData(subject: "Hi there!", recipients: ["[email protected]"])
    
    var body: some View {
        Button("Send Email") {
            
        }
        .sheet(isPresented: $showEmailComposer, content: {
            
        })
        .alert(isPresented: $showAlert, content: {
            Alert(title: Text("Send Email"),
                  message: Text(alertMessage),
                  dismissButton: .default(Text("Dismiss")))
        })
    }
}

Целью представления является представление составителя электронной почты при нажатии на кнопку «Отправить электронную почту». Модификатор представления sheet(isPresented:onDismiss:content:) существует в приведенном выше фрагменте, потому что он будет содержать экземпляр EmailComposerView в качестве своего содержимого.

Смотрите также, что есть предупреждение; нам нужно, чтобы отображать сообщение пользователю, если устройство не может отправить электронное письмо. В этой конкретной демонстрации у предупреждения есть дополнительная роль. Мы будем использовать его, чтобы сообщить об успешной или неудачной отправке электронной почты.

В приведенном выше фрагменте вы можете увидеть три свойства состояния. В порядке появления они указывают и определяют представленное состояние как листа, так и предупреждения, а также сохраняют сообщение, которое будет отображаться в предупреждении. Мы также создаем экземпляр EmailData с несколькими значениями по умолчанию; субъекта и получателя.

Теперь нам нужно сделать две вещи, чтобы представить композитор электронной почты. Первый — запустить презентацию листа при закрытии действия кнопки. Однако мы сделаем это, как только убедимся, что устройство действительно может отправлять электронные письма. Если это невозможно, вместо этого мы покажем предупреждение:

Button("Send Email") {
    if EmailComposerView.canSendEmail() {
        showEmailComposer = true
    } else {
        alertMessage = "Unable to send an email from this device."
        showAlert = true
    }
}

Второе — инициализировать экземпляр EmailComposerView в содержимом листа:

.sheet(isPresented: $showEmailComposer, content: {
    EmailComposerView(emailData: emailData) { result in
        handleEmailComposeResult(result)
    }
})

Обратите внимание, что мы передаем свойство emailData в качестве аргумента для EmailComposerView. Кроме того, handleEmailComposeResult(_:) — это настраиваемый метод, который обрабатывает результат создания электронной почты способом, специфичным для этого примера. Скорее всего, в реальном приложении была бы более уместна другая обработка. Метод handleEmailComposerResult(_:) таков:

func handleEmailComposeResult(_ result: Result<MFMailComposeResult, Error>) {
    switch result {
        case .success(let result):
            let resultString = ["Cancelled", "Saved", "Sent", "Failed"][result.rawValue]
            alertMessage = "Email result: \(resultString)"
        
        case .failure(let error):
            alertMessage = "Failed to send email.\n\(error.localizedDescription)"
    }
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
        showAlert = true
    }
}

После всего вышесказанного вся реализация представления теперь такая:

struct ContentView: View {
    @State private var showEmailComposer = false
    @State private var showAlert = false
    @State private var alertMessage: String = ""
    
    let emailData = EmailData(subject: "Hi there!", recipients: ["[email protected]"])
    
    var body: some View {
        Button("Send Email") {
            if EmailComposerView.canSendEmail() {
                showEmailComposer = true
            } else {
                alertMessage = "Unable to send an email from this device."
                showAlert = true
            }
        }
        .sheet(isPresented: $showEmailComposer, content: {
            EmailComposerView(emailData: emailData) { result in
                handleEmailComposeResult(result)
            }
        })
        .alert(isPresented: $showAlert, content: {
            Alert(title: Text("Send Email"),
                  message: Text(alertMessage),
                  dismissButton: .default(Text("Dismiss")))
        })
    }
}

Это самая короткая реализация, которую мы можем сделать, чтобы представить MFMailComposeViewController через EmailComposerView в SwiftUI. И хотя это просто, его нелегко использовать повторно.

Итак, давайте продолжим выяснять, как сделать вещи проще и легче для повторного использования с помощью пользовательского модификатора представления.

Создание пользовательского модификатора представления

Вы можете думать о модификаторе представления, который мы здесь создадим, как о листе особого типа. Это потому, что мы собираемся фактически содержать лист в модификаторе и иметь экземпляр EmailComposerView в качестве содержимого листа.

Имея в виду эту информацию, мы приступим к реализации нового пользовательского типа, который будет соответствовать протоколу ViewModifier. Сначала мы объявим несколько свойств, которые мы можем сгруппировать в две категории; те, которые необходимы для листа, и те, которые необходимы для EmailComposerView:

struct EmailComposer: ViewModifier {
    @Binding var isPresented: Bool
    var emailData: EmailData
    var onDismiss: (() -> Void)? = nil
    var result: (Result<MFMailComposeResult, Error>) -> Void
}

Обратите внимание, что среди других объявлено замыкание onDismiss. Он у нас есть, потому что мы можем инициализировать лист с таким замыканием и выполнять возможные дополнительные действия, когда мы его закроем.

Далее мы реализуем требуемый метод body(content:) по протоколу ViewModifier. В значении параметра content мы применим модификатор вида листа. Однако обратите внимание на кое-что важное здесь; мы не будем просто содержать экземпляр EmailComposerView на листе. Мы проверим, может ли устройство отправить электронное письмо, и если нет, то вместо этого мы отобразим текстовое представление с соответствующим сообщением и кнопкой отклонения!

Для этого есть причина. Таким образом мы избавимся от оповещения в представлении SwiftUI, если оно нам не нужно для каких-либо других целей. Итак, как вы понимаете, у листа будет двойная роль. Чтобы отобразить либо EmailComposerView, либо текстовое представление вместе с кнопкой.

Вот реализация:

struct EmailComposer: ViewModifier {
    ...
        
    func body(content: Content) -> some View {
        content
            .sheet(isPresented: $isPresented, onDismiss: onDismiss) {
                if EmailComposerView.canSendEmail() {
                    EmailComposerView(emailData: emailData) { result in
                        self.result(result)
                    }
                } else {
                    VStack {
                        Text("Unable to send an email from this device.")
                        Button("Dismiss") {
                            isPresented = false
                        }
                    }
                }
            }
    }
}

Обратите внимание, что в случае, если устройство может отправлять электронные письма, мы передаем результат EmailComposerView в качестве аргумента результату экземпляра EmailComposer. В противном случае мы устанавливаем false для свойства привязки isPresented, которое управляет представленным состоянием листа внутри закрытия действия кнопки закрытия.

Теперь мы можем использовать приведенное выше, как показано ниже:

var body: some View {
            
  Button("Send Email") {
      showEmailComposer = true
  }
  .modifier(EmailComposer(isPresented: $showEmailComposer,
                          emailData: emailData,
                          onDismiss: nil, result: { result in
      handleEmailComposeResult(result)
  }))
            
            
  // Alert is optional and specific to this example.
  .alert(isPresented: $showAlert, content: {
      ...
  })
}

В действии кнопки мы больше не проверяем, может ли устройство отправлять электронные письма или нет; мы обрабатываем это в модификаторе представления EmailComposer. Все, что нам нужно, это сделать свойство showEmailComposer истинным.

Но самая важная часть — это то, как мы используем модификатор EmailComposer. Единственный способ привести его в действие — передать его в качестве аргумента модификатору вида modifier().

Несмотря на то, что приведенное выше, несомненно, проще и короче по сравнению с первоначальной реализацией, оно все же не так элегантно. И это потому, что мы должны вызывать modifier() всякий раз, когда мы хотим использовать модификатор представления EmailComposer.

В качестве последнего шага в этом посте давайте изменим это и обеспечим более естественный подход.

Метод emailComposer

Чтобы создать модификатор представления, который выглядит как встроенные модификаторы в SwiftUI, определения типа ViewModifier (например, EmailComposer) недостаточно. Также необходимо расширить протокол View и определить новый метод. Имя этого метода должно быть именно тем, что мы хотим использовать и видеть в качестве конечного результата.

Чтобы продемонстрировать это, вот расширение протокола View с новым методом, определенным в нем:

extension View {
    func emailComposer(isPresented: Binding<Bool>,
                       emailData: EmailData,
                       onDismiss: (() -> Void)? = nil,
                       result: @escaping (Result<MFMailComposeResult, Error>) -> Void) -> some View {
        
    }
}

Значения параметров метода полностью совпадают со свойствами типа EmailComposer. Это потому, что в его теле мы собираемся вызвать модификатор представления modifier() и передать экземпляр EmailComposer:

extension View {
    func emailComposer(isPresented: Binding<Bool>,
                       emailData: EmailData,
                       onDismiss: (() -> Void)? = nil,
                       result: @escaping (Result<MFMailComposeResult, Error>) -> Void) -> some View {
 
        self.modifier(EmailComposer(isPresented: isPresented,
                                    emailData: emailData,
                                    onDismiss: onDismiss,
                                    result: result))
    }
}

Теперь мы можем вызывать вышеупомянутый метод, как и любой другой модификатор представления в SwiftUI:

Button("Send Email") {
    showEmailComposer = true
}
.emailComposer(isPresented: $showEmailComposer,
               emailData: emailData, onDismiss: nil) { result in
    handleEmailComposeResult(result)
}

На самом деле, мы можем вообще опустить аргумент onDismiss, если не хотим выполнять никаких действий при закрытии листа. Помните, что по умолчанию это ноль:

Button("Send Email") {
    showEmailComposer = true
}
.emailComposer(isPresented: $showEmailComposer, emailData: emailData) { result in
    handleEmailComposeResult(result)
}

Это определенно намного лучшая реализация, чем та, с которой мы начали!

Резюме

Составление и отправка электронных писем — обычная функция приложений. Сделать это несложно с помощью класса MFMailComposeViewController. Однако это контроллер представления UIKit, и требуется несколько дополнительных шагов, чтобы сделать его доступным в качестве представления в SwiftUI. В этом посте я прошел эти шаги и показал, как реализовать тип UIViewControllerRepresentable и получить представление SwiftUI. После этого я продемонстрировал, как взять это представление и скрыть его за пользовательским модификатором представления, и в конечном итоге получился аккуратный, удобный и похожий на SwiftUI способ представления компоновщика электронной почты. Надеюсь, что все это было для вас интересным, полезным и познавательным. И я хочу верить, что достаточно мотивировал вас, чтобы вы начали внедрять свои пользовательские модификаторы представления; они часто делают вещи проще. И мы можем использовать их повторно! Спасибо за чтение!

EmailComposer доступен в виде пакета Swift на Github.

Первоначально опубликовано на https://serialcoder.dev 17 июня 2021 г.