Simone.paris, c’est un service de coiffure à domicile à Paris. L’app iOS permet de choisir une prestation, sélectionner un créneau, renseigner ses coordonnées et payer — le tout en quelques écrans. On a développé l’app fin 2014, livrée début 2015.
L’objectif était clair : une app fluide, avec un parcours de réservation sans friction. Le design était ambitieux pour une v1 — animations de scroll, champs de texte avec états visuels, layout custom pour les créneaux horaires. Rien d’insurmontable individuellement, mais le tout devait être cohérent et agréable.
Cet article revient sur les choix techniques et les petites astuces qui ont rendu ça possible.
Le contexte
L’équipe : Emmanuelle au développement, Aurélien en encadrement technique. Le backend est une API Rails classique qui expose des endpoints JSON. Côté iOS, on part sur Swift — le langage a moins d’un an, mais les bénéfices par rapport à Objective-C sont déjà évidents sur un projet neuf.
Le projet cible iOS 7.1. On utilise des Storyboards pour la navigation, AFNetworking pour le réseau, et MFSideMenu pour le menu latéral. Pas de reactive, pas de MVVM — du MVC classique, mais pensé à l’avance.
Les dépendances
Le Podfile reste sobre :
- AFNetworking — le standard pour le HTTP en 2015
- MBProgressHUD — indicateurs de chargement
- UICKeyChainStore — persistance sécurisée du token
- HexColors — couleurs hex dans le code
- TTTAttributedLabel — texte enrichi
- Reachability — état du réseau
Pas de librairie miracle. Chaque pod est là pour un besoin précis, et aucun n’empiète sur l’architecture de l’app.
Le réseau : un Singleton décoré
C’est le choix d’architecture qui a le mieux vieilli. Tout le réseau passe par un SimoneHTTPManager — un singleton qui hérite de AFHTTPRequestOperationManager et expose une méthode par endpoint.
Le parsing générique
Plutôt que de répéter la logique de succès/erreur dans chaque appel, on a factorisé avec des fonctions génériques. Le principe : une seule fonction successBlock<T> reçoit un bloc de parsing et wrappe la gestion d’erreur :
let success = successBlock(completion: completion) { (op, obj) -> [Service]! in
if let array = obj as? [[String: AnyObject]] {
return array.map { Service(dict: $0) }
}
return nil
}
super.GET("services.json", parameters: params, success: success,
failure: failureBlockWithCompletion(completion: completion))
Chaque endpoint se résume à son bloc de parsing. La vérification du status code, l’extraction du message d’erreur, la construction de NSError — tout est centralisé dans checkForProtocolError. Le ViewController qui consomme l’API ne voit rien de cette mécanique.
Le pattern Decorator pour le HUD
Le SimoneWrapper décore le manager en wrappant chaque completion. L’astuce : le HUD ne s’affiche qu’après 250ms, et un flag fired empêche de l’afficher si la requête a déjà abouti :
private func prepareWrappedCompletion<U>(
completion: (U!, NSError!) -> Void
) -> (U!, NSError!) -> Void {
var fired = false
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(0.25 * Double(NSEC_PER_SEC))),
dispatch_get_main_queue()) { [weak self] in
if fired { return }
MBProgressHUD.showHUDAddedTo(self?.vc?.view, animated: true)
}
return { [weak self] (arg: U!, error: NSError!) -> Void in
fired = true
MBProgressHUD.hideAllHUDsForView(self?.vc?.view, animated: true)
completion(arg, error)
}
}
Côté ViewController, l’accès est transparent via une extension qui utilise objc_getAssociatedObject pour créer un wrapper par écran, à la volée. Un simple self.api.getServices(...) suffit — le loader s’ajoute tout seul.
Les animations UI
Synchroniser avec le clavier
Adapter le scroll quand le clavier apparaît, avec une animation parfaitement calée. La plupart des tutoriels Swift de l’époque se contentent d’un animateWithDuration(0.3) codé en dur. Le résultat : une animation de scroll qui ne colle jamais tout à fait avec celle du clavier. La vraie durée, la vraie courbe — elles sont dans le userInfo de la notification, mais la documentation Swift ne les mentionne pas.
C’est notre expérience d’Objective-C qui nous a mis sur la piste. En ObjC, lire UIKeyboardAnimationDurationUserInfoKey et UIKeyboardAnimationCurveUserInfoKey est un pattern établi depuis iOS 4. Mais en Swift 1.2, les valeurs sont masquées derrière des casts AnyObject et des conversions numériques non triviales :
let duration = (info[UIKeyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue
let curve = (info[UIKeyboardAnimationCurveUserInfoKey] as! NSNumber).integerValue
let options = UIViewAnimationOptions(rawValue: UInt(curve << 16))
UIView.animateWithDuration(duration, delay: 0, options: options, animations: {
self.scrollView.contentInset = UIEdgeInsetsMake(0, 0, inset, 0)
}, completion: nil)
Le même pattern sert pour keyboardWillHide : on relit durée et courbe depuis la notification de disparition. Le résultat est une synchronisation pixel-perfect entre le clavier système et notre scroll — un détail que l’utilisateur ne remarque pas consciemment, mais qui fait toute la différence de polish.
Le header qui s’efface au scroll
Sur l’écran des prestations, les en-têtes de section deviennent transparents quand leurs cellules scrollent en dessous. L’idée : calculer le chevauchement entre le header et sa première cellule, dans le repère de la vue.
Le code essentiel tient en quelques lignes :
func scrollViewDidScroll(scrollView: UIScrollView) {
if let header = tableView.headerViewForSection(section) as? TreatmentTableHeaderView {
let headerRect = view.convertRect(header.bounds, fromView: header)
if let firstCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: 0, inSection: section)) {
let cellRect = view.convertRect(firstCell.bounds, fromView: firstCell)
header.backgroundView?.alpha = (headerRect.maxY - cellRect.origin.y) / HeaderHeight
}
}
}
convertRect:fromView: fait tout le travail — on ramène les frames dans un repère commun. Quand le header recouvre entièrement la cellule, alpha vaut 1 ; quand la cellule défile plus loin, alpha tend vers 0.
Les vues custom
SimoneTextField — un champ avec des états
Le design demandait des champs de texte dont la bordure change de couleur : gris au repos, foncé quand actif, rouge en erreur. L’idée : un switch sur un tuple Swift qui capture les deux dimensions d’état :
private func borderColor() -> UIColor {
switch (isError, isFirstResponder()) {
case (true, _): return UIColor(hexString: "de6255") // erreur
case (_, true): return UIColor(hexString: "20282d") // actif
case (_, false): return UIColor(hexString: "8b9092") // repos
default: return UIColor.clearColor()
}
}
La bordure est mise à jour à trois moments : changement d’état d’erreur via didSet, et changement de focus via les overrides de becomeFirstResponder / resignFirstResponder. Le pattern matching sur tuple remplace une cascade de if/else — c’est une construction Swift qui nous a beaucoup servi dans ce projet.
StrikedLabel et Separator — du sur-mesure sans librairie
Pour afficher un ancien prix barré, une diagonale CoreGraphics par-dessus le texte suffit :
override func drawTextInRect(rect: CGRect) {
super.drawTextInRect(rect)
guard strikeEnabled else { return }
let ctx = UIGraphicsGetCurrentContext()
CGContextMoveToPoint(ctx, rect.origin.x, rect.maxY)
CGContextAddLineToPoint(ctx, rect.maxX, rect.origin.y)
CGContextStrokePath(ctx)
}
Pour les séparateurs en pointillés, un patternImage et une hauteur d’exactement un pixel physique — 1.0 / UIScreen.mainScreen().scale — donnent un trait net sur tous les écrans, Retina ou non.
Le layout custom pour les créneaux horaires
L’écran de sélection de créneau affiche les disponibilités en colonnes — matin, après-midi, soir. Un UICollectionViewFlowLayout ne convient pas : on veut des colonnes de tailles variables avec un header par période.
Le layout se résume à deux méthodes : collectionViewContentSize calcule la hauteur à partir de la colonne la plus longue, et layoutAttributesForElementsInRect positionne chaque élément :
override func layoutAttributesForElementsInRect(rect: CGRect)
-> [UICollectionViewLayoutAttributes]?
{
let sectionWidth = collectionView!.bounds.width / CGFloat(sections)
for section in 0..<sections {
// Header en haut de chaque colonne
let headerFrame = CGRectMake(CGFloat(section) * sectionWidth, 0,
sectionWidth, headerHeight)
// Items empilés verticalement
for item in 0..<collectionView!.numberOfItemsInSection(section) {
let itemFrame = CGRectMake(CGFloat(section) * sectionWidth,
CGFloat(item) * (itemHeight + verticalPadding) + headerHeight,
sectionWidth, itemHeight)
}
}
}
Chaque section occupe un tiers de la largeur, les items s’empilent verticalement. Pas besoin de delegate pour les tailles, tout est déterministe.
Architecture : simple ne veut pas dire naïf
Héritage de ViewControllers
Plutôt que de dupliquer du code dans chaque écran, on a mis en place une chaîne d’héritage sobre :
C’est du MVC classique, mais chaque niveau de la hiérarchie a une responsabilité unique. On évite les “God ViewControllers” de 500 lignes.
Storyboards multiples et callbacks typés
On a découpé l’app en storyboards par domaine fonctionnel : Login, Menu, Cards (paiement), UserProfile, Contact, Root. Chaque storyboard reste lisible — cinq ou six écrans maximum.
Pour le couplage entre écrans, des factory methods avec callbacks typés. Le LoginViewController en est un bon exemple :
class func loginController(
loginCallback: UserAuthorizationCallback,
cancel: UserAuthorizationCancelCallback
) -> UIViewController {
let board = UIStoryboard(name: "Login", bundle: NSBundle.mainBundle())
let login = board.instantiateViewControllerWithIdentifier("LoginViewController")
as! LoginViewController
login.loginCallback = loginCallback
login.cancelCallback = cancel
return UINavigationController(rootViewController: login)
}
N’importe quel écran peut présenter le login sans connaître les détails d’implémentation. Le jour où on a ajouté l’inscription Facebook, seul le LoginViewController a changé.
Ce qu’on en retient
Le projet Simone nous a confirmé plusieurs intuitions :
- Swift tient ses promesses même en version 1.2. Les optionnels, le pattern matching sur les tuples, les génériques — tout ça rend le code plus sûr sans le rendre plus verbeux.
- Le pattern Decorator sur le réseau a été le meilleur investissement. Séparer la logique métier du feedback visuel, c’est ce qui a permis à Emmanuelle de travailler sur les écrans sans se soucier du loading.
- Les vues custom restent le meilleur outil pour des designs précis. Sous-classer
UILabelouUITextField, override une méthode de dessin — c’est souvent plus simple et plus fiable qu’empiler des sous-vues. - Un
UICollectionViewLayoutcustom demande un peu de réflexion, mais le résultat est incomparablement plus propre qu’un détournement deFlowLayout.
L’app a été livrée dans les temps, avec un rendu fidèle aux maquettes. La base de code est restée maintenable — on a pu la reprendre sereinement un an plus tard pour des ajustements.
Lire aussi : Pourquoi Rails pour le backend d’une app iOS — le retour de Raphaël sur l’architecture côté serveur, ActiveAdmin et la machine à états.