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.

ARCHITECTURE RÉSEAU — PATTERN DECORATORLe ViewController ne sait pas s’il parle au manager direct ou au wrapper — le HUD est transparentViewControllerself.api.getServices(…)ne voit qu’un SimoneHTTPManager.apiSimoneWrapperprepareWrappedCompletion()dispatch_after(0.25s) → show HUDfired = true → hide HUDoriginSimoneHTTPManagersuccessBlock<T>(completion:parserBlock:)checkForProtocolError()→ T! ou NSError!AFNetworkingAPI Railsapplication/simone.backend-v1.1+jsonMBProgressHUDrequête < 250ms → pas de HUDrequête > 250ms → affiche le spinnercomplétion → hide dans tous les casobjc_getAssociatedObjectlazy init par ViewController→ un wrapper par écranCode applicatifInfrastructure / externePoint d’entrée

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.

HEADER FADE ON SCROLL — TROIS ÉTATSalpha = overlap / headerHeight — la transparence suit le défilement en continualpha = 1.0CoiffureCOUPESCoupe femme35 €Coupe homme25 €Coupe enfant20 €COULEURSBalayage55 €header opaquerecouvre les cellulesalpha = 0.4CoiffureCOUPESCoupe enfant20 €COULEURSBalayage55 €Mèches65 €Coloration45 €header semi-transparentoverlapalpha = 0.0CoiffureCOULEURSBalayage55 €Mèches65 €Coloration45 €Tie & dye70 €COUPES a disparutransition fluidescroll ↑scroll ↑alpha = (headerRect.maxY - cellRect.origin.y) / headerHeightconvertRect:fromView: ramène les frames dans le repère de self.view

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.

TIMELAYOUT — UICOLLECTIONVIEWLAYOUT CUSTOMChaque section occupe 1/N de la largeur — les items s’empilent verticalement avec un padding configurableUICollectionViewMatinAprès-midiSoir09:0009:3010:0010:3011:0011:3014:0014:3015:0015:3018:0018:3019:00sectionWidthheaderHeightitemHeightvPaddingFormules de positionnement :x = section * sectionWidthy = item * (itemHeight + vPadding) + headerHeightsectionWidth = bounds.width / nbSectionscontentHeight = max(items) * itemHeightvs FlowLayout :✓ Colonnes indépendantes (nb items variable)✓ Pas de delegate pour les tailles✓ @IBDesignable → preview dans Interface Builder

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 :

HIÉRARCHIE DE VIEWCONTROLLERSChaque niveau a une responsabilité unique — les écrans concrets n’héritent que du nécessaireUIViewControllerInternetViewControllerReachability — bloque les actions réseau si offlineBarButtonsPositionsViewControllerMarges des boutons de nav — workaround iOS 7LoginVCTreatmentsVCAvailabilitiesVCLogin.storyboardMain.storyboardMain.storyboardextension UIViewControllervar api: SimoneHTTPManager

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 UILabel ou UITextField, override une méthode de dessin — c’est souvent plus simple et plus fiable qu’empiler des sous-vues.
  • Un UICollectionViewLayout custom demande un peu de réflexion, mais le résultat est incomparablement plus propre qu’un détournement de FlowLayout.

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.