Me encanta crear componentes de interfaz de usuario. Me da una especie de placer estético verlos y usarlos después de realizar una construcción. Esto también se combina con mi amor por el ecosistema MacOS/iOS en general. Recientemente, me encargaron la tarea de crear una barra de progreso personalizada usando UIKit
. No es gran cosa, pero probablemente el 99% de los desarrolladores de iOS hayan creado uno desde cero en algún momento de sus carreras. En mi caso, quería hacer algo más que simplemente crear un par de CALayer
con animaciones basadas en un rango de valores de 0 a 1. Buscaba una solución que fuera adecuada para muchos escenarios donde se necesita una barra de progreso. .
Quiero regresar a los días en que los componentes de la interfaz de usuario en el mundo de Apple se veían así:
Reflexionando sobre la evolución del diseño de la interfaz de usuario, ha pasado casi una década desde que los elementos de la interfaz de usuario en el mundo Apple adoptaron un diseño plano con la introducción de iOS 7 en 2014. Entonces, dejemos que la nostalgia por las versiones anteriores de Mac OS e iOS y creemos una hermosa barra de progreso juntos.
Si le gusta usar UIKit
pero aún no está seguro de cómo manipular CALayers, este artículo le resultará útil. Aquí, analizaremos los desafíos típicos de diseño, animación y dibujo, brindando información y soluciones para ayudarlo en el camino.
La barra de progreso puede parecer sencilla, pero si profundizamos en los detalles, comprenderá que requiere muchas subcapas.
No voy a profundizar en CALayers
en detalle aquí. Puede consultar este artículo donde describí brevemente qué son CALayers
y cómo funcionan: https://hackernoon.com/rolling-numbers-animation-using-only-calayers .
Entonces, dividámoslo en pequeñas capas:
La capa de fondo tiene dos capas secundarias adicionales:
Estructurémoslo correctamente:
RetroProgressBar [UIView] |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer] |--Progress Background Layer [CALayer] |--Animated Mask Layer [CALayer] |--Glare [CALayer] |--Shimering [CALayer]
Brevemente, así es como debería funcionar: La barra de progreso debería reaccionar a un valor entre 0,0 y 1,0. Cuando el valor cambia, necesitamos ajustar el ancho de una capa según este valor:
bounds.width * value
Usando este simple cálculo, podemos modificar el ancho usando CATransaction
. Pero antes de hacer esto, debemos comprender qué capas deberían cambiar cuando cambia el valor. Revisemos la estructura e identifiquemos las capas que deben responder a los cambios de valores.
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]
Parece que necesitamos desarrollar un protocolo que puedan adoptar las capas que necesitan un ancho animado según el valor.
En protocolo, solo necesitamos dos métodos: cambiar el ancho de la capa con animación y sin ella.
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)?) }
Implementación:
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() } }
Ambos métodos utilizan CATransaction
. El primer método puede parecer extraño porque sólo necesitamos cambiar un valor sin animación. Entonces, ¿por qué es necesaria CATransaction
?
Intente crear un CALayer
básico y cambiar su ancho o alto sin ninguna animación. Cuando ejecuta una compilación, notará que CALayer
cambia con una animación. ¿Cómo es esto posible? En Core Animation, los cambios en las propiedades de la capa (como límites, posición, opacidad, etc.) generalmente se animan según la acción predeterminada asociada con esa propiedad. Este comportamiento predeterminado tiene como objetivo proporcionar transiciones visuales fluidas sin la necesidad de animaciones explícitas para cada cambio de propiedad.
Esto significa que debemos deshabilitar explícitamente las acciones en CALayer
. Al configurar CATransaction.setDisableActions(true)
, se asegura de que el ancho de la capa se actualice instantáneamente al nuevo valor sin ninguna transición animada intermedia.
El segundo método ofrece una forma flexible y reutilizable de animar el ancho de cualquier capa que cumpla con el protocolo ProgressAnimatable
. Proporciona control sobre la duración y el ritmo de la animación e incluye una opción para una acción de finalización. Esto lo hace adecuado para diversas necesidades de animación dentro del marco UIKit.
Nuestros candidatos para ajustarse a este protocolo serán tres capas: Capa de máscara animada, Capa de deslumbramiento y Capa brillante. Sigamos adelante y lo implementemos.
Es hora de ajustarnos a nuestro protocolo, y la capa más importante en esta configuración será nuestra CAShapeLayer
general. ¿Por qué necesitamos esta máscara? ¿Por qué no puede ser simplemente un CALayer
con un ancho animado? Como ingeniero de iOS , especialmente uno que se centra en el desarrollo front-end, a menudo necesita anticipar posibles casos de uso para sus componentes. En mi caso, hay una capa de fondo de progreso. Este no es un CALayer
animado, pero ¿y si estuviera animado con algo como CAReplicatorLayer
? Si le preocupa el rendimiento, probablemente considere que dicha capa debe renderizarse una vez y luego simplemente realizar su animación. Por eso esta mascarilla puede resultar de gran utilidad. Permite una renderización eficiente y al mismo tiempo ofrece la flexibilidad necesaria para diversos escenarios de animación.
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") } }
Echemos un vistazo a esta línea:
anchorPoint = CGPoint(x: 0, y: 0.5)
Esta línea es crucial para nuestra barra de progreso. De forma predeterminada, CALayers
están centradas, con el anchorPoint
ubicado en el centro de la capa, no en el medio del borde izquierdo como comúnmente se supone. El anchorPoint
es el punto alrededor del cual ocurren todas las transformaciones de la capa. Para una capa utilizada como barra de progreso, esta configuración generalmente significa que cuando el ancho de la capa cambia, se expande igualmente en ambas direcciones desde el anchorPoint
. Sin embargo, si ajustamos el anchorPoint
al centro del borde izquierdo, la capa se expandirá solo hacia la derecha mientras el borde izquierdo permanece fijo en su lugar. Este comportamiento es esencial para una barra de progreso, ya que garantiza que el crecimiento de la barra aparezca desde un lado y no desde ambos.
private let progressMaskLayer = ProgressMaskLayer() private let progressBackgroundLayer = ProgressBackgroundLayer() public init() { super.init(frame: .zero) layer.addSublayer(progressMaskLayer) layer.addSublayer(progressBackgroundLayer) progressBackgroundLayer.mask = progressMaskLayer }
En el fragmento anterior, inicializamos dos partes principales de la barra de progreso: ProgressMaskLayer
y ProgressBackgroundLayer
. Echemos un vistazo al segundo.
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 }) } }
Como puede ver, es un CAGradientLayer
que presenta tres colores. Esta implementación transforma un color inyectado en un degradado vertical, mejorando la visibilidad de la barra de progreso y dándole una apariencia más voluminosa.
Para que esto sea posible, preparé dos métodos en una extensión CGColor
. Por diversión, elegí no convertir CGColor
nuevamente a UIColor
, sino jugar únicamente dentro del ámbito 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 } }
Como puedes ver, no es demasiado complicado. Solo necesitamos obtener colorSpace
, components
( red
, green
, blue
y alpha
) y model
. Esto significa que, en función del valor, podemos calcular un nuevo color: más oscuro o más claro.
Avancemos para hacer que nuestra barra de progreso tenga un aspecto más 3D. Ahora necesitamos agregar otra capa, a la que llamaremos Glare.
Es una implementación sencilla, pero debe ajustarse al protocolo 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") } }
Para simplificar las cosas, aquí solo necesitamos que el color de fondo sea blanco con un alfa de 0,3.
Sé que algunos podrían decir que MacOS X o iOS anteriores a la versión 6 no tenían componentes de interfaz de usuario con efectos brillantes como este. Pero, como dije, esto es como una película inspirada en una historia real. Este brillo acentúa el efecto 3D y lo hace más 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") } } }
Aquí, necesitamos dos animaciones (Opacidad y Ubicación) combinadas en un CAAnimationGroup
. Además, usar CAAnimationGroup
nos permite organizar una pausa entre sesiones de animación. Esta característica es bastante útil porque es posible que los clientes también quieran controlar esta propiedad.
Como puedes ver en la imagen, esto añade más efectos 3D, pero no es suficiente. Sigamos adelante.
Necesitamos trabajar con nuestra capa de fondo estática, que se encuentra debajo de la Línea de Progreso animada. Si recuerdas, tenemos dos subcapas adicionales allí: la Sombra interior y el Degradado del borde.
|--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer]
Yo diría que estas dos capas fueron bastante populares en las consultas de búsqueda hace varios años, posiblemente debido a las tendencias de la interfaz de usuario. Creemos nuestras propias versiones e inmortalicémoslas en este artículo para las generaciones futuras :)
La clase está diseñada para crear una capa con un efecto de sombra interior, que puede ser una forma visualmente atractiva de agregar profundidad a los elementos de la interfaz de usuario.
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 } }
Necesitamos un CAShapeLayer
porque tenemos que configurar shadowPath
. Para eso, necesitamos crear un camino para la sombra que se extienda ligeramente fuera de los límites de la capa usando insetBy
from limits. Y luego otro UIBezierPath
: un camino recortado que es el inverso de los límites de la capa.
En la ilustración, ya casi está allí. Pero necesitamos la última capa, que nos devolverá a MacOS X Leopard.
Es una subclase de CAGradientLayer
diseñada para crear un borde degradado alrededor de una capa. Utiliza CAShapeLayer
como máscara para lograr el efecto de borde. shapeLayer
está configurado con un color de trazo (inicialmente negro) y sin color de relleno y se usa como máscara para BorderGradientLayer
. Esta configuración permite que el degradado sea visible solo donde la shapeLayer
tiene un trazo, creando efectivamente un borde de degradado.
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 } }
El inicializador de BorderGradientLayer
acepta un borderWidth
y una matriz de UIColor
para el degradado. Configura los colores del degradado, aplica el ancho del borde a shapeLayer
y usa shapeLayer
como máscara para restringir el degradado al área del borde.
El método layoutSublayers
garantiza que la ruta de shapeLayer
se actualice para que coincida con los límites de la capa y el radio de las esquinas, asegurándose de que el borde se ajuste a la capa correctamente. El método setBorderWidth
permite el ajuste dinámico del ancho del borde después de la inicialización.
Mucho mejor. De acuerdo. Dejemos de hacer cosas visuales y trabajemos en la lógica.
Ahora es el momento de combinar todas estas capas y dejarlas cobrar vida.
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 }
Echemos un vistazo a cómo vamos a tratar con todas las capas que deberían cambiar su ancho con la animación:
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?() } }
Primero, necesitamos preparar un ancho a partir de un valor. El valor debe ser menor que 0 y mayor que 1. Además, debemos tener en cuenta el posible ancho del borde.
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 }
Para lograr el efecto 3D correcto, las capas de brillo y brillo deben tener un poco de relleno en los lados derecho e izquierdo:
newWidth - WIDTH_ANIMATABLE_INSET
Y finalmente, el método utiliza un grupo de animación ( DispatchGroup
) para sincronizar las animaciones de tres capas: shimmeringLayer
, progressMaskLayer
y glareLayer
. El ancho de cada capa se anima al ancho calculado (ajustado por WIDTH_ANIMATABLE_INSET
para shimmeringLayer
y glareLayer
para ajustarse al diseño) durante la duración especificada y con la curva de animación especificada.
Las animaciones se coordinan mediante DispatchGroup
, lo que garantiza que todas las animaciones comiencen simultáneamente y que el cierre de finalización se llame solo después de que todas las animaciones hayan finalizado. Este método proporciona una forma visualmente atractiva de actualizar el valor de la barra de progreso con una animación fluida y sincronizada en sus diversas capas decorativas.
La última versión existe como paquete Swift y Pod. Así que puedes obtenerlo desde aquí Repositorio de Retro Progress Bar . Por cierto, ¡las contribuciones son bienvenidas!
Crea una instancia de RetroProgressBar. Puede agregarlo a su jerarquía de vistas como lo haría con cualquier UIView.
let progressBar = RetroProgressBar()
Personalización:
progressBar.progressColor = UIColor.systemBlue progressBar.cornerRadius = 5.0 progressBar.borderWidth = 2.0 progressBar.borderColors = [UIColor.white, UIColor.gray]
Para cambiar el valor con animación:
progressBar.setValueWithAnimation(0.75, duration: 1.0, animationType: .easeInEaseOut) { print("Animation Completed") }
Sin animación:
progressBar.setValue(0.5)
Recrear esta barra de progreso de estilo antiguo ha sido una inmersión nostálgica en la estética que extraño profundamente. Trabajar con UIKit y CALayer
me recordó la versatilidad y el poder de nuestras herramientas como desarrolladores. Este proyecto no se trataba sólo de implementación técnica; fue un viaje de regreso a una querida era del diseño, demostrando que todavía podemos crear cualquier cosa, incluido el encanto atemporal de los diseños más antiguos.
Esta experiencia ha sido un conmovedor recordatorio de que en el diseño de la interfaz de usuario , la simplicidad y la nostalgia pueden coexistir maravillosamente, uniendo el pasado y el presente.
¡Feliz codificación!