Coder une barre de progression circulaire pour iOS
Dans UIKit
les ingénieurs d'Apple ont mis à notre disposition une barre de progression très simple.
Elle est suffisante dans beaucoup de cas mais lorsque l'on décide de travailler un peu le graphisme de son application il devient un peu difficile de continuer d'utiliser celle-ci.
Dans mon cas, j'ai eu besoin d'avoir une barre de progression circulaire. J'ai passé mon dimanche après-midi dessus, voyons ce que j'ai pu en tirer.
Nouveau composant d'interface
Nous voulons pouvoir réutiliser à souhait la barre de progression que nous allons développer. La solution est de créer un nouveau composant d'interface (UI Element).
import UIKit
class CustomProgressBar: UIView {
}
Dans un nouveau fichier, nous créons donc un objet CustomProgressBar
.
Nous le faisons hériter de UIView
parce que tous les éléments graphiques d'iOS héritent de près ou de loin de UIView
.
Un arc de cercle
Si l'on y réfléchit bien, notre barre de progression n'est qu'un empilement d'arc de cercle. Ajoutons donc une méthode qui sait en créer.
func addOval(_ lineWidth: CGFloat, path: CGPath, strokeStart: CGFloat, strokeEnd: CGFloat, strokeColor: UIColor, fillColor: UIColor, shadowRadius: CGFloat, shadowOpacity: Float, shadowOffsset: CGSize) {
let arc = CAShapeLayer()
arc.lineWidth = lineWidth
arc.path = path
arc.strokeStart = strokeStart
arc.strokeEnd = strokeEnd
arc.strokeColor = strokeColor.cgColor
arc.fillColor = fillColor.cgColor
arc.shadowColor = UIColor.black.cgColor
arc.shadowRadius = shadowRadius
arc.shadowOpacity = shadowOpacity
arc.shadowOffset = shadowOffsset
layer.addSublayer(arc)
}
Cette méthode créée un CAShapeLayer
avec les paramètres qu'on lui passe et l'ajoute au layer principal.
Rien de compliqué dans les attributs de cet objet, on lui indique juste des informations sur sa couleur, son ombre, etc.
Assemblage
La méthode qui est utilisée par le système pour demander à une vue de se dessiner s'appelle draw(_ rect:)
. Nous devons la surcharger.
override func draw(_ rect: CGRect) {
let X = self.bounds.midX
let Y = self.bounds.midY
let path = UIBezierPath(ovalIn: CGRect(x: (X - (200/2)), y: (Y - (200/2)), width: 200, height: 200)).cgPath
self.addOval(20, path: path, strokeStart: 0.0, strokeEnd: 0.9, strokeColor: UIColor.blue, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
}
Avec ce code on a déjà une barre de progression mais elle fera toujours 200 points de large et restera toujours coincée à 90%. Pas très réutilisable comme code.
Externalisons donc quelques variables :
var percent: CGFloat = 0.9
var barColor: UIColor = UIColor.blue
// ...
override func draw(_ rect: CGRect) {
let X = self.bounds.midX
let Y = self.bounds.midY
let path = UIBezierPath(ovalIn: CGRect(x: (X - (200/2)), y: (Y - (200/2)), width: 200, height: 200)).cgPath
self.addOval(20, path: path, strokeStart: 0.0, strokeEnd: self.percent, strokeColor: self.barColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
}
La couleur de la barre et la progression sont maintenant personnalisables de l'extérieur de notre objet.
Lorsque la progression est à 100% le cercle est complet. Sinon le cercle est coupé.
Parfait. Par contre, pour prévoir les différents cas d'utilisation, il serait bien de pouvoir visualiser la partie restante du cercle.
var percent: CGFloat = 0.9
var barColor: UIColor = UIColor.blue
var bgColor: UIColor = UIColor.clear
// ...
override func draw(_ rect: CGRect) {
let X = self.bounds.midX
let Y = self.bounds.midY
let path = UIBezierPath(ovalIn: CGRect(x: (X - (200/2)), y: (Y - (200/2)), width: 200, height: 200)).cgPath
self.addOval(20, path: path, strokeStart: 0.0, strokeEnd: 1.0, strokeColor: self.bgColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
self.addOval(20, path: path, strokeStart: 0.0, strokeEnd: self.percent, strokeColor: self.barColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
}
Il suffit de rajouter un cercle d'une autre couleur personnalisable sous la barre de progression.
On pourra toujours passer la couleur de ce cercle à UIColor.clear
dans les cas où on ne souhaite pas voir le cercle complet. D'ailleurs c'est la valeur par défaut.
Autre problème — un peu plus gênant quand même — que l'on a avec notre code c'est la taille de notre composant. Quoi qu'il arrive la barre de progression fera 200 points. Il faut pouvoir réduire ou augmenter la taille de notre composant au besoin.
override func draw(_ rect: CGRect) {
// ...
var size = self.frame.size.width
if self.frame.size.height < size {
size = self.frame.size.height
}
size -= 25
let path = UIBezierPath(ovalIn: CGRect(x: (X - (size/2)), y: (Y - (size/2)), width: size, height: size)).cgPath
// ...
}
L'idée est de calquer la taille de notre barre de progression sur celle de notre UIView
. Pour ne pas se retrouver avec un cercle déformé on garde, entre la hauteur et la largeur de la UIView
, la valeur la plus petite.
Le size -= 25
sert à garder une marge pour prendre en compte l'épaisseur de la barre de progression.
En parlant d'épaisseur, il peut s'agir d'une option de paramétrage de notre composant : réduire son épaisseur lorsque l'on réduit sa taille pour qu'il soit moins grossier, englober la barre de progression dans une barre plus épaisse pour une impression de liquide dans un tube, etc.
var thickness: CGFloat = 20
var bgThickness: CGFloat = 20
// ...
override func draw(_ rect: CGRect) {
// ...
let path = UIBezierPath(ovalIn: CGRect(x: (X - (size/2)), y: (Y - (size/2)), width: size, height: size)).cgPath
self.addOval(self.bgThickness, path: path, strokeStart: 0.0, strokeEnd: 1.0, strokeColor: self.bgColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
self.addOval(self.thickness, path: path, strokeStart: 0.0, strokeEnd: self.percent, strokeColor: self.barColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
}
Par défaut les deux épaisseurs sont les mêmes pour garder ce que l'on a jusque maintenant mais elles peuvent être changées indépendamment l'une de l'autre.
Notre composant est maintenant bien customizable. Nous sommes contents.
Mais laissez moi vous poser une question : si je veux une barre de progression qui n'est pas en cercle mais en demi-cercle, je fais comment ? Je code un nouveau composant ?
Bon, ajoutons cette fonctionnalité à notre barre de progression.
var self.isHalfBar: Bool = false
// ...
override func draw(_ rect: CGRect) {
let X = self.bounds.midX
var Y = self.bounds.midY
var strokeStart: CGFloat = 0.0
var strokeEnd: CGFloat = self.percent / 100
if self.isHalfBar == true {
Y = self.bounds.size.height
strokeStart = 0.5
strokeEnd = (strokeEnd / 2) + 0.5
}
// ...
self.addOval(self.bgThickness, path: path, strokeStart: strokeStart, strokeEnd: 1.0, strokeColor: self.bgColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
self.addOval(self.thickness, path: path, strokeStart: strokeStart, strokeEnd: strokeEnd, strokeColor: self.barColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
}
Au final, seul le calcul du pourcentage change entre le cercle complet et le demi cercle. Ça aurait été dommage de re-coder un composant complet juste pour ça.
Utilisation dans Interface Builder
Je ne vais pas vous faire l'affront de vous expliquer comment créer un objet et affecter de nouvelles valeurs à ces attributs.
Voyons comment l'utiliser dans Interface Builder :
- Dans la liste des composants disponibles, sélectionnez
UIView
. - Faites glissez la vue (
UIView
) là où vous souhaitez afficher le cercle de progression. - Dans l'onglet Identity Inspector, modifiez la classe par celle que l'on vient de coder :
CustomProgressBar
.
Si vous buildez votre projet, vous verrez bien s'afficher votre cercle de progression. Par contre dans Interface Builder tout est blanc, exactement comme une UIView
de base.
Pour changer ça et pouvoir utiliser votre composant au mieux dans Interface Builder nous allons utilisez @IBDesignable
et @IBInspectable
.
Je vous mets le code en entier avec les deux petites modifications pour Interface Builder. Après ça aller dans l'onglet Attributes Inspector et amusez-vous.
// source: https://github.com/Mindsers/awwbar
import UIKit
@IBDesignable
class CustomProgressBar: UIView {
@IBInspectable var percent: CGFloat = 0.9
@IBInspectable var barColor: UIColor = UIColor.blue
@IBInspectable var bgColor: UIColor = UIColor.clear
@IBInspectable var thickness: CGFloat = 20
@IBInspectable var bgThickness: CGFloat = 20
@IBInspectable var self.isHalfBar: Bool = false
override func draw(_ rect: CGRect) {
let X = self.bounds.midX
var Y = self.bounds.midY
var strokeStart: CGFloat = 0.0
var strokeEnd: CGFloat = self.percent / 100
var size = self.frame.size.width
if self.frame.size.height < size {
size = self.frame.size.height
}
size -= 25
if self.isHalfBar == true {
Y = self.bounds.size.height
strokeStart = 0.5
strokeEnd = (strokeEnd / 2) + 0.5
}
let path = UIBezierPath(ovalIn: CGRect(x: (X - (size/2)), y: (Y - (size/2)), width: size, height: size)).cgPath
self.addOval(self.bgThickness, path: path, strokeStart: strokeStart, strokeEnd: 1.0, strokeColor: self.bgColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
self.addOval(self.thickness, path: path, strokeStart: strokeStart, strokeEnd: strokeEnd, strokeColor: self.barColor, fillColor: UIColor.clear, shadowRadius: 0, shadowOpacity: 0, shadowOffsset: CGSize.zero)
}
func addOval(_ lineWidth: CGFloat, path: CGPath, strokeStart: CGFloat, strokeEnd: CGFloat, strokeColor: UIColor, fillColor: UIColor, shadowRadius: CGFloat, shadowOpacity: Float, shadowOffsset: CGSize) {
let arc = CAShapeLayer()
arc.lineWidth = lineWidth
arc.path = path
arc.strokeStart = strokeStart
arc.strokeEnd = strokeEnd
arc.strokeColor = strokeColor.cgColor
arc.fillColor = fillColor.cgColor
arc.shadowColor = UIColor.black.cgColor
arc.shadowRadius = shadowRadius
arc.shadowOpacity = shadowOpacity
arc.shadowOffset = shadowOffsset
layer.addSublayer(arc)
}
}