https://d226lax1qjow5r.cloudfront.net/blog/blogposts/building-a-drop-in-audio-app-with-swiftui-and-vapor-part-2/voice_swift-vapor_p2_1200x600.png

Construire une application audio avec SwiftUI et Vapor - Partie 2

Temps de lecture : 8 minutes

Introduction

La première première partie de ce tutoriel a utilisé l Conversation API pour créer un serveur pour une application audio. Le serveur prend en charge la création de nouveaux utilisateurs, la création de nouvelles salles de chat et la liste de toutes les salles de chat ouvertes. Dans ce tutoriel, vous allez construire une application iOS qui utilise le Client SDK de Vonage pour consommer et commencer à chatter. Si vous souhaitez vous lancer directement dans ce tutoriel, vous pouvez suivre les instructions du dépôt dépôt GitHub pour le serveur afin de tout mettre en place.

Conditions préalables

En outre, d'après les conditions préalables de la première partie, vous aurez besoin de Cocoapods pour installer le Vonage Client SDK pour iOS.

Création de l'application iOS

Il est temps de mettre en place l'application iOS. Une fois qu'elle est créée, vous allez installer le Client SDK et demander des autorisations pour le microphone.

Créer un projet Xcode

Pour commencer, ouvrez Xcode et créez un nouveau projet en allant dans Fichier > Nouveau > Projet. Sélectionnez un modèle d'application et donnez-lui un nom. Sélectionnez SwiftUI pour l pour l'interfaceSwiftUI App pour le cycle de vieet Swift pour le langage. Enfin, un emplacement pour sauvegarder votre projet.

Xcode project creation

Installer le Client SDK

Maintenant que vous avez créé le projet, vous pouvez ajouter le Vonage Client SDK comme dépendance. Naviguez jusqu'à l'emplacement où vous avez enregistré le projet dans votre terminal et exécutez les commandes suivantes.

  1. Exécutez la commande pod init pour créer un nouveau Podfile pour votre projet.

  2. Ouvrez le fichier Podfile dans Xcode en utilisant open -a Xcode Podfile.

  3. Mettre à jour le Podfile pour avoir NexmoClient comme dépendance.

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'SwiftUIDropin' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for SwiftUIDropin
  pod 'NexmoClient'
end
  1. Installer le SDK en utilisant pod install.

  2. Ouvrez le nouvel espace de travail xcworkspace dans Xcode en utilisant open SwiftUIDropin.xcworkspace.

Autorisations pour le microphone

Étant donné que l'application utilisera le microphone pour passer des appels, vous devez demander explicitement l'autorisation de le faire.

La première étape consiste à éditer le fichier Info.plist . Le fichier Info.plist est un fichier qui contient toutes les métadonnées nécessaires à l'application. Ajoutez une nouvelle entrée au fichier en passant votre souris sur la dernière entrée de la liste et cliquez sur le petit bouton + qui apparaît. Dans la liste déroulante, choisissez Privacy - Microphone Usage Description et ajoutez Microphone access required to take part in audio rooms pour sa valeur.

Vous effectuerez la deuxième étape de la demande d'autorisations pour le microphone plus loin dans le tutoriel.

Créer l'écran de connexion

Le Client SDK a besoin d'un JWT pour se connecter aux serveurs de Vonage. L'application iOS a besoin d'envoyer un nom d'utilisateur au /auth du serveur. Créez un nouveau fichier appelé Models.swift en allant dans Fichier > Nouveau > Fichier (CMD + N). Comme pour le backend, il y a une structure pour le corps de la requête et une structure pour la réponse du serveur.

Ajoutez les structures suivantes au fichier Models.swift les structures suivantes :

struct Auth: Codable {
    struct Body: Codable {
        let name: String
    }
    
    struct Response: Codable {
        let name: String
        let jwt: String
    }
}

Étant donné que l'application iOS utilisera trois points d'extrémité différents, vous allez créer une petite classe pour réutiliser le code de mise en réseau. Créez un nouveau fichier appelé RemoteLoader.swift et ajoutez la classe suivante :

import Foundation

final class RemoteLoader {
    enum RemoteLoaderError: Error {
        case url
        case data
    }
    
