📱Créer une app iOS

Dans cette page, nous allons voir comment créer une application mobile iOS à partir d'une application Ksaar, prête à être publiée sur l'app store.

Cette documentation est plus technique que les autres, mais tout ce qui est présenté ici peut-être reproduit simplement en faisant des copier-coller des morceaux de codes présents dans chaque étape.

Créer le projet

On commence par créer un nouveau projet d'app IOS sur Xcode et on remplit les quelques informations nécessaires.

Dans le dossier ‘Assets’, on place l'icône qu'aura notre application dans 'AppIcon', puis on ajoute l'image de son logo qu'on renomme 'Logo' pour pouvoir l'utiliser plus tard.

Pour définir l'écran qui s'affiche au lancement de son application avec son logo, il faut aller dans les réglages de notre application, puis dans l'onglet Info, il faut ensuite aller dans 'Custom iOS Target Properties' et rajouter une ligne sous 'Launch Screen' avec le nom 'Image Name' et y mettre le nom de notre fichier : 'Logo'.

Créer une WebView

On débute ce projet par la création d'une simple WebView qui permet d'afficher une page internet sur notre application.

Dans le fichier ContentView, qui va contenir les éléments principaux de notre application, on créé notre structure WebView classique avec les fonctions makeUIView, qui permet d'initialiser la WebView, et updateUIView, qui permet de la mettre à jour.

On place notre WebView dans notre ContentView avec une url de test dans un premier temps.

On obtient alors une simple app avec uniquement notre WebView qui affiche le site choisi.

import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {    
    var url: String
    
    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        guard let url = URL(string: self.url) else {
            return
        }
        let request = URLRequest(url: url)
        webView.load(request)
    }

}

struct ContentView: View {
    var body: some View {
        WebView(url: "https://www.example.com")
    }
}

Ajouter une TopBar et BottomBar

On va maintenant ajouter une TopBar et une BottomBar pour pouvoir naviguer entre plusieurs pages de notre application Ksaar.

Pour cela, on créé d'abord un nouveau fichier swift ‘Config’ qui va contenir tous les paramètres de notre application.

Dedans, on créé une structure ‘Page’ qui contient une icône et une url et qui définit toutes les icônes que l'on aura dans nos barres de navigations et les urls des pages de notre application Ksaar vers lesquelles ces icônes renvoient.

On ajoute une première ’settingsPage’, issue de cette structure, qui sera dans notre TopBar, puis toutes les pages qui seront dans notre BottomBar, on peut en mettre le nombre que l’on souhaite.

On ajoute aussi dans ce fichier une extension qui permet d'utiliser des valeurs hexadécimales pour les couleurs.

Puis, on définit la couleur de navigation qu'on souhaite utiliser pour signaler la page en cours.

import SwiftUI
import Foundation

struct Page {
    let icon: String
    let url: String
}

let settingsPage = Page(icon: "gearshape", url:"<à compléter>")

let pages = [
        Page(icon: "calendar", url: "<à compléter>"),
        Page(icon: "person", url: "<à compléter>"),
        Page(icon: "house", url: "<à compléter>"),
        Page(icon: "folder", url: "<à compléter>"),
        Page(icon: "doc.text", url: "<à compléter>"),
    ]

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 3: // RGB (12-bit)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32-bit)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (255, 0, 0, 0)
        }
        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue:  Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
}

let navigationColorHex = "29E0A6"

BottomBar

Dans le fichier ContentView, on créé notre BottomBar en plaçant autant de boutons qu’on a définis de pages dans le fichier Config, chaque bouton ayant l’icône choisie.

Au clic sur un bouton, on indique que c’est la page correspondante qui est sélectionnée, ce qui change la couleur du bouton.

struct BottomBar: View {
    @Binding var selectedPage: Int
    @Binding var showSettings: Bool
    
    var body: some View {
        HStack {
            ForEach(pages.indices, id: \.self) { i in
                Button(action: {
                    self.selectedPage = i
                    self.showSettings = false
                }) {
                    VStack {
                        Image(systemName: pages[i].icon)
                            .imageScale(.large)
                            .foregroundColor(selectedPage == i ? Color(hex: navigationColorHex) : Color(hex: "000000"))
                        Circle()
                            .fill(selectedPage == i ? Color(hex: navigationColorHex) : Color.clear)
                            .frame(width: 5, height: 5)
                    }
                }
                .frame(maxWidth: .infinity)
            }
        }
        .padding(.bottom, 10)
        .padding(.top, 8)
        .background(Color(hex: "FFFFFF"))
    }
}

TopBar

