Thierry Chappuis

 

  • Site perso: placepython.fr
  • Professeur et chercheur à la Haute Ecole d'Ingénierie et d'Architecture de Fribourg
  • Mentor Python et Django sur PlacePython.fr
  • Présent sur X: @PlacePython

Objectifs

  1. Transformer une application web Django/Flask/Whatever en app mobile hybride

  2. Utiliser Turbo, Stimulus et Hotwire Native

  3. Simplifier et accélérer le dev mobile pour les petites équipes

  4. Réduire le temps de mise sur le marché (time to app store) de votre app

  5. Créer une présence sur les App Stores Android et iOS avec peu de ressources

Pourquoi créer une application mobile avec votre projet Django ?

  • Répondre aux attentes des utilisateurs mobiles (majorité de vos visiteurs)

  • Augmenter la portée de votre app ou SaaS

  • Offrir une expérience utilisateur améliorée

  • S’adapter à l’évolution des comportements numériques

Qu’est-ce qu’une application mobile hybride ?

Qu’est-ce qu’une application mobile hybride ?

  1. Une app entre le web et le natif

  2. Une base de code pour plusieurs plateformes

  3. Accès aux fonctionnalités natives de l’appareil

  4. Déployée sur les App Stores comme une application native

  5. Solution idéale pour des petites équipes ou des projets à budget limité

Qu'est-ce que Hotwire ?

Qu'est-ce que Hotwire ?

  1. Une vieille approche moderne du dev web :)
  2. Créé par les développeurs de Ruby on Rails (37signals)

  3. Technologie front-end par défaut de Ruby on Rails

  4. Moins de JavaScript, plus de simplicité

  5. Une solution agnostique, adaptable à votre framework back-end

Qu'est-ce que Hotwire ?

+

+

Qu’est-ce que Hotwire Native ?

  1. Hotwire du web au mobile
  2. Réutilisation de votre code existant
  3. Vos vues Django sont vos vues mobiles
  4. Usage ponctuel de pages iOS ou Android natives
  5. Usage ponctuel de composants natifs avec BridgeComponent (anciennent Strada)

Avantages de Hotwire Native par rapport aux applications natives, Flutter ou React Native

  1. Réutilisation maximale des vues Django ou Flask

  2. Moins de complexité et de nouvelles technologies à apprendre

  3. Pas nécessairement d'API

  4. Temps de développement plus court et maintenance simplifiée

  5. Accès aux fonctionnalités natives

Avantages de Hotwire Native par rapport aux PWA (Progressive Web Apps)

  1. Présence sur les App Stores (iOS et Android)

  2. Accès complet aux fonctionnalités natives de l’appareil

  3. Meilleure expérience utilisateur avec une intégration native

  4. Compatibilité et support étendus sur toutes les plateformes

Contraintes sur le Back-end

Pré-requis: Turbo Drive et Turbo Frames

$ npm install @hotwired/turbo
// frontend/src/application/app.js
import "@hotwired/turbo"

Qu'est-ce que Turbo Drive ?

Une navigation rapide et sans rechargement de page:

  • Sans Turbo Drive : Chaque fois que l’utilisateur clique sur un lien ou soumet un formulaire, Django renvoie une nouvelle page HTML complète, et tout le navigateur est rechargé.

  • Avec Turbo Drive : Django génère toujours la page HTML complète, mais seuls les éléments qui ont changé sont actualisés, ce qui rend la navigation beaucoup plus rapide et fluide pour l’utilisateur.

Pourquoi Turbo Drive est important pour Hotwire Native

Turbo Drive est la pierre angulaire de la fluidité de Hotwire Native

  • Navigation rapide et sans rechargement
  • Performance et économies de ressources

  • Turbo Drive gère la navigation sans rechargement, Hotwire Native transforme la navigation en navigation native

Qu'est-ce que Turbo Frame ?

Chargement de parties de la page:

  • Sans Turbo Frame : Si une petite partie de la page (comme une liste de commentaires ou un formulaire) doit être mise à jour, Django renvoie une nouvelle page complète, et le navigateur doit tout recharger.

  • Avec Turbo Frame : Découpe de la page en section (appelées Frames) qui sont gérés indépendemment par le back-end.

Qu'est-ce que Turbo Frame ?

<body>
  <div id="navigation">Links targeting the entire page</div>

  <turbo-frame id="message_1">
    <h1>My message title</h1>
    <p>My message content</p>
    <a href="/messages/1/edit">Edit this message</a>
  </turbo-frame>

  <turbo-frame id="comments">
    <div id="comment_1">One comment</div>
    <div id="comment_2">Two comments</div>

    <form action="/messages/comments">...</form>
  </turbo-frame>
</body>