    static func load<T: Codable, U: Codable>(urlString: String, body: T?, responseType: U.Type, completion: @escaping ((Result<U, RemoteLoaderError>) -> Void)) {
        guard let url = URL(string: urlString) else {
            completion(.failure(.url))
            return
        }
        
        var request = URLRequest(url: url)
        
        if let body = body, let encodedBody = try? JSONEncoder().encode(body) {
            request.httpMethod = "POST"
            request.httpBody = encodedBody
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data {
                if let response = try? JSONDecoder().decode(U.self, from: data) {
                    completion(.success(response))
                    return
                }
            }
            completion(.failure(.data))
        }.resume()
    }
}

La classe RemoteLoader se compose d'une liste d'erreurs et d'une fonction statique. load statique. La fonction load est générique sur deux types, T et Uqui sont conformes au protocole Codeable protocole.

T représente la structure qui sera utilisée comme corps de la requête envoyée par cette fonction. Elle est facultative car certaines requêtes peuvent ne pas nécessiter de corps. U représente le type de structure de réponse.

Lors d'une requête réseau, vous fournissez l'URL, le corps et le type de réponse, et la fonction load renvoie un résultat.

Avant de commencer à construire l'interface utilisateur (UI) de l'application, vous allez d'abord construire une classe de modèle. Cette classe est utilisée pour séparer la logique de l'application du code de visualisation. Dans le cas présent, la classe de modèle gérera les appels de délégués du Client SDK et effectuera la demande de connexion au réseau.

En haut du fichier ContentView.swift importez le Client SDK et AVFoundation :

import SwiftUI
import NexmoClient
import AVFoundation

Ensuite, au bas du fichier, créez une nouvelle classe appelée AuthModel.

final class AuthModel: NSObject, ObservableObject, NXMClientDelegate {

}

Dans cette classe, définissez les propriétés nécessaires :

final class AuthModel: NSObject, ObservableObject, NXMClientDelegate {
    @Published var loading = false
    @Published var connected = false
    
    var name = ""
    
    private let audioSession = AVAudioSession.sharedInstance()
}

L'enveloppe de propriété @Published permet à l'interface utilisateur de savoir quand réagir aux modifications apportées par la classe de modèle ; tout cela est géré pour vous, car la classe se conforme au ObservedObject protocole.

La propriété audioSession est utilisée pour demander les autorisations du microphone. Pour terminer la demande d'autorisations de microphone pour l'application, ajoutez la fonction suivante à la classe AuthModel la fonction suivante :

func requestPermissionsIfNeeded() {
    if audioSession.recordPermission != .granted {
        audioSession.requestRecordPermission { (isGranted) in
            print("Microphone permissions \(isGranted)")
        }
    }
}

Cette fonction vérifiera d'abord si les autorisations ont déjà été accordées ; si ce n'est pas le cas, elle les demandera et affichera le résultat sur la console. Ensuite, vous pouvez ajouter la fonction qui fait la demande au serveur dorsal en utilisant la classe RemoteLoader classe :

func login() {
    loading = true
    
    RemoteLoader.load(urlString: "https://URL.ngrok.io/auth", body: Auth.Body(name: self.name), responseType: Auth.Response.self) { result in
        switch result {
        case .success(let response):
            DispatchQueue.main.async {
                NXMClient.shared.setDelegate(self)
                NXMClient.shared.login(withAuthToken: response.jwt)
            }
        default:
            break
        }
    }
}

Remplacez la chaîne urlString par l'URL de votre ngrok. Une fois la réponse reçue, la fonction utilisera le JWT pour se connecter au Client SDK et définira le délégué du SDK à cette classe. Dans un environnement de production, vous voudrez que le serveur transmette également des informations sur le TTL du JWT et effectue des contrôles supplémentaires dans l'application sur la validité du JWT avant d'effectuer des actions qui nécessitent le Client SDK.

Le NXMClientDelegate est la façon dont le Client SDK communique à votre application les changements apportés aux serveurs de Vonage. Ensuite, mettez en œuvre les fonctions déléguées requises dans la AuthModel dans la classe

func client(_ client: NXMClient, didChange status: NXMConnectionStatus, reason: NXMConnectionStatusReason) {
    switch status {
    case .connected:
        self.connected = true
        self.loading = false
    default:
        self.connected = false
        self.loading = false
    }
}

func client(_ client: NXMClient, didReceiveError error: Error) {
    self.loading = false
    self.connected = false
}

