Blog tech

RubyMotion et les entrées utilisateur

Rédigé par Nicolas Cavigneaux | 10 juillet 2013

Dans le premier article à propos de RubyMotion nous avons vu les bases de la création d’une application avec l’utilisation de Rake puis du REPL. Nous avons ensuite mis en place l’affichage de labels, le traitement de date / heure ainsi que la personnalisation des backgrounds.

Nous avons vu qu’il très facile et assez concis de mettre en place le traitement d’informations mais aussi l’UI via RubyMotion.

Aujourd’hui nous allons nous pencher sur la mise en place du traitement d’un formulaire basique. L’idée est de permettre à l’utilisateur de sélectionner une heure ainsi qu’un fuseau horaire pour lui permettre de convertir cette heure vers le fuseau horaire concerné.

Voyons comment procéder.

Création de l’application

Commençons par créer le projet :

  $ motion create TimeZones
  Create TimeZones
  Create TimeZones/.gitignore
  Create TimeZones/app/app_delegate.rb
  Create TimeZones/Rakefile
  Create TimeZones/resources/Default-568h@2x.png
  Create TimeZones/spec/main_spec.rb

Nous allons, pour les besoins de cette présentation, demander à l’utilisateur d’entrer son nom, de choisir une heure et un fuseau horaire cible. Nous aurons donc besoin d’un champ texte, d’une liste de fuseaux horaires, d’un sélecteur de date et d’un label pour afficher le résultat.

Commençons par le plus simple, l’ajout du champ texte, du bouton de validation et du label.

Formulaire de récupération du nom

Ouvrez le projet dans votre éditeur de texte préféré pour vous diriger dans le fichier app/app_delegate.rb pour y instancier la fenêtre principale :

class AppDelegate
  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds)
    @window.rootViewController = RootViewController.alloc.init
    @window.makeKeyAndVisible
  end
end

Comme vous pouvez le voir, nous utilisons comme controller principal une instance de RootViewController. Il va donc falloir créer cette classe :

app/controllers/root_view_controller.rb

class RootViewController < UIViewController
  def viewDidLoad
    view.backgroundColor = UIColor.scrollViewTexturedBackgroundColor
  end
end

Vous pouvez déjà compiler le projet via la commande rake et vous verrez apparaître la fenêtre principale avec en fond la texture grisée.

Nous pouvons maintenant passer à l’ajout du champ texte et de son label de description :

class RootViewController < UIViewController
  def viewDidLoad
    view.backgroundColor = UIColor.scrollViewTexturedBackgroundColor

    view.addSubview name_label
    view.addSubview name_text_field
  end

  private

  def name_label
    label = UILabel.alloc.initWithFrame [[10, 10], [100, 30]]
    label.backgroundColor = UIColor.clearColor
    label.textColor = UIColor.whiteColor
    label.text = "Votre nom"
    label
  end

  def name_text_field
    textField = UITextField.alloc.initWithFrame [[110, 10], [170, 30]]
    textField.borderStyle = UITextBorderStyleRoundedRect
    textField.font = UIFont.systemFontOfSize(15)

    textField
  end
end

Nous avons ici ajouté deux méthodes privées qui nous permettent de générer un label avec fond transparent et texte blanc puis un champ texte avec une police de caractères à 15px.

Ces deux méthodes sont appelées l’une après l’autre au chargement de la vue pour que les éléments soient ajoutés à la vue principale.

Si vous testez vous verrez qu’il n’y a pour le moment pas grand chose de fonctionnel, d’ailleurs une fois le clavier virtuel déployé, impossible de revenir en arrière.

Traitons ce problème avant de passer à la suite.

Masquage du clavier virtuel

def textFieldShouldReturn(text_field)
  text_field.resignFirstResponder
end

La définition de la méthode textFieldShouldReturn permet de définir ce qui doit se passer lorsque l’utilisateur demande explicitement la fermeture du clavier via un appuie sur “entrée” par exemple.

Il faudra ensuite préciser à qui nous déléguons cette tâche, en l’occurrence ce sera notre controller principal qui va s’en charger.

Nous allons donc modifier notre code pour qu’il fonctionne comme attendu :