Turbo Frame et Hotwire Native

  • Actualisation partielle des sections spécifiques d’une page mobile

  • Optimisation des interactions et des performances sur mobile

  • Permet une expérience utilisateur plus fluide et modulaire: chaque frame interagit avec le serveur sans rechargement

  • Améliore l'UX liée gestion des formulaires

Notre première app mobile 

XCode 15+ et Swift pour iOS

XCode 15+ et Swift pour iOS

XCode 15+ et Swift pour iOS

XCode 15+ et Swift pour iOS

XCode 15+ et Swift pour iOS

File → Add Packages Dependencies…

Add to package

// SceneDelegate.swift
import HotwireNative
import UIKit

let rootURL = URL(string: "https://hotwire-native-demo.dev")!

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    private let navigator = Navigator()

    func scene(_ scene: UIScene, 
        willConnectTo session: UISceneSession, 
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        window?.rootViewController = navigator.rootViewController
        navigator.route(rootURL)
    }
}

Modifier la navigation par défaut avec path-configuration.json

{
  "settings": {},
  "rules": [
    {
      "patterns": [
        ".*"
      ],
      "properties": {
        "context": "default",
        "pull_to_refresh_enabled": true
      }
    },
    {
      "patterns": [
        "/new$",
      ],
      "properties": {
        "context": "modal",
        "pull_to_refresh_enabled": false
      }
    }
  ]
}

Et Android ? Utiliser Android studio et Kotlin

File → New → New Project…

Web + Native == Hybride

  • Substituer des composants avec des composants natifs grace à BridgeComponent (anciennement Strada)
  • Substituer des vues avec des vues natives

Créer des pages natives

Amélioration progressive :

Certaines vues stratégique méritent une expérience utilisateur native

Interception de l'URL et réimplémentation de la page (et juste de cette page) en Swift/Kotlin

Créer des pages natives

{
  "settings": {},
  "rules": [
    {
      "patterns": [
        "/numbers$"
      ],
      "properties": {
        "uri": "hotwire://fragment/numbers",
        "title": "Numbers"
      }
    }
  ]
}
import HotwireNative
import UIKit

/// A simple native table view controller to demonstrate loading non-Turbo screens
/// for a visit proposal
final class NumbersViewController: UITableViewController, PathConfigurationIdentifiable {
    static var pathConfigurationIdentifier: String { "numbers" }

    convenience init(url: URL, navigator: Router) {
        self.init(nibName: nil, bundle: nil)
        self.url = url
        self.navigator = navigator
    }

    private var url: URL!
    private unowned var navigator: Router?

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Numbers"
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    }

    override func numberOfSections(in tableView: UITableView) -> Int {
        1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        100
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

        let number = indexPath.row + 1
        cell.textLabel?.text = "Row \(number)"

        return cell
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let detailURL = url.appendingPathComponent("\(indexPath.row + 1)")
        navigator?.route(detailURL)
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

Logique visuelle et comportement en Swift/Kotlin

Créer des composants natifs (BridgeComponent)

$ npm install -D @hotwired/stimulus 

$ npm install -D @hotwired/hotwire-native-bridge

Contenu géré par Django

Bouton géré par Swift/Kotlin

via un BidgeComponent

// frontend/src/application/application.js
import "@hotwired/turbo"
import { Application } from "@hotwired/stimulus"
import "@hotwired/hotwire-native-bridge"

// Controllers
import MenuController from "../controllers/menu_controller.js"

// Bridge Components
import BridgeFormController from "../controllers/bridge/form_controller.js"
import BridgeMenuController from "../controllers/bridge/menu_controller.js"
import BridgeOverflowMenuController from "../controllers/bridge/overflow_menu_controller.js"

// Start Stimulus
window.Stimulus = Application.start()

// Register Controllers
Stimulus.register("menu", MenuController)

// Register Bridge Components
Stimulus.register("bridge--form", BridgeFormController)
Stimulus.register("bridge--menu", BridgeMenuController)
Stimulus.register("bridge--overflow-menu", BridgeOverflowMenuController)
// frontend/src/controllers/bridge/menu_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
import { BridgeElement } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "menu"
  static targets = [ "title", "item" ]

  show(event) {
    if (this.enabled) {
      event.stopImmediatePropagation()
      this.notifyBridgeToDisplayMenu(event)
    }
  }

  notifyBridgeToDisplayMenu(event) {
    const title = new BridgeElement(this.titleTarget).title
    const items = this.makeMenuItems(this.itemTargets)

    this.send("display", { title, items }, message =>  {
      const selectedIndex = message.data.selectedIndex
      const selectedItem = new BridgeElement(this.itemTargets[selectedIndex])

      selectedItem.click()
    })
  }

  makeMenuItems(elements) {
    const items = elements.map((element, index) => this.menuItem(element, index))
    const enabledItems = items.filter(item => item)

    return enabledItems
  }

  menuItem(element, index) {
    const bridgeElement = new BridgeElement(element)

    if (bridgeElement.disabled) return null

    return {
      title: bridgeElement.title,
      index: index
    }
  }
}