En cas de changement d'état du SDK ou d'erreur, les booléens connected et loading changent, ce qui entraîne des modifications de l'interface utilisateur. La dernière fonction que vous devez ajouter à l'élément AuthModel calls requestPermissionsIfNeeded:

func setup() {
    requestPermissionsIfNeeded()
}

La classe de modèle étant terminée, vous pouvez maintenant créer l'interface utilisateur. Mettez à jour la ContentView struct :

struct ContentView: View {
    @ObservedObject var authModel = AuthModel()
    
    var body: some View {
        NavigationView {
            VStack {
                if authModel.loading {
                    ProgressView()
                    Text("Loading").padding(20)
                } else {
                    TextField("Name", text: $authModel.name)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .multilineTextAlignment(.center)
                        .padding(20)
                    Button("Log in") {
                        authModel.login()
                    }
                    NavigationLink("", destination: RoomListView(),
                                   isActive: $authModel.connected).hidden()
                    
                }
            }.navigationTitle("VonageHouse 👋")
            .navigationBarBackButtonHidden(true)
        }.onAppear(perform: authModel.setup)
    }
}

La ContentView struct possède une instance de la AuthModel (classe). La propriété loading de la structure authModel déterminera si la structure ContentView affiche un état de chargement ou la vue d'entrée, qui comporte une fenêtre de saisie de nom d'utilisateur et un bouton qui déclenche l'exécution de l'action. Textfield pour saisir un nom d'utilisateur et un bouton qui déclenche la fonction login dont nous avons parlé plus haut.

La vue d'entrée possède également un élément caché NavigationLink qui poussera la vue suivante, RoomListViewlorsque le Client SDK se connecte avec succès. Si vous commentez la ligne NavigationLink et que vous exécutez le projet (CMD + R), vous verrez l'écran de connexion :

Two screenshots, the first the iOS app requesting permissions, the second the login screen.

Créer l'écran Liste des salles

Lorsque le Client SDK se connecte avec succès, vous devez demander au serveur dorsal d'obtenir une liste de toutes les salles ouvertes. Comme pour l'écran de connexion, vous devrez ajouter les structures du modèle, puis créer une classe de modèle pour gérer la logique. Ajoutez les structures au fichier Models.swift au fichier

struct RoomResponse: Codable {
    let id: String
    let displayName: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case displayName = "display_name"
    }
}

struct CreateRoom: Codable {
    struct Body: Codable {
        let displayName: String
        
        enum CodingKeys: String, CodingKey {
            case displayName = "display_name"
        }
    }
    
    struct Response: Codable {
        let id: String
    }
}

Créez un nouveau fichier appelé RoomListView.swift et ajoutez la classe de modèle :

import SwiftUI

final class RoomModel: ObservableObject {
    @Published var results = [RoomResponse]()
    @Published var loading = false
    @Published var showingCreateModal = false
    @Published var hasConv = false
    
    var convID: String? = nil
    var roomName: String = ""
    
    func loadRooms() {
        RemoteLoader.load(urlString: "https://URL.ngrok.io/rooms", body: Optional<String>.none, responseType: [RoomResponse].self) { result in
            switch result {
            case .success(let response):
                DispatchQueue.main.async {
                    self.results = response
                }
            default:
                break
            }
        }
    }
    
    func createRoom() {
        RemoteLoader.load(urlString: "https://URL.ngrok.io/rooms", body: CreateRoom.Body(displayName: self.roomName), responseType: CreateRoom.Response.self) { result in
            switch result {
            case .success(let response):
                self.convID = response.id
                DispatchQueue.main.async {
                    self.hasConv = true
                    self.loading = false
                    self.showingCreateModal = false
                }
            default:
                break
            }
        }
    }
}

Cette classe modèle gère le chargement de la liste des salles et l'envoi de la demande de création d'une nouvelle salle ; remplacez la chaîne urlString par votre URL de ngrok. L'interface utilisateur observera la propriété results propriété.

Ensuite, créez l'interface utilisateur qui observera cette classe de modèle. Ajoutez la RoomListView au même fichier :

struct RoomListView: View {
    @ObservedObject var roomModel = RoomModel()
    
