/ swift

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 :

  1. Dans la liste des composants disponibles, sélectionnez UIView.
  2. Faites glissez la vue (UIView) là où vous souhaitez afficher le cercle de progression.
  3. 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)
    }
}