class RootViewController < UIViewController
  def viewDidLoad
    view.backgroundColor = UIColor.scrollViewTexturedBackgroundColor

    view.addSubview name_label
    view.addSubview name_text_field
  end

  def textFieldShouldReturn(text_field)
    text_field.resignFirstResponder
  end

  private

  def name_label
    label = UILabel.alloc.initWithFrame [[10, 10], [100, 30]]
    label.backgroundColor = UIColor.clearColor
    label.textColor = UIColor.whiteColor
    label.text = "Votre nom"

    label
  end

  def name_text_field
    textField = UITextField.alloc.initWithFrame [[110, 10], [170, 30]]
    textField.borderStyle = UITextBorderStyleRoundedRect
    textField.font = UIFont.systemFontOfSize(15)
    textField.delegate = self

    textField
  end
end

Nous avons donc ajouté le textField.delegate = self dans la méthode name_text_field qui permet de préciser le controller qui doit gérer le comportement du champ texte puis nous avons ajouté la méthode dédiée à la gestion de l’appui sur la touche “retour” dans laquelle nous demandons de fermer le clavier pour le champ texte à l’origine de la demande.

Pour peaufiner le comportement, il serait pratique de faire en sorte que le clavier se ferme également lorsque nous cliquons ailleurs que sur le clavier en lui même.

Pour arriver à nos fin nous allons attraper l’événement de “tap” simple sur l’écran. Lorsqu’un “tap” est effectué, nous fermerons le clavier si ce dernier est visible. Lors d’un “tap” nous n’aurons pas connaissance du champ texte en cours d’utilisation, nous allons donc devoir stocker notre champ texte dans une variable d’instance pour pouvoir y faire référence par la suite. Voici donc le code modifié :

class RootViewController < UIViewController
  def viewDidLoad
    view.backgroundColor = UIColor.scrollViewTexturedBackgroundColor

    @text_field = name_text_field

    view.addSubview name_label
    view.addSubview @text_field

    single_tap = UITapGestureRecognizer.alloc.initWithTarget(self, action: :'handle_single_tap')
    view.addGestureRecognizer(single_tap)
  end

  def textFieldShouldReturn(text_field)
    text_field.resignFirstResponder
  end

  def handle_single_tap
    @text_field.resignFirstResponder
  end

  private

  def name_label
    label = UILabel.alloc.initWithFrame [[10, 10], [100, 30]]
    label.backgroundColor = UIColor.clearColor
    label.textColor = UIColor.whiteColor
    label.text = "Votre nom"

    label
  end

  def name_text_field
    textField = UITextField.alloc.initWithFrame [[110, 10], [170, 30]]
    textField.borderStyle = UITextBorderStyleRoundedRect
    textField.font = UIFont.systemFontOfSize(15)
    textField.delegate = self

    textField
  end
end

Nous avons donc ajouté la reconnaissance du “tap” simple sur notre vue et associé ce tap à la méthode handle_single_tap qui va explicitement demander à notre champ texte de masquer son clavier s’il est visible.

Mise à jour de l’affichage

Lorsque notre utilisateur valide sa saisie, nous allons ajouter un message dans un label pour le saluer. Ce label servira également à lui indiquer l’heure du fuseau horaire sélectionné par la suite.

Le code va être assez simple puisqu’en nous basant sur l’existant il ne nous reste qu’à récupérer le texte lors de la validation de la saisie par l’utilisateur pour l’ajouter à notre label nouvellement créé.

Nous ajoutons donc une méthode privée pour générer le label :

def remote_time_label
  label = UILabel.alloc.initWithFrame [[0, 350], [view.frame.size.width, 30]]
  label.backgroundColor = UIColor.clearColor
  label.textColor = UIColor.whiteColor
  label.textAlignment = NSTextAlignmentCenter

  label
end

On prend ici soin de créer un label qui prend toute la largeur de la vue et d’aligner le texte au centre puis nous ajoutons cette vue à notre vue principale via viewDidLoad :

@remote_time_label = remote_time_label

view.addSubview @remote_time_label

Nous pouvons maintenant faire en sorte que le label soit mis à jour suite à la validation utilisateur :

def textFieldShouldReturn(text_field)
  text_field.resignFirstResponder
  @remote_time_label.text = "Bonjour #{text_field.text} !"