    var body: some View {
        VStack {
            List(roomModel.results, id: \.id) { item in
                VStack(alignment: .leading) {
                    NavigationLink(destination: RoomView(convID: item.id, convName: item.displayName)) {
                        Text(item.displayName)
                    }
                }
            }.onAppear(perform: roomModel.loadRooms)
            Button("Create room") {
                roomModel.showingCreateModal.toggle()
            }
            NavigationLink("", destination: RoomView(convID: roomModel.convID ?? "", convName: roomModel.roomName),
                           isActive: $roomModel.hasConv).hidden()
        }
        .navigationTitle("VonageCottage 👋")
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(trailing:
                                Button("Refresh") {
                                    roomModel.loadRooms()
                                }
        )
        .sheet(isPresented: $roomModel.showingCreateModal, content: {
            CreateRoomModal(roomModel: roomModel)
        })
    }
}

La RoomListView comporte un composant List qui affiche la liste des salles ouvertes et un bouton d'actualisation dans la barre de navigation. Il y a également un composant Button qui fait basculer un booléen qui affiche une CreateRoomModal vue. Ajoutez la vue au même fichier :

struct CreateRoomModal: View {
    @ObservedObject var roomModel: RoomModel
    
    var body: some View {
        if !roomModel.loading {
            VStack {
                TextField("Enter the room name", text: $roomModel.roomName)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .multilineTextAlignment(.center)
                    .padding(20)
                Button("Create room") {
                    roomModel.loading = true
                    roomModel.createRoom()
                }
            }
        } else {
            ProgressView()
        }
    }
}

Cette vue se compose d'un TextField pour la saisie du nom de la salle, d'un état de chargement et d'une fonction Button pour déclencher la fonction de création de salle sur la classe RoomModel de la classe. Les deux composants NavigationLink de la vue RoomListView référence a RoomView. La référence RoomView prend en paramètre un identifiant de conversation dont le Client SDK a besoin pour charger la conversation.

Créer l'écran de la pièce

C'est dans ce dernier écran que les utilisateurs de votre application se joindront aux conversations et se parleront. Comme pour les écrans précédents, vous commencerez par ajouter un modèle struct au fichier Models.swift au fichier

struct Member: Hashable {
    let id: String
    let name: String
}

Cette structure représentera la manière dont un membre d'une salle est affiché dans l'interface utilisateur. Ensuite, créez un nouveau fichier appelé RoomView.swiftet dans ce fichier, créez une ConversationModel classe :

import SwiftUI
import NexmoClient

final class ConversationModel: NSObject, ObservableObject, NXMConversationDelegate {
    @Published var loading = false
    @Published var members = [Member]()
    
    private var conversation: NXMConversation?
    private let currentUsername: String? = NXMClient.shared.user?.name
        
    func memberFrom(_ event: NXMMemberEvent) -> Member {
        return Member(id: event.fromMemberId, name: event.embeddedInfo?.user.name ?? "")
    }

    func memberFrom(_ nxmMemberSummary: NXMMemberSummary) -> Member {
         return Member(id: nxmMemberSummary.memberUuid, name: nxmMemberSummary.user.name)
     }
    
    func conversation(_ conversation: NXMConversation, didReceive event: NXMMemberEvent) {
        let member = memberFrom(event)
        switch event.state {
        case .joined:
            guard !self.members.contains(member),
                  self.currentUsername != member.name else { break }
            self.members.append(member)
        case .left:
            guard self.members.contains(member),
                  let memberIndex = self.members.firstIndex(of: member) else { break }
            self.members.remove(at: memberIndex)
        default:
            break
        }
    }
    
    func conversation(_ conversation: NXMConversation, didReceive error: Error) {
        print(error.localizedDescription)
    }
}

La classe ConversationModel est conforme à NXMConversationDelegate en plus du protocole ObservableObject comme les autres classes de modèles. Les deux fonctions de la classe NXMConversationDelegate que vous utiliserez sont didReceive:event et didReceive:error.

La fonction didReceive:event permet à l'application iOS d'être informée des utilisateurs qui quittent et rejoignent la conversation. Lorsque cela se produit, des membres sont ajoutés et supprimés du tableau members que l'interface utilisateur observe.

Vous pouvez maintenant ajouter les fonctions qui gèrent le chargement et le départ des conversations à la classe ConversationModel à la classe

