Transformer une application web Django/Flask/Whatever en app mobile hybride
Utiliser Turbo, Stimulus et Hotwire Native
Simplifier et accélérer le dev mobile pour les petites équipes
Réduire le temps de mise sur le marché (time to app store) de votre app
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 ?
Une app entre le web et le natif
Une base de code pour plusieurs plateformes
Accès aux fonctionnalités natives de l’appareil
Déployée sur les App Stores comme une application native
Solution idéale pour des petites équipes ou des projets à budget limité
Qu'est-ce que Hotwire ?
Qu'est-ce que Hotwire ?
Créé par les développeurs de Ruby on Rails (37signals)
Technologie front-end par défaut de Ruby on Rails
Moins de JavaScript, plus de simplicité
Une solution agnostique, adaptable à votre framework back-end
+
+
Avantages de Hotwire Native par rapport aux applications natives, Flutter ou React Native
Réutilisation maximale des vues Django ou Flask
Moins de complexité et de nouvelles technologies à apprendre
Pas nécessairement d'API
Temps de développement plus court et maintenance simplifiée
Accès aux fonctionnalités natives
Avantages de Hotwire Native par rapport aux PWA (Progressive Web Apps)
Présence sur les App Stores (iOS et Android)
Accès complet aux fonctionnalités natives de l’appareil
Meilleure expérience utilisateur avec une intégration native
Compatibilité et support étendus sur toutes les plateformes
$ npm install @hotwired/turbo
// frontend/src/application/app.js
import "@hotwired/turbo"
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.
Turbo Drive est la pierre angulaire de la fluidité de Hotwire Native
Performance et économies de ressources
Turbo Drive gère la navigation sans rechargement, Hotwire Native transforme la navigation en navigation native
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.
<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>
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
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)
}
}
{
"settings": {},
"rules": [
{
"patterns": [
".*"
],
"properties": {
"context": "default",
"pull_to_refresh_enabled": true
}
},
{
"patterns": [
"/new$",
],
"properties": {
"context": "modal",
"pull_to_refresh_enabled": false
}
}
]
}
File → New → New Project…
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
{
"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
$ 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
}
}
}
//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 !
Trois types d’écrans à envisager pour un rendu natif :
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 :
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.
L'essentiel de la logique est contrôlée par le back-end Python !