end

Nous avons donc bouclé notre premier étape et nous allons pouvoir passer à l’étape suivante qui va consister à permettre à l’utilisateur de choisir son fuseau horaire cible.

Conversion de l’heure

Il va nous falloir récupérer une liste des fuseaux horaires disponibles pour ensuite les afficher dans une liste déroulante. L’utilisateur pourra ainsi faire son choix et on utilisera la valeur sélectionnée pour faire la conversion de l’heure.

Dans un premier temps concentrons nous sur la mise en place de cette liste déroulante pour simplement afficher le fuseau horaire sélectionné.

Sélection du fuseau horaire

En tout premier lieu, à l’initialisation de la vue nous allons créer une variable d’instance qui contiendra les fuseaux horaires. Cocoa nous permet d’obtenir cette liste très facilement :

@timezones = NSTimeZone.knownTimeZoneNames

Nous pouvons ensuite ajouter une méthode privée qui nous servira à générer notre vue pour la liste déroulante :

def timezone_picker
  picker = UIPickerView.alloc.init
  picker.showsSelectionIndicator = true
  picker.center = self.view.center
  picker.dataSource = self
  picker.delegate = self

  picker
end

Nous initialisons donc un UIPickerView, nous précisons que l’on souhaite avoir un indice visuel pour l’élément sélectionné. On place la liste déroulante au centre de la fenêtre puis on définit le délégué et la source de données. Une fois encore, c’est notre controller principal qui se chargera de ça.

Nous pouvons maintenant ajouter cette vue à la vue principale :

view.addSubview timezone_picker

Il ne nous reste plus qu’à implémenter les méthodes requises par l’interface de UIPickerView :

def numberOfComponentsInPickerView(pickerView)
  1
end

def pickerView(pickerView, numberOfRowsInComponent:component)
  @timezones.size
end

def pickerView(pickerView, titleForRow:row, forComponent:component)
  @timezones[row]
end

def pickerView(pickerView, didSelectRow:row, inComponent:component)
  @remote_time_label.text = "#{@text_field.text}, vous avez choisi #{@timezones[row]}"
end

La méthode numberOfComponentsInPickerView permet de définir le nombre de listes déroulantes qu’on aura au sein de la vue, dans notre cas nous n’avons qu’une liste à afficher.

La méthode pickerView(pickerView, numberOfRowsInComponent:component) nous permet d’indiquer au composant le nombre total d’éléments qui seront dans la liste. Pour nous, c’est le nombre d’éléments dans notre tableau de fuseaux horaires.

La méthode pickerView(pickerView, titleForRow:row, forComponent:component) permet de définir le contenu de chaque élément de la liste, le titre de l’élément en quelque sorte, on aura par exemple “Europe/Paris”. Nous devons donc tout simplement retourner l’élément de notre tableau à l’index demandé, ici disponible via la variable row.

La méthode pickerView(pickerView, didSelectRow:row, inComponent:component) permet quand à elle de définir le comportement à adopter lorsqu’une sélection est faite dans la liste. Nous décidons ici d’utiliser notre label pour y afficher le nom de l’utilisateur ainsi que le fuseau horaire choisi.

Voici un exemple du résultat obtenu :

Choix de l’heure

La dernière étape en terme d’interaction avec l’utilisateur est la possibilité de lui laisser choisir la date et l’heure à convertir, pour cela nous allons utiliser un élément d’UI appelé UIDatePicker.

Nous avons pour le UIPickerView utilisé la taille par défaut, nous pourrions faire de même avec le UIDatePicker mais tous les éléments ne tiendraient pas à l’écran. Nous allons donc devoir personnaliser la taille et le positionnement du UIPickerView, les UIDatePicker ne permettant pas ce genre de manipulation.

Nous ajoutons donc une méthode privée pour générer la vue dont nous avons besoin :

def date_picker
  picker = UIDatePicker.alloc.init
  picker.center = [view.frame.size.width / 2, 320]

  picker
end

Il nous suffit ensuite de l’ajouter à la vue principale lors de son initialisation :

view.addSubview date_picker

Finalement nous devons modifier les méthodes existantes pour le UIPickerView et notre label pour qu’ils ne se recouvrent pas les uns, les autres :

