Si, comme moi, vous êtes fatigué de toujours écrire les mêmes lignes de code chaque fois que vous souhaitez faire quelque chose de simple avec les UITableView (ou les UICollectionView), ce billet est fait pour vous !
Grâce à la librairie RxSwift vous pouvez créer des comportements aussi bien simple que complexe avec très peu de ligne de code.
RxSwift, Qu’est ce que c’est ?
Ce billet n’a pas pour but de vous expliquer ce qu’est la programmation réactive mais pour être capable de suivre il faut que vous connaissiez quelques bases.
Pour faire simple (et désolé pour les raccourcis) le framework RxSwift est une implémentation de ReactiveX ou RX qui fournit une API unifié pour faciliter le travail avec les Observables. Un observable est une abstraction d’un flux d’événements asynchrones. Cela peut être un array, des événements lors que l’on touche l’écran, des changements de textes, et plein d’autre chose encore/ De plus cela permet de chaîner ces flux, appliquer des filtres par dessus ou encore de les transformer pour avoir des observables plus spécifiques.
Si vous souhaiter aller plus loin, je vous conseil ces liens (en anglais malheureusement) : lien 1, lien 2 ou lien 3.
Commençons!
Avant de commencer, il faut savoir que le projet fini est disponible sur github.
Maintenant nous allons développer un petit quiz à choix multiple (QCM) en utilisant une UITableView. Les cellules seront utiliser pour afficher les choix, l’UINavigationBar pour afficher le titre de la question et nous aurons aussi un bouton pour valider nos réponses.
Le modèle de donnée
Commençons par définir le modèle de donnée pour nos questions :
struct ChoiceModel {
let title: String
let valid: Bool
}
struct QuestionModel {
let title: String
let choices: [ChoiceModel]
}
Comme vous pouvez le voir, nous avons ici un modèle très simple, une question ayant un titre et une liste de choix, tandis que chaque choix possède lui aussi un titre et un booléen pour savoir si le choix est valide.
Voici maintenant nos 2 questions :
class Quiz {
static var questions: [QuestionModel] = {
// Question 1
let c1 = ChoiceModel(title: "A powerful library", valid: true)
let c2 = ChoiceModel(title: "A brush", valid: false)
let c3 = ChoiceModel(title: "A swift implementation of ReactiveX", valid: true)
let c4 = ChoiceModel(title: "The Observer pattern on steroids", valid: true)
let q1 = QuestionModel(title: "What is RxSwift?", choices: [c1, c2, c3, c4])
// Question 2
let c5 = ChoiceModel(title: "Asynchronous events", valid: true)
let c6 = ChoiceModel(title: "Email validation", valid: true)
let c7 = ChoiceModel(title: "Networking", valid: true)
let c8 = ChoiceModel(title: "Interactive UI", valid: true)
let c9 = ChoiceModel(title: "And many more...", valid: true)
let q2 = QuestionModel(title: "In which cases RxSwift is useful?", choices: [c5, c6, c7, c8, c9])
return [q1, q2]
}()
}
Chacune des questions possède un nombre de choix différents ainsi qu’un nombre variable de bonne proposition.
Créons le squelette de l’application
Dans cette partie nous allons nous occuper de l’interface utilisateur. Je vous conseille de partir du storyboard du projet github. Comme vous pouvez le voir sur l’image du dessus, nous avons besoin d’une UINavigationBar pour afficher le titre de la question ainsi que le bouton pour passer à la question suivante. Il y a aussi une UITableView pour les choix (cells) et un bouton de validation :
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var navigationBar: UINavigationBar!
@IBOutlet weak var nextQuestionButton: UIBarButtonItem!
@IBOutlet weak var choiceTableView: UITableView!
@IBOutlet weak var submitButton: UIButton!
}
Maintenant nous allons préparer l’application pour l’utilisation de la librairie RxSwift. Comme dans l’example sur github je vous suggère d’utiliser cocoapods pour l’intégrer au projet. Une fois cela fait nous pouvons utiliser RxSwift et RxCocoa en les importants comme ceci :
import RxCocoa
import RxSwift
La déclaration de RxCocoa permet d’étendre les composants d’UIKit en leur ajoutant des méthodes et propriétés pour qu’ils puisse être utiliser avec RxSwift. Ensuite nous allons déclarer toutes les variables nécessaire à l’application :
let currentQuestionIndex = Variable(0)
let currentQuestion: Variable<QuestionModel?> = Variable(nil)
let selectedIndexPaths: Variable<[NSIndexPath]> = Variable([])
let displayAnswers: Variable = Variable(false)
let disposeBag = DisposeBag()
Une Variable représente une valeur qui peut changer au cours du temps. Avec cet objet on peut ainsi observer facilement ces changements et surtout avec RxSwift souscrire à ces événements pour réagir en conséquence. Ici nous avons défini 4 variables :
- currentQuestionIndex : pour écouter l’index de la question courante
- currentQuestion : pour suivre la question à afficher
- selectedIndexPaths : pour savoir quels sont les choix sélectionnés par l’utilisateur
- displayAnswers : pour connaitre quand afficher les résultats
La dernière variable (disposeBag) est ce que l’on appelle un sac. Vous devez toujours ajouter un sac en bout de chaîne quand on écoute un événement pour être sur qu’il n’y est pas de fuite mémoire avec les événements et les abonnés.
Pour finir cette partie, la ViewController à besoin de se conformer au protocol UITableViewDelegate et configurer le choiceTableView comme delegate :
class ViewController: UIViewController, UITableViewDelegate {
override func viewDidLoad() {
super.viewDidLoad()
// Sets self as tableview delegate
choiceTableView
.rx_setDelegate(self)
.addDisposableTo(disposeBag)
}
}
Maintenant créons nos cellules pour afficher les choix de manière approprié :
import UIKit
final class ChoiceCell: UITableViewCell {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var checkboxImageView: UIImageView!
@IBOutlet weak var checkmarkImageView: UIImageView!
private let redColor = UIColor(red: 231 / 255, green: 76 / 255, blue: 60 / 255, alpha: 1)
private let greenColor = UIColor(red: 46 / 255, green: 204 / 255, blue: 113 / 255, alpha: 1)
var choiceModel: ChoiceModel? {
didSet {
layoutCell()
}
}
override var selected: Bool {
didSet {
checkmarkImageView.hidden = !selected
layoutCell()
}
}
var displayAnswers: Bool = false {
didSet {
layoutCell()
}
}
private func layoutCell() {
titleLabel.text = choiceModel?.title
if let choice = choiceModel where displayAnswers {
checkboxImageView.tintColor = selected ? choice.valid ? greenColor : redColor : choice.valid ? greenColor : .blackColor()
checkmarkImageView.tintColor = selected ? choice.valid ? greenColor : redColor : .blackColor()
}
else {
checkboxImageView.tintColor = .blackColor()
checkmarkImageView.tintColor = .blackColor()
}
}
}
Voilà la préparation des modèle fini, et de l’interface fini, passons maintenant au cœur du sujet.
Le comportement réactif
Le premier événement qu’il faut que l’on suive c’est le changement de question. Pour cela nous allons « écouter » la variable currentQuestionIndex :
private func setupCurrentQuestionIndexObserver() {
currentQuestionIndex
.asObservable()
.map { $0 % Quiz.questions.count }
.subscribeNext { index -> Void in
self.currentQuestion.value = Quiz.questions[index]
}
.addDisposableTo(disposeBag)
}
Ici on défini une méthode setupCurrentQuestionIdexObserver qui suit la valeur de la variable currentQuestionIndex pour ensuite transformer (map) celle-ci afin de garantir qu’elle est bien présente dans la liste. Ensuite on change la variable currentQuestion avec la bonne question. Et pour finir en bout de chaîne on dispose l’événement dans le sac.
Après, on s’occupe de la variable currentQuestion :
private func setupCurrentQuestionObserver() {
currentQuestion
.asObservable()
.subscribeNext { question in
self.navigationBar.topItem?.title = question?.title
}
.addDisposableTo(disposeBag)
currentQuestion
.asObservable()
.filter { $0 != nil }
.map { $0!.choices }
.bindTo(choiceTableView.rx_itemsWithCellIdentifier("ChoiceCell", cellType: ChoiceCell.self)) { (row, element, cell) in
cell.choiceModel = element
}
.addDisposableTo(disposeBag)
}
Dans un premier temps nous mettons à jour le titre chaque fois que la question currentQuestion change. Ensuite, si il y a une question, nous attachons les choix possible de la question à la tableview. Ainsi chaque fois que la question courante change, la choiceTableView est rechargée afin d’afficher les nouvelle propositions.
Pour mettre à jour la question courante nous avons par contre besoin d’écouter les événements du bouton de la barre de navigation :
private func setupNextQuestionButtonObserver() {
nextQuestionButton
.rx_tap
.subscribeNext {
self.displayAnswers.value = false
self.currentQuestionIndex.value += 1
}
.addDisposableTo(disposeBag)
}
Ici, comme précédemment, chaque fois que le bouton est pressé nous nous assurons de cacher les résultats précédents et nous passons à l’index suivant en mettant à jour la variable currentQuestionIndex.
Nous avons aussi besoin de suivre la sélection des choix pour chacune des questions :
private func setupChoiceTableViewObserver() {
choiceTableView
.rx_itemSelected
.subscribeNext { indexPath in
self.selectedIndexPaths.value.append(indexPath)
self.choiceTableView.cellForRowAtIndexPath(indexPath)?.selected = true
}
.addDisposableTo(disposeBag)
choiceTableView
.rx_itemDeselected
.subscribeNext { indexPath in
self.selectedIndexPaths.value = self.selectedIndexPaths.value.filter { $0 != indexPath }
self.choiceTableView.cellForRowAtIndexPath(indexPath)?.selected = false
}
.addDisposableTo(disposeBag)
}
Chaque fois qu’un choix est sélectionné (ou désélectionné) nous mettons à jour la liste selectedIndexPaths et les cellules.
Nous allons activé le bouton valider quand au moins une proposition a été sélectionné et que les résultats ne sont pas affichés :
private func setupSubmitButtonObserver() {
Observable
.combineLatest(selectedIndexPaths.asObservable(), displayAnswers.asObservable()) { (s, d) in
return s.count > 0 && !d
}
.bindTo(submitButton.rx_enabled)
.addDisposableTo(disposeBag)
submitButton
.rx_tap
.subscribeNext {
self.displayAnswers.value = true
}
.addDisposableTo(disposeBag)
}
Quand le bouton valider est tapé nous affichons les réponses :
private func setupDisplayAnswersObserver() {
displayAnswers
.asObservable()
.subscribeNext { displayAnswers in
for cell in self.choiceTableView.visibleCells as! [ChoiceCell] {
cell.displayAnswers = displayAnswers
}
}
.addDisposableTo(disposeBag)
}
Pour finir nous allons implémenter quelques méthodes du protocole UITableViewDelegate pour améliorer notre expérience utilisateur :
func tableView(tableView: UITableView, willSelectRowAtIndexPath indexPath: NSIndexPath) -> NSIndexPath? {
return displayAnswers.value ? nil : indexPath
}
func tableView(tableView: UITableView, willDeselectRowAtIndexPath indexPath: NSIndexPath) -> NSIndexPath? {
return displayAnswers.value ? nil : indexPath
}
func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
cell.selectionStyle = .None
}
func tableView(tableView: UITableView, editingStyleForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCellEditingStyle {
return .None
}
Ces dernières lignes de code permettent à la tableview de sélectionner les cellules quand les résultats sont cachés et réciproquement cela permet d’éviter que l’utilisateur puisse interagir avec la tableview quand ceux ci sont afficher.
Conclusion
Si vous êtes familier avec les UITableView et les UICollectionView, nous vous conseillons d’investiguer la puissance des librairies Rx et plus spécifiquement RxSwift. Comme vous avez pu le voir dans ce tutoriel, avec très peu de ligne de code il est possible de créer des comportement complexe sans avoir à se préoccuper des interactions entre les variables.
Vous pouvez télécharger le projet sur github.
Note : cet article est une traduction Make UITableView more Reactive with RxSwift de Yannick Loriot.