final class ConversationModel: NSObject, ObservableObject, NXMConversationDelegate {
    ...
    func loadConversation(convID: String) {
        guard conversation == nil else { return }
        
        loading = true
        NXMClient.shared.getConversationWithUuid(convID) { error, conversation in
            self.conversation = conversation
            self.conversation?.delegate = self

            self.conversation?.join { [weak self] error, memberId in
                guard let self = self else { return }
                self.conversation?.getMembersPage(withPageSize: 100, order: .asc) { error, membersPage in
                    DispatchQueue.main.async {
                        guard let membersPage = membersPage else { return }
                        self.members = membersPage.memberSummaries.map { self.memberFrom($0) }

                        if !self.members.contains(where: { $0.name == self.currentUsername }) {
                            if let id = memberId, let name = self.currentUsername {
                                self.members.append(Member(id: id, name: name))
                            }
                        }
                        self.loading = false
                    }
                    
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        self.conversation?.enableMedia()
                    }
                }
            }
        }
    }
    
    func leaveConversation(completion: () -> Void) {
        self.conversation?.disableMedia()
        self.conversation?.leave(nil)
        completion()
    }
    ...
}

La fonction loadConversation fait appel getConversationWithUuid au Client SDK, renvoyant l'objet de conversation stocké dans une propriété locale. Maintenant que vous disposez de l'objet de conversation, vous pouvez rejoindre la conversation. Une fois cette étape franchie, vous pouvez activer les médias pour l'utilisateur, ce qui lui permet de parler et d'entendre les autres utilisateurs de la conversation. leaveConversation fait le contraire. Il désactive les médias, quitte la conversation et appelle un gestionnaire d'achèvement transmis à la fonction.

L'interface utilisateur de cet écran est divisée en deux structures, une petite vue pour un seul membre et une vue plus grande avec une grille de membres et des poignées de navigation. Créez la MemberView dans le même fichier :

struct MemberView: View {
    var memberName: String
    
    var body: some View {
        VStack {
            Circle()
                .fill(Color.gray)
                .frame(width: 75, height: 75)
            Text(memberName)
        }
    }
}

Il s'agit d'un cercle avec un composant Text pour le nom du membre de la chambre. Ajoutez ensuite l'élément RoomView:

struct RoomView: View {
    @StateObject var conversationModel = ConversationModel()
    @Environment(\.presentationMode) var presentationMode
    
    var convID: String
    var convName: String
    
    let columns = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]
    
    var body: some View {
        VStack {
            if conversationModel.loading {
                ProgressView()
                Text("Loading").padding(20)
            } else {
                VStack {
                    ScrollView {
                        LazyVGrid(columns: columns, spacing: 75) {
                            ForEach(conversationModel.members, id: \.self) { member in
                                MemberView(memberName: member.name)
                            }
                        }
                    }
                    Button("Leave room") {
                        conversationModel.leaveConversation(completion: { presentationMode.wrappedValue.dismiss() })
                    }
                }
            }
        }.navigationTitle(convName)
        .navigationBarBackButtonHidden(true)
        .onAppear(perform: {
            conversationModel.loadConversation(convID: convID)
        })
    }
}

Cette vue possède une propriété presentationMode qui permettra à la vue d'être supprimée lorsque l'utilisateur quittera la conversation/salle, et une grille de trois colonnes où les membres de la salle seront affichés.

Exécutez votre application

Si vous exécutez le projet (CMD + R), vous serez d'abord invité à autoriser les permissions pour les microphones, si ce n'est pas déjà fait.

Connectez-vous avec un nom d'utilisateur et vous serez dirigé vers l'écran de la liste des salles où vous pourrez créer une salle. La création d'une salle vous permet d'accéder à la vue de la salle. Répétez les mêmes étapes avec un autre nom d'utilisateur et un autre appareil, et vous pourrez discuter dans la salle !

Gif of the app flow

Quelle est la prochaine étape ?

Vous pouvez trouver le projet d'application iOS terminé sur GitHub. Vous pouvez faire beaucoup plus avec le Client SDK, apprenez-en plus sur developer.vonage.com.

Partager:

https://a.storyblok.com/f/270183/400x400/19c02db2d3/abdul-ajetunmobi.png
Abdul AjetunmobiVonage Ancien membre de l'équipe

Abdul est défenseur des développeurs chez Vonage. Il a travaillé dans le domaine des produits de consommation en tant qu'ingénieur iOS. Pendant son temps libre, il aime faire du vélo, écouter de la musique et conseiller ceux qui commencent leur parcours dans la technologie.