def remote_time_label
  label = UILabel.alloc.initWithFrame [[0, 430], [view.frame.size.width, 30]]
  label.backgroundColor = UIColor.clearColor
  label.textColor = UIColor.whiteColor
  label.textAlignment = NSTextAlignmentCenter

  label
end

def timezone_picker
  picker = UIPickerView.alloc.initWithFrame [[0, 50], [320, 120]]
  picker.showsSelectionIndicator = true
  picker.dataSource = self
  picker.delegate = self

  picker
end

Nous pouvons maintenant mettre en place un mécanisme similaire à celui du UIPickerView pour réagir lors de la sélection d’une date par l’utilisateur. Pour cela nous allons devoir stocker notre sélecteur de date dans une variable d’instance puis observer ses événements pour savoir quand la valeur sélectionnée change.

Dans le viewDidLoad, on aura :

@date_picker = date_picker
view.addSubview @date_picker

@date_picker.addTarget(self, action: :'handle_date_change', forControlEvents:UIControlEventValueChanged)

On demande ici a être prévenu à chaque changement de valeur dans le UIDatePicker et que la méthode handle_date_change soit appelée à ce moment là. Il ne nous reste donc plus qu’à implémenter cette méthode pour afficher la date sélectionnée :

def handle_date_change
  fr_FR = NSLocale.alloc.initWithLocaleIdentifier "fr_FR"

  format = NSDateFormatter.alloc.init
  format.locale = fr_FR
  format.setDateFormat("dd MMM yyyy - HH:mm")

  # Conversion de la date en chaine
  dateString = format.stringFromDate(@date_picker.date)

  @remote_time_label.text = dateString
end

On récupère donc la date via @date_picker.date, date qu’on prend soin de formater pour l’affichage comme vu dans le précédent article. On l’affiche ensuite dans le label.

Conversion vers le fuseau horaire choisi

Dernière étape de cet article, convertir la date / heure choisie vers le fuseau horaire choisi juste au dessus. Nous allons donc devoir modifier notre méthode handle_date_change. Pour avoir accès au fuseau horaire sélectionné à tout instance, nous allons stocker la vue dans une variable d’instance dans le viewDidLoad :

@timezone_picker = timezone_picker
view.addSubview @timezone_picker

Nous pouvons maintenant modifier la méthode handle_date_change :

def handle_date_change
  selected_row = @timezone_picker.selectedRowInComponent(0)
  selected_tz = @timezones[selected_row]

  fr_FR = NSLocale.alloc.initWithLocaleIdentifier "fr_FR"

  format = NSDateFormatter.alloc.init
  format.locale = fr_FR
  format.timeZone = NSTimeZone.timeZoneWithName(selected_tz)
  format.setDateFormat("dd MMM yyyy - HH:mm")

  dateString = format.stringFromDate(@date_picker.date)

  @remote_time_label.text = dateString
end

Nous n’avons finalement pas modifié grand chose puisqu’on récupère simplement le fuseau horaire sélectionné puis on s’en sert ensuite sur notre formateur de date via format.timeZone = NSTimeZone.timeZoneWithName(selected_tz).

Pour finaliser le fonctionnement, on remplace le code de pickerView(pickerView, didSelectRow:row, inComponent:component) pour qu’il appelle handle_date_change. On a donc la date qui se met à jour qu’on change le fuseau horaire ou l’heure :

def pickerView(pickerView, didSelectRow:row, inComponent:component)
  handle_date_change
end

Nous avons maintenant une application capable de convertir une heure donnée vers tous les fuseaux horaires !

Conclusion

Nous avons vu dans cet article plusieurs composants qui à eux seuls peuvent largement suffire à créer une application complète et fonctionnelle.

Il n’y a aucun piège à éviter, la seule chose bloquante peut être d’avoir à se rappeler des signatures des méthodes déléguées de UIPickerView.

Vous trouverez le code de cet article sur GitHub

Dans le prochain article concernant RubyMotion, nous verrons comment mettre en place des models, des interactions multi-controllers. Nous découvrirons de nouveaux éléments d’UI que nous personnaliserons. Nous verrons également créer des transitions entre les différentes vues “root” des controllers.

L’équipe Synbioz. Libres d’être ensemble.