Et du côté Swift ?

Et du côté Swift ?

//Brige/MenuController.swift
import Foundation
import HotwireNative
import UIKit

/// Bridge component to display a native bottom sheet menu,
/// which will send the selected index of the tapped menu item back to the web.
final class MenuComponent: BridgeComponent {
    override class var name: String { "menu" }

    override func onReceive(message: Message) {
        guard let event = Event(rawValue: message.event) else {
            return
        }

        switch event {
        case .display:
            handleDisplayEvent(message: message)
        }
    }

    // MARK: Private

    private var viewController: UIViewController? {
        delegate.destination as? UIViewController
    }

    private func handleDisplayEvent(message: Message) {
        guard let data: MessageData = message.data() else { return }
        showAlertSheet(with: data.title, items: data.items)
    }

    private func showAlertSheet(with title: String, items: [Item]) {
        let alertController = UIAlertController(
            title: title,
            message: nil,
            preferredStyle: .actionSheet
        )

        for item in items {
            let action = UIAlertAction(title: item.title, style: .default) { [unowned self] _ in
                onItemSelected(item: item)
            }
            alertController.addAction(action)
        }

        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
        alertController.addAction(cancelAction)

        // Set popoverController for iPads
        if let popoverController = alertController.popoverPresentationController {
            if let barButtonItem = viewController?.navigationItem.rightBarButtonItem {
                popoverController.barButtonItem = barButtonItem
            } else {
                popoverController.sourceView = viewController?.view
                popoverController.sourceRect = viewController?.view.bounds ?? .zero
                popoverController.permittedArrowDirections = []
            }
        }

        viewController?.present(alertController, animated: true)
    }

    private func onItemSelected(item: Item) {
        reply(
            to: Event.display.rawValue,
            with: SelectionMessageData(selectedIndex: item.index)
        )
    }
}

// MARK: Events

private extension MenuComponent {
    enum Event: String {
        case display
    }
}

// MARK: Message data

private extension MenuComponent {
    struct MessageData: Decodable {
        let title: String
        let items: [Item]
    }

    struct Item: Decodable {
        let title: String
        let index: Int
    }

    struct SelectionMessageData: Encodable {
        let selectedIndex: Int
    }
}

On pilote le contenu depuis l'application Django !

Quand passer à des écrans natifs avec Hotwire Native ?

Trois types d’écrans à envisager pour un rendu natif :

  • Écran d’accueil natif : Permet un lancement rapide de l’application et une haute qualité d’affichage dès le début. Exemples : HEY et Basecamp utilisent des vues SwiftUI natives dès le lancement, avec des données en cache pour accélérer l’accès hors-ligne.
  • Cartes natives : Les cartes basées sur des solutions comme MapKit offrent une meilleure expérience utilisateur que celles rendues dans une vue web. Cela permet d’afficher des cartes interactives avec des pins, des overlays, et des itinéraires.

  • Écrans utilisant des APIs natives : Lorsque vous interagissez avec des APIs natives, comme HealthKit, AVFoundation ou CoreLocation.

Trois types d’écrans mieux adaptés à une vue web :

  • Écrans qui changent souvent : Les écrans comme les paramètres ou préférences, qui subissent des modifications fréquentes, sont plus faciles à gérer via HTML. Les mises à jour web sont moins coûteuses que les mises à jour natives 
  • Fonctionnalités CRUD classiques : Les opérations basiques comme les CRUD (Create, Read, Update, Delete), qui n’ajoutent pas de valeur spécifique à l’expérience utilisateur, peuvent rester dans une vue web pour gagner du temps de dev.

  • Contenus dynamiques complexes : Les listes d’éléments hétérogènes, comme un flux d’actualités, sont souvent plus faciles à rendre via Hotwire. Chaque type d’élément natif nécessiterait une vue et une validation distincte, alors qu’une vue web peut les traiter facilement sans passer par une mise à jour sur l’App Store.

L’objectif est d’avoir une application simplement acceptée sur l’App Store, avec les fonctionnalités minimales.

Pour le lancement initial, vous n’avez pas nécessairement besoin d’écrans natifs.

  • Hotwire est mature et soutenu par une grosse communauté 
  • Une solution framework agnostique
  • Ne nécessite pas d'API pour le back-end
  • Amélioration progressive comme philosophie: aller vers des écrans natifs SI nécessaire, et QUAND nécessaire

De votre back-end à l'AppStore ?

L'essentiel de la logique est contrôlée par le back-end Python !