paint-brush
Retour sur l'interface utilisateur : création d'une barre de progression rétro pour iOS à l'aide de CALayerspar@maxkalik
684 lectures
684 lectures

Retour sur l'interface utilisateur : création d'une barre de progression rétro pour iOS à l'aide de CALayers

par Max Kalik22m2023/12/17
Read on Terminal Reader

Trop long; Pour lire

Ce guide explore la création d'une barre de progression de style rétro dans iOS, en utilisant CALayers pour une touche nostalgique. Il couvre le processus étape par étape, de la conception de chaque couche à la mise en œuvre des animations, offrant un aperçu de la combinaison de l'esthétique de la vieille école avec le développement iOS moderne. Idéal pour les développeurs et les concepteurs d’interface utilisateur, l’article fournit à la fois une profondeur technique et une inspiration créative pour des composants d’interface utilisateur uniques. Code source : https://github.com/maxkalik/RetroProgressBar
featured image - Retour sur l'interface utilisateur : création d'une barre de progression rétro pour iOS à l'aide de CALayers
Max Kalik HackerNoon profile picture
0-item


J'adore créer des composants d'interface utilisateur. Cela me procure une sorte de plaisir esthétique de les voir et de les utiliser après avoir réalisé une construction. Cela se combine également avec mon amour pour l’écosystème MacOS/iOS en général. Récemment, j'ai été chargé de créer une barre de progression personnalisée à l'aide de UIKit . Ce n'est pas grave, mais probablement 99 % des développeurs iOS en ont créé un à un moment donné de leur carrière. Dans mon cas, je voulais faire un peu plus que simplement créer quelques CALayer avec des animations basées sur une plage de valeurs de 0 à 1. Je visais une solution qui conviendrait à de nombreux scénarios où une barre de progression est nécessaire .


Je veux me replonger dans l'époque où les composants de l'interface utilisateur du monde Apple ressemblaient à ceci :


source : https://www.smashingmagazine.com/2010/08/free-wireframing-kits-ui-design-kits-pdfs-and-resources/


En réfléchissant à l'évolution de la conception de l'interface utilisateur, cela fait près d'une décennie que les éléments de l'interface utilisateur du monde Apple ont adopté un design plat avec l'introduction d'iOS 7 en 2014. Alors, livrons-nous à la nostalgie des versions antérieures de Mac OS et iOS et créons une belle barre de progression ensemble.


Si vous aimez utiliser UIKit mais que vous ne savez toujours pas comment manipuler CALayers, cet article vous sera utile. Ici, nous passerons en revue les défis typiques de mise en page, d’animation et de dessin, en vous fournissant des informations et des solutions pour vous aider tout au long du processus.

De UIView en profondeur dans l'API CALayers.

La barre de progression peut sembler simple, mais si nous approfondissons les détails, vous comprendrez qu'elle nécessite de nombreuses sous-couches.


Barre de progression rétro


Je ne vais pas approfondir CALayers ici. Vous pouvez consulter cet article dans lequel j'ai brièvement décrit ce que sont CALayers et comment ils fonctionnent : https://hackernoon.com/rolling-numbers-animation-using-only-calayers .


Alors, divisons-le en petites couches :

  1. Calque d'arrière-plan général
  2. Calque de masque (partie animable)
  3. Couche d'arrière-plan de progression
  4. Couche d'éblouissement
  5. Couche chatoyante


Le calque d'arrière-plan comporte deux calques enfants supplémentaires :

  1. Couche d'ombre intérieure
  2. Couche de dégradé de bordure


Structureons-le correctement :


 RetroProgressBar [UIView] |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer] |--Progress Background Layer [CALayer] |--Animated Mask Layer [CALayer] |--Glare [CALayer] |--Shimering [CALayer]


En bref, voici comment cela devrait fonctionner : La barre de progression doit réagir à une valeur comprise entre 0,0 et 1,0. Lorsque la valeur change, nous devons ajuster la largeur d'un calque en fonction de cette valeur :


 bounds.width * value