De la même manière, on créé notre TopBar avec le logo au milieu et une icône vers une page tout à droite, qui peut être une page utilisateur, une page de réglage ou autre.

struct TopBar: View {
    @Binding var selectedPage: Int
    @Binding var showSettings: Bool
    
    var body: some View {
        ZStack {
            HStack {
                Spacer()
                Image("Logo")
                    .resizable()
                    .scaledToFit()
                    .frame(height: 30)
                Spacer()
            }

            HStack {
                Spacer()
                Button(action: {
                    self.selectedPage = -1
                    self.showSettings = true
                }) {
                    Image(systemName: settingsPage.icon)
                        .imageScale(.large)
                        .foregroundColor(self.showSettings ? Color(hex: navigationColorHex) : Color(hex: "000000"))
                }
                .padding(.trailing, 20)
            }
        }
        .padding(.bottom, 8)
        .background(Color(hex: "FFFFFF"))
    }
}

On ajoute nos TopBar et BottomBar dans la ContentView.

struct ContentView: View {
    @State private var selectedPage = 0
    @State private var showSettings = false
    
    var body: some View {
        VStack(spacing: 0) {
            TopBar(selectedPage: $selectedPage, showSettings: $showSettings)
            WebView(url: "https://www.example.com")
            BottomBar(selectedPage: $selectedPage, showSettings: $showSettings)
        }
    }
}

Ajouter la navigation

On a maintenant nos barres de navigation, mais la page ne change pas encore sur la WebView.

Pour pouvoir faire la navigation, et que chaque icône redirige bien vers la page voulue, il suffit dans un premier temps de donner l’url correspondant dans la WebView à la place de notre url d’exemple.

On choisit la première page comme page par défaut.

struct ContentView: View {
    @State private var selectedPage = 0
    @State private var showSettings = false
    
    var body: some View {
        VStack(spacing: 0) {
            TopBar(selectedPage: $selectedPage, showSettings: $showSettings)
            WebView(url: self.showSettings ? settingsPage.url : (selectedPage >= 0 && selectedPage < pages.count) ? pages[selectedPage].url : "")
            BottomBar(selectedPage: $selectedPage, showSettings: $showSettings)
        }
    }
}

On a maintenant une navigation fonctionnelle depuis nos barres de navigation.

Si on veut aller plus loin, il nous reste à gérer le cas où l’on serait redirigé vers un autre lien directement depuis notre WebView et pas par les barres de navigation, il faut alors mettre à jour nos barres de navigation pour qu’une icône ne soit plus sélectionnée si on n'est plus sur la bonne url.

Pour cela, on ajoute un coordinateur dans notre structure WebView qui permet de détecter les changements. On envoie alors une notification avec l’url actuelle dès que celle-ci change.

struct WebView: UIViewRepresentable {
    var url: String
    var coordinator: Coordinator
    
    class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, ObservableObject {
        
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            
        }
        
        var webView: WKWebView?

        override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            if keyPath == "URL", let webView = object as? WKWebView {
                // Handle the URL change
                if let newURL = webView.url {
                    NotificationCenter.default.post(name: Notification.Name("WebViewURLChanged"), object: nil, userInfo: ["url": newURL.absoluteString])
                }
            }
        }

    }

    func makeCoordinator() -> Coordinator {
        return coordinator
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        
        webView.addObserver(context.coordinator, forKeyPath: "URL", options: .new, context: nil)
        
        return webView
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        guard let url = URL(string: self.url) else {
            return
        }
        let request = URLRequest(url: url)
        webView.load(request)
    }

}

Maintenant, on peut ajouter notre coordinator dans notre contentView et une fonction qui va mettre à jour les barres de navigation à la réception de la notification et donc à chaque changement d’url.

struct ContentView: View {
    @StateObject private var webViewCoordinator = WebView.Coordinator()
    @State private var selectedPage = 0
    @State private var showSettings = false
    
    var body: some View {
        VStack(spacing: 0) {
            TopBar(selectedPage: $selectedPage, showSettings: $showSettings)
            WebView(url: self.showSettings ? settingsPage.url : (selectedPage >= 0 && selectedPage < pages.count) ? pages[selectedPage].url : "", coordinator: webViewCoordinator)
            BottomBar(selectedPage: $selectedPage, showSettings: $showSettings)
        }
        .onReceive(NotificationCenter.default.publisher(for: Notification.Name("WebViewURLChanged")), perform: { notification in
            guard let url = notification.userInfo?["url"] as? String else { return }
            updateSelectedPageForUrl(url)
        })
    }
    
    func updateSelectedPageForUrl(_ url: String) {
        if let index = pages.firstIndex(where: { url.contains($0.url) }) {
            selectedPage = index
            showSettings = false
        } else if (url.contains(settingsPage.url)){
            selectedPage = -1
            showSettings = true
        } else {
            selectedPage = -1
            showSettings = false
        }
    }
}

Ajouter du CSS personnalisé

Maintenant que nous avons un Top et BottomBar fonctionnelles, on peut vouloir modifier le css de notre page Ksaar pour, par exemple, enlever le menu de navigation qui est par défaut sur mobile. On peut aussi vouloir enlever la scrollbar qui n’est pas très utile sur une application mobile.

Pour cela, il faut créer notre propre css, dans un nouveau fichier Design.js, que l’on va injecter dans la page :

var css = `
header {
  display:none !important;
}
::-webkit-scrollbar {
    display: none;
}
`;

var style = document.createElement('style');
style.type = 'text/css';
if (style.styleSheet){
    style.styleSheet.cssText = css;
} else {
    style.appendChild(document.createTextNode(css));
}

document.getElementsByTagName('head')[0].appendChild(style);

Pour injecter ce css dans notre page, on ajoute une configuration dans notre fonction makeUIView qui permet de mettre du script personnalisé :

func makeUIView(context: Context) -> WKWebView {
    let configuration = WKWebViewConfiguration()

    if let cssFilePath = Bundle.main.path(forResource: "Design", ofType: "js"),
       let cssFileContent = try? String(contentsOfFile: cssFilePath) {
        let cssScript = WKUserScript(source: cssFileContent, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
        configuration.userContentController.addUserScript(cssScript)
    }
    
    let webView = WKWebView(frame: .zero, configuration: configuration)
    
    webView.addObserver(context.coordinator, forKeyPath: "URL", options: .new, context: nil)
    webView.navigationDelegate = context.coordinator
    
    return webView
}

Récupérer les informations d’un utilisateur

On peut vouloir récupérer des informations de la WebView directement dans notre application pour pouvoir les utiliser, comme par exemple le mail de l’utilisateur connecté.

Dans cet exemple, nous allons récupérer le mail de l'utilisateur pour afficher la TopBar et la BottomBar uniquement si l’utilisateur est connecté.

Pour cela, on ajoute du javascript dans la fonction WebView de notre classe Coordinator qui permet de récupérer l’email de l’utilisateur qui est dans le localStorage.

class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, ObservableObject {
    @Published var email: String = ""
    
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        webView.evaluateJavaScript("localStorage.getItem('userData');") { [weak self] (result, error) in
            guard let self = self, let userDataString = result as? String,
                  let userData = try? JSONSerialization.jsonObject(with: Data(userDataString.utf8), options: []) as? [String: Any],
                  let email = userData["email"] as? String else {
                return
            }

            self.email = email
        }
    }

    ///
}

On peut maintenant afficher les barres de navigation uniquement dans le cas où l’utilisateur est bien connecté. On définit alors une variable isUserLoggedIn qui se met à jour quand on reçoit un mail qui n'est pas vide, et on affiche les barres de navigation uniquement dans le cas où cette variable est vraie et donc que notre utilisateur est connecté.

@StateObject private var webViewCoordinator = WebView.Coordinator()
@State private var selectedPage = 0
@State private var showSettings = false
@State public var isUserLoggedIn = false

var body: some View {
    VStack(spacing: 0) {
        if isUserLoggedIn {
            TopBar(selectedPage: $selectedPage, showSettings: $showSettings)
        }
            
        WebView(url: self.showSettings ? settingsPage.url : (selectedPage >= 0 && selectedPage < pages.count) ? pages[selectedPage].url : "", coordinator: webViewCoordinator)
        .onReceive(webViewCoordinator.$email) { email in
            DispatchQueue.main.async {
                isUserLoggedIn = !email.isEmpty
            }
        }
        
        if isUserLoggedIn {
            BottomBar(selectedPage: $selectedPage, showSettings: $showSettings)
        }
    }
    .onReceive(NotificationCenter.default.publisher(for: Notification.Name("WebViewURLChanged")), perform: { notification in
        guard let url = notification.userInfo?["url"] as? String else { return }
        updateSelectedPageForUrl(url)
    })
}

Déclencher une action native à iOS depuis la WebView

Il est possible d’effectuer une action native à iOS depuis un élément intégré à la WebView comme par exemple un bouton. En combinant cela avec la récupération de l’email de l’utilisateur, il est possible par exemple d’ajouter des achats in-app à l’application et de débloquer certaines pages et fonctionnalités à partir de cela.

Dans cet exemple, nous allons voir comment déclencher une alerte iOS native contenant l’email de l’utilisateur à partir d’un bouton directement intégré dans la WebView.

On va commencer par créer un nouveau fichier JavaScript ‘CustomButton.js’ où l’on va créer notre bouton personnalisé que l’on va pouvoir intégrer dans une page.

Vous pouvez personnaliser ce bouton comme vous le souhaitez, ici on lui donne un style similaire aux boutons par défaut sur Ksaar.

var customButton = document.createElement("button");

var span = document.createElement("span");
span.innerHTML = "Bouton";
customButton.style.marginBottom = "20px"
span.style.padding = "11px 30px";
span.style.borderRadius = "22px";
span.style.backgroundColor = "#29E0A6";
span.style.color = "white";
customButton.appendChild(span);

customButton.addEventListener("touchstart", function() {
    span.style.backgroundColor = "#4ce6b5";
});

customButton.addEventListener("touchend", function() {
    span.style.backgroundColor = "#29E0A6";
});

customButton.addEventListener("mousedown", function() {
    span.style.backgroundColor = "#4ce6b5";
});

customButton.addEventListener("mouseup", function() {
    span.style.backgroundColor = "#29E0A6";
});

var checkDivInterval = setInterval(function() {
    var customButtonDiv = document.getElementById("custom-button-div");
    if (customButtonDiv) {
        clearInterval(checkDivInterval);
        customButtonDiv.appendChild(customButton);
    }
}, 100);

setTimeout(function() {
    clearInterval(checkDivInterval);
}, 5000);

De la même manière que pour le css, on injecte notre bouton en javascript dans la fonction makeUIView :

if let jsFilePath = Bundle.main.path(forResource: "CustomButton", ofType: "js"),
   let jsFileContent = try? String(contentsOfFile: jsFilePath) {
    let userScript = WKUserScript(source: jsFileContent, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
    configuration.userContentController.addUserScript(userScript)
}

Pour ajouter le bouton n’importe où sur l’application Ksaar, il suffit d’ajouter un élément HTML avec le contenu suivant à l'endroit où vous souhaitez placer votre bouton, l'élément sera reconnu grâce à l'id et rempli avec le bouton qu'on a créé :

<div id="custom-button-div" style="text-align: center;"></div>

Pour effectuer une action à partir du bouton, on ajoute dans le javascript une fonction, qui se déclenche au clic sur le bouton, qui envoie un message qui permet d’être reçu et utilisé dans la partie native de notre application. On ajoute le mail de l'utilisateur dans ce message pour l'utiliser.

Dans le fichier CustomButton.js, on ajoute notre fonction au clic dans checkDivInterval :

var checkDivcustInterval = setInterval(function() {
    var customButtonDiv = document.getElementById("custom-button-div");
    if (customButtonDiv) {
        clearInterval(checkDivInterval);
        customButtonDiv.appendChild(customButton);
        
        customButton.addEventListener("click", function() {
            var userData = localStorage.getItem('userData');
            var email = JSON.parse(userData).email;
            webkit.messageHandlers.customButton.postMessage(email);
        });
    }
}, 100);

Pour réceptionner ce message issue du javascript et l'utiliser dans la partie native de notre application mobile, on ajoute un userContentController dans notre classe Coordinator qui permet de faire le pont entre notre application et le javascript :

class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, ObservableObject {
    @Published var email: String = ""
    @Published var showAlert: Bool = false
    @Published var alertMessage: String = ""
    
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        if message.name == "customButton", let alertEmail = message.body as? String {
            DispatchQueue.main.async {
                self.alertMessage = "Utilisateur : \(alertEmail)"
                self.showAlert = true
            }
        }
    }
    
    ///
}

Puis, on déclare notre userContentController dans makeUIView

func makeUIView(context: Context) -> WKWebView {
    let configuration = WKWebViewConfiguration()
    let userContentController = WKUserContentController()
    userContentController.add(coordinator, name: "customButton")
    configuration.userContentController = userContentController
    
    ///
}

Enfin, on créé notre alerte iOS et on la fait apparaître quand on reçoit le message du bouton, au niveau de notre WebView.

WebView(url: self.showSettings ? settingsPage.url : (selectedPage >= 0 && selectedPage < pages.count) ? pages[selectedPage].url : "", coordinator: webViewCoordinator)
.onReceive(webViewCoordinator.$email) { email in
    DispatchQueue.main.async {
        isUserLoggedIn = !email.isEmpty
    }
}
.alert(isPresented: $webViewCoordinator.showAlert) {
        Alert(title: Text("Message"), message: Text(webViewCoordinator.alertMessage), dismissButton: .default(Text("OK")))
}

Dernière mise à jour