À l'aide de ce calcul simple, nous pouvons modifier la largeur à l'aide de CATransaction . Mais avant de faire cela, nous devons comprendre quelles couches doivent changer lorsque la valeur change. Revoyons la structure et identifions les couches qui doivent être réactives aux changements de valeur.


 RetroProgressBar [UIView] |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer] |--Progress Background Layer [CALayer] |--Animated Mask Layer [CALayer] : [Animated Width] |--Glare [CALayer] : [Animated Width] |--Shimering [CALayer] : [Animated Width]


Il semble que nous devions développer un protocole pouvant être adopté par les couches nécessitant une largeur animée en fonction de la valeur.

Protocole ProgressAnimatable

En protocole, nous n'avons besoin que de deux méthodes : changer la largeur du calque avec et sans animation.


 protocol ProgressAnimatable: CALayer { /// Sets the layer's width instantly to the specified value. func setToWidth(_ width: CGFloat) /// Animates the layer's width to the specified value. func animateToWidth(_ width: CGFloat, duration: TimeInterval, animationType: CAMediaTimingFunctionName, completion: (() -> Void)?) }


Mise en œuvre:


 extension ProgressAnimatable { func setToWidth(_ width: CGFloat) { guard width >= 0 else { return } removeAllAnimations() CATransaction.begin() CATransaction.setDisableActions(true) self.bounds.size.width = width CATransaction.commit() } func animateToWidth(_ width: CGFloat, duration: TimeInterval, animationType: CAMediaTimingFunctionName = .easeInEaseOut, completion: (() -> Void)? = nil) { guard width >= 0, width != self.bounds.width else { completion?() return } let animation = CABasicAnimation(keyPath: "bounds.size.width") animation.fromValue = bounds.width animation.toValue = width animation.duration = duration animation.fillMode = .forwards animation.isRemovedOnCompletion = false animation.timingFunction = CAMediaTimingFunction(name: animationType) CATransaction.begin() CATransaction.setCompletionBlock { self.bounds.size.width = width completion?() } self.add(animation, forKey: "widthChange") CATransaction.commit() } }


Les deux méthodes utilisent CATransaction . La première méthode peut paraître étrange car il suffit de modifier une valeur sans animation. Alors, pourquoi CATransaction est-il nécessaire ?


Essayez de créer un CALayer de base et de modifier sa largeur ou sa hauteur sans aucune animation. Lorsque vous exécutez une build, vous remarquerez les changements CALayer avec une animation. Comment est-ce possible? Dans Core Animation, les modifications apportées aux propriétés du calque (telles que les limites, la position, l'opacité, etc.) sont généralement animées en fonction de l'action par défaut associée à cette propriété. Ce comportement par défaut est destiné à fournir des transitions visuelles fluides sans avoir besoin d'animations explicites pour chaque changement de propriété.


Cela signifie que nous devons désactiver explicitement les actions dans CALayer . En définissant CATransaction.setDisableActions(true) , vous vous assurez que la largeur du calque est instantanément mise à jour avec la nouvelle valeur sans aucune transition animée intermédiaire.


La deuxième méthode offre un moyen flexible et réutilisable d'animer la largeur de n'importe quelle couche conforme au protocole ProgressAnimatable . Il permet de contrôler la durée et le rythme de l'animation et inclut une option pour une action de fin. Cela le rend bien adapté à divers besoins d'animation dans le cadre UIKit.


Nos candidats pour se conformer à ce protocole seront trois couches : une couche de masque animée, une couche éblouissante et une couche chatoyante. Allons-y et mettons-le en œuvre.

ProgressMaskLayer

Il est temps de se conformer à notre protocole, et la couche la plus importante de cette configuration sera notre CAShapeLayer général. Pourquoi avons-nous besoin de ce masque ? Pourquoi ne peut-il pas s'agir simplement d'un CALayer avec une largeur animée ? En tant qu'ingénieur iOS , en particulier celui qui se concentre sur le développement front-end, vous devez souvent anticiper les cas d'utilisation potentiels de vos composants. Dans mon cas, il existe un calque d'arrière-plan de progression. Ce n'est pas un CALayer animé, mais et s'il était animé avec quelque chose comme CAReplicatorLayer ? Si vous êtes préoccupé par les performances, vous considérerez probablement qu'un tel calque doit être rendu une fois, puis simplement effectuer son animation. C'est pourquoi ce masque peut être extrêmement utile. Il permet un rendu efficace tout en offrant la flexibilité nécessaire à divers scénarios d'animation.


 final class ProgressMaskLayer: CAShapeLayer, ProgressAnimatable { override init() { super.init() backgroundColor = UIColor.black.cgColor anchorPoint = CGPoint(x: 0, y: 0.5) } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }


Jetons un coup d'œil à cette ligne :


 anchorPoint = CGPoint(x: 0, y: 0.5)


Cette ligne est cruciale pour notre barre de progression. Par défaut, CALayers sont centrés, avec le anchorPoint situé au centre du calque, et non au milieu du bord gauche comme on le suppose généralement. Le anchorPoint est le point autour duquel se produisent toutes les transformations du calque. Pour un calque utilisé comme barre de progression, ce paramètre signifie généralement que lorsque la largeur du calque change, il s'étend de manière égale dans les deux directions à partir du anchorPoint . Cependant, si nous ajustons le anchorPoint au milieu du bord gauche, le calque s'étendra uniquement vers la droite tandis que le bord gauche restera fixe. Ce comportement est essentiel pour une barre de progression, car il garantit que la croissance de la barre apparaît d'un côté plutôt que des deux.


 private let progressMaskLayer = ProgressMaskLayer() private let progressBackgroundLayer = ProgressBackgroundLayer() public init() { super.init(frame: .zero) layer.addSublayer(progressMaskLayer) layer.addSublayer(progressBackgroundLayer) progressBackgroundLayer.mask = progressMaskLayer }


Dans l'extrait ci-dessus, nous initialisons deux parties principales de la barre de progression : ProgressMaskLayer et ProgressBackgroundLayer . Jetons un coup d'œil au deuxième.


 final class ProgressBackgroundLayer: CAGradientLayer { override init() { super.init() locations = [0, 0.5, 1] } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() colors = [ backgroundColor?.lightened(by: 0.3), backgroundColor, backgroundColor?.darkened(by: 0.5) ].compactMap({ $0 }) } }


Comme vous pouvez le voir, il s'agit d'un CAGradientLayer comportant trois couleurs. Cette implémentation transforme une couleur injectée en dégradé vertical, améliorant la visibilité de la barre de progression et lui donnant un aspect plus volumineux.


Pente

Pour rendre cela possible, j'ai préparé deux méthodes dans une extension CGColor . Pour m'amuser, j'ai choisi de ne pas reconvertir CGColor en UIColor , mais de jouer uniquement dans le domaine de CGColor :


 extension CGColor { func darkened(by value: CGFloat) -> CGColor { guard let colorSpace = self.colorSpace, let components = self.components, colorSpace.model == .rgb, components.count >= 3 else { return self } let multiplier = 1 - min(max(value, 0), 1) let red = components[0] * multiplier let green = components[1] * multiplier let blue = components[2] * multiplier let alpha = components.count > 3 ? components[3] : 1.0 return CGColor( colorSpace: colorSpace, components: [red, green, blue, alpha] ) ?? self } func lightened(by value: CGFloat) -> CGColor { guard let colorSpace = self.colorSpace, let components = self.components, colorSpace.model == .rgb, components.count >= 3 else { return self } let red = min(components[0] + value, 1.0) let green = min(components[1] + value, 1.0) let blue = min(components[2] + value, 1.0) let alpha = components.count > 3 ? components[3] : 1.0 return CGColor( colorSpace: colorSpace, components: [red, green, blue, alpha] ) ?? self } }


Comme vous pouvez le constater, ce n'est pas trop compliqué. Nous avons juste besoin d'obtenir colorSpace , components ( red , green , blue et alpha ) et model . Cela signifie qu'en fonction de la valeur, nous pouvons calculer une nouvelle couleur : soit plus foncée, soit plus claire.


Couche d'éblouissement

Continuons à rendre notre barre de progression plus 3D. Maintenant, nous devons ajouter un autre calque, que nous appellerons Glare.


C'est une implémentation simple, mais elle doit être conforme au protocole ProgressAnimatable .


 final class ProgressGlareLayer: CALayer, ProgressAnimatable { override init() { super.init() backgroundColor = UIColor.white.withAlphaComponent(0.3).cgColor anchorPoint = CGPoint(x: 0, y: 0.5) } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } 


Avec éblouissement


Pour simplifier les choses, nous avons seulement besoin que la couleur d’arrière-plan soit blanche avec un alpha de 0,3.


Little Nice Shimmering (Couche chatoyante)

Je sais que certains pourraient dire que MacOS X ou iOS avant la version 6 ne disposaient pas de composants d'interface utilisateur avec des effets chatoyants comme celui-ci. Mais comme je l’ai dit, c’est comme un film inspiré d’une histoire vraie. Ces reflets accentuent l'effet 3D et le rendent plus visible.


 final class ShimmeringLayer: CAGradientLayer, ProgressAnimatable { let glowColor: UIColor = UIColor.white private func shimmerAnimation() -> CABasicAnimation { let animation = CABasicAnimation(keyPath: "locations") animation.fromValue = [-1.0, -0.5, 0.0] animation.toValue = [1.0, 1.5, 2.0] animation.duration = 1.5 animation.repeatCount = Float.infinity return animation } private func opacityAnimation() -> CABasicAnimation { let animation = CABasicAnimation(keyPath: "opacity") animation.fromValue = 1.0 animation.toValue = 0.0 animation.duration = 0.1 // Quick transition to transparent animation.beginTime = shimmerAnimation().duration animation.fillMode = .forwards animation.isRemovedOnCompletion = false return animation } private func animationGroup() -> CAAnimationGroup { let pauseDuration = 3.0 // Duration of pause between shimmering let shimmerAnimation = shimmerAnimation() let opacityAnimation = opacityAnimation() let animationGroup = CAAnimationGroup() animationGroup.animations = [shimmerAnimation, opacityAnimation] animationGroup.duration = shimmerAnimation.duration + pauseDuration animationGroup.repeatCount = Float.infinity return animationGroup } override init() { super.init() setupLayer() } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() startAnimation() } private func setupLayer() { let lightColor = UIColor.white.withAlphaComponent(0.7).cgColor let darkColor = UIColor.white.withAlphaComponent(0).cgColor colors = [ darkColor, lightColor, darkColor ] anchorPoint = CGPoint(x: 0, y: 0.5) locations = [0.0, 0.5, 1.0] startPoint = CGPoint(x: 0.0, y: 0.5) endPoint = CGPoint(x: 1.0, y: 0.5) shadowColor = glowColor.cgColor shadowRadius = 5.0 shadowOpacity = 1 shadowOffset = .zero } private func startAnimation() { if animation(forKey: "shimmerEffect") == nil { let animationGroup = animationGroup() add(animationGroup, forKey: "shimmerEffect") } } }


Ici, nous avons besoin de deux animations (Opacity et Location) combinées dans un CAAnimationGroup . De plus, l'utilisation CAAnimationGroup nous permet d'organiser une pause entre les sessions d'animation. Cette fonctionnalité est très utile car les clients peuvent également vouloir contrôler cette propriété.


Avec des reflets chatoyants


Comme vous pouvez le voir sur l'image, cela ajoute plus d'effets 3D, mais ce n'est pas tout à fait suffisant. Allons de l'avant.

Couche d'arrière-plan

Nous devons travailler avec notre couche d'arrière-plan statique, qui se trouve sous la ligne de progression animée. Si vous vous en souvenez, nous y avons deux sous-couches supplémentaires : l'ombre intérieure et le dégradé de bordure.


 |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer]


Je dirais que ces deux couches étaient très populaires dans les requêtes de recherche il y a plusieurs années, probablement en raison des tendances de l'interface utilisateur. Créons nos propres versions et immortalisons-les dans cet article pour les générations futures :)

Couche d'ombre intérieure

La classe est conçue pour créer un calque avec un effet d’ombre interne, ce qui peut constituer un moyen visuellement attrayant d’ajouter de la profondeur aux éléments de l’interface utilisateur.


 final class InnerShadowLayer: CAShapeLayer { override init() { super.init() masksToBounds = true shadowRadius = 3 shadowColor = UIColor.black.cgColor shadowOffset = CGSize(width: 0.0, height: 1.0) shadowOpacity = 0.5 } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() // Creates a path for the shadow that extends slightly outside the bounds of the layer. let shadowPath = UIBezierPath(roundedRect: bounds.insetBy(dx: -5, dy: -5), cornerRadius: cornerRadius) // Creates a cutout path that is the inverse of the layer's bounds. let cutoutPath = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).reversing() shadowPath.append(cutoutPath) self.shadowPath = shadowPath.cgPath } }


Nous avons besoin d'un CAShapeLayer car nous devons définir shadowPath . Pour cela, nous devons créer un chemin pour l'ombre qui s'étend légèrement en dehors des limites du calque en utilisant insetBy frombounds. Et puis un autre UIBezierPath — un chemin de découpe qui est l'inverse des limites du calque.


Ombre intérieure


Dans l'illustration, c'est déjà presque là. Mais nous avons besoin de la dernière couche, qui nous ramènera à MacOS X Leopard.

Couche de dégradé de bordure

C'est une sous-classe de CAGradientLayer conçue pour créer une bordure dégradée autour d'un calque. Il utilise un CAShapeLayer comme masque pour obtenir l'effet de bordure. Le shapeLayer est configuré avec une couleur de trait (initialement noir) et aucune couleur de remplissage et est utilisé comme masque pour le BorderGradientLayer . Cette configuration permet au dégradé d'être visible uniquement là où le shapeLayer a un trait, créant ainsi une bordure de dégradé.


 final class BorderGradientLayer: CAGradientLayer { let shapeLayer: CAShapeLayer = { let layer = CAShapeLayer() layer.strokeColor = UIColor.black.cgColor // Temporary color layer.fillColor = nil return layer }() init(borderWidth: CGFloat, colors: [UIColor]) { super.init() self.mask = shapeLayer self.colors = colors.map { $0.cgColor } self.setBorderWidth(borderWidth) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() shapeLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).cgPath } func setBorderWidth(_ borderWidth: CGFloat) { self.shapeLayer.lineWidth = borderWidth } }


L'initialiseur de BorderGradientLayer accepte un borderWidth et un tableau de UIColor pour le dégradé. Il configure les couleurs du dégradé, applique la largeur de la bordure au shapeLayer et utilise le shapeLayer comme masque pour limiter le dégradé à la zone de bordure.


La méthode layoutSublayers garantit que le chemin de shapeLayer est mis à jour pour correspondre aux limites et au rayon des coins du calque, en s'assurant que la bordure s'adapte correctement au calque. La méthode setBorderWidth permet un ajustement dynamique de la largeur de la bordure après initialisation.


Avec bordure


Beaucoup mieux. D'accord. Arrêtons de faire des trucs visuels et travaillons sur la logique.

Tous ensemble

Il est maintenant temps de combiner toutes ces couches et de leur donner vie.


 private lazy var backgroundLayer = BackgroundLayer( borderWidth: borderWidth, colors: borderColors ) private let glareLayer = ProgressGlareLayer() private let shimmeringLayer = ShimmeringLayer() private let progressMaskLayer = ProgressMaskLayer() private let progressBackgroundLayer = ProgressBackgroundLayer() public init() { super.init(frame: .zero) layer.backgroundColor = UIColor.white.cgColor layer.masksToBounds = true layer.addSublayer(progressMaskLayer) layer.addSublayer(progressBackgroundLayer) layer.insertSublayer(backgroundLayer, at: 0) progressBackgroundLayer.addSublayer(shimmeringLayer) progressBackgroundLayer.addSublayer(glareLayer) progressBackgroundLayer.mask = progressMaskLayer }


Voyons comment nous allons gérer tous les calques dont la largeur doit changer avec l'animation :


 public func setValueWithAnimation(_ value: Double, duration: TimeInterval = 1.0, animationType: CAMediaTimingFunctionName = .easeInEaseOut, completion: (() -> Void)? = nil) { let newWidth = calculateProgressWidth(value) let animationGroup = DispatchGroup() animationGroup.enter() shimmeringLayer.animateToWidth( newWidth - WIDTH_ANIMATABLE_INSET, duration: duration, animationType: animationType, completion: animationGroup.leave ) animationGroup.enter() progressMaskLayer.animateToWidth( newWidth, duration: duration, animationType: animationType, completion: animationGroup.leave ) animationGroup.enter() glareLayer.animateToWidth( newWidth - WIDTH_ANIMATABLE_INSET, duration: duration, animationType: animationType, completion: animationGroup.leave ) animationGroup.notify(queue: .main) { completion?() } }


Tout d’abord, nous devons préparer une largeur à partir d’une valeur. La valeur doit être inférieure à 0 et supérieure à 1. De plus, nous devons prendre en considération une éventuelle largeur de bordure.


 fileprivate func normalizeValue(_ value: Double) -> Double { max(min(value, 1), 0) } private func calculateProgressWidth(_ value: Double) -> CGFloat { let normalizedValue = normalizeValue(value) let width = bounds.width * normalizedValue - borderWidth return width }


Pour un effet 3D correct, les calques éblouissants et chatoyants doivent être légèrement rembourrés sur les côtés droit et gauche :


 newWidth - WIDTH_ANIMATABLE_INSET


Et enfin, la méthode utilise un groupe d'animation ( DispatchGroup ) pour synchroniser les animations de trois calques : shimmeringLayer , progressMaskLayer et glareLayer . La largeur de chaque calque est animée à la largeur calculée (ajustée par WIDTH_ANIMATABLE_INSET pour shimmeringLayer et glareLayer pour s'adapter au design) sur la durée spécifiée et avec la courbe d'animation spécifiée.


Les animations sont coordonnées à l'aide du DispatchGroup , garantissant que toutes les animations démarrent simultanément et que la clôture d'achèvement n'est appelée qu'une fois toutes les animations terminées. Cette méthode offre un moyen visuellement attrayant de mettre à jour la valeur de la barre de progression avec une animation fluide et synchronisée sur ses différentes couches décoratives.

Code source

La dernière version existe sous forme de package Swift et de pod. Vous pouvez donc le récupérer à partir d'ici Référentiel Retro Progress Bar . Au fait, les contributions sont les bienvenues !

Usage

Créez une instance de RetroProgressBar. Vous pouvez l'ajouter à votre hiérarchie de vues comme vous le feriez avec n'importe quel UIView.


 let progressBar = RetroProgressBar()


Personnalisation :


 progressBar.progressColor = UIColor.systemBlue progressBar.cornerRadius = 5.0 progressBar.borderWidth = 2.0 progressBar.borderColors = [UIColor.white, UIColor.gray]


Pour modifier la valeur avec une animation :


 progressBar.setValueWithAnimation(0.75, duration: 1.0, animationType: .easeInEaseOut) { print("Animation Completed") }


Sans animation :


 progressBar.setValue(0.5)

Dernières pensées

Recréer cette barre de progression à l’ancienne a été une plongée nostalgique dans l’esthétique qui me manque profondément. Travailler avec UIKit et CALayer m'a rappelé la polyvalence et la puissance de nos outils en tant que développeurs. Ce projet ne concernait pas seulement la mise en œuvre technique ; c'était un voyage dans une époque de design bien-aimée, prouvant que nous pouvons encore créer n'importe quoi, y compris le charme intemporel des modèles plus anciens.


Cette expérience a été un rappel poignant que dans la conception de l'interface utilisateur , la simplicité et la nostalgie peuvent coexister à merveille, reliant le passé et le présent.


Bon codage !