paint-brush
Retrocesso da IU: criação de uma barra de progresso retrô para iOS usando CALayersby@maxkalik
637
637

Retrocesso da IU: criação de uma barra de progresso retrô para iOS usando CALayers

Max Kalik22m2023/12/17
Read on Terminal Reader

Este guia explora a criação de uma barra de progresso em estilo retrô no iOS, usando CALayers para um toque nostálgico. Ele cobre o processo passo a passo, desde o design de cada camada até a implementação de animações, oferecendo insights sobre como combinar a estética tradicional com o desenvolvimento moderno do iOS. Ideal para desenvolvedores e designers de UI, o artigo fornece profundidade técnica e inspiração criativa para componentes de UI exclusivos. Código fonte: https://github.com/maxkalik/RetroProgressBar
featured image - Retrocesso da IU: criação de uma barra de progresso retrô para iOS usando CALayers
Max Kalik HackerNoon profile picture
0-item


Adoro construir componentes de UI. Dá-me uma espécie de prazer estético vê-los e utilizá-los depois de fazer uma construção. Isso também combina com meu amor pelo ecossistema MacOS/iOS em geral. Recentemente, recebi a tarefa de criar uma barra de progresso personalizada usando UIKit . Não é grande coisa, mas provavelmente 99% dos desenvolvedores iOS criaram um do zero em algum momento de suas carreiras. No meu caso, eu queria fazer um pouco mais do que apenas criar alguns CALayer s com animações baseadas em uma faixa de valores de 0 a 1. Eu estava buscando uma solução que fosse adequada para muitos cenários onde uma barra de progresso é necessária .


Quero voltar aos dias em que os componentes de UI no mundo Apple eram assim:


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


Refletindo sobre a evolução do design de UI, já se passou quase uma década desde que os elementos de UI no mundo Apple adotaram um design plano com a introdução do iOS 7 em 2014. Então, vamos nos entregar à nostalgia das versões anteriores do Mac OS e iOS e criar uma bela barra de progresso juntos.


Se você gosta de usar UIKit , mas ainda não tem certeza sobre como manipular CALayers, este artigo será útil para você. Aqui, passaremos por desafios típicos de layout, animação e desenho, fornecendo insights e soluções para ajudá-lo ao longo do caminho.

Do UIView profundamente na API CALayers.

A barra de progresso pode parecer simples, mas se nos aprofundarmos nos detalhes, você entenderá que ela requer muitas subcamadas.


Barra de progresso retrô


Não vou me aprofundar no CALayers em detalhes aqui. Você pode conferir este artigo onde descrevi brevemente o que são CALayers e como funcionam: https://hackernoon.com/rolling-numbers-animation-using-only-calayers .


Então, vamos dividir em pequenas camadas:

  1. Camada de fundo geral
  2. Camada de máscara (parte animável)
  3. Camada de fundo de progresso
  4. Camada de brilho
  5. Camada Cintilante


A camada de fundo possui duas camadas secundárias adicionais:

  1. Camada de sombra interna
  2. Camada de gradiente de borda


Vamos estruturá-lo corretamente:


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


Resumidamente, é assim que deveria funcionar: A Barra de Progresso deveria reagir a um valor variando de 0,0 a 1,0. Quando o valor muda, precisamos ajustar a largura de uma camada com base neste valor:


 bounds.width * value


Usando este cálculo simples, podemos modificar a largura usando CATransaction . Mas antes de fazermos isso, precisamos entender quais camadas devem mudar quando o valor muda. Vamos revisitar a estrutura e identificar as camadas que precisam responder às mudanças de valor.


 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 precisamos desenvolver um protocolo que possa ser adotado pelas camadas que necessitam de largura animada baseada em valor.

Protocolo ProgressAnimável

No protocolo, precisamos apenas de dois métodos: alterar a largura da camada com e sem animação.


 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)?) }


Implementação:


 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 os métodos usam CATransaction . O primeiro método pode parecer estranho porque só precisamos alterar um valor sem animação. Então, por que CATransaction é necessário?


Tente criar um CALayer básico e alterar sua largura ou altura sem qualquer animação. Ao executar uma compilação, você notará as alterações CALayer com uma animação. Como isso é possível? Na Core Animation, as alterações nas propriedades da camada (como limites, posição, opacidade, etc.) são normalmente animadas com base na ação padrão associada a essa propriedade. Este comportamento padrão destina-se a fornecer transições visuais suaves sem a necessidade de animações explícitas para cada alteração de propriedade.


Isso significa que precisamos desativar explicitamente as ações no CALayer . Ao definir CATransaction.setDisableActions(true) , você garante que a largura da camada seja atualizada instantaneamente para o novo valor sem qualquer transição animada intermediária.


O segundo método oferece uma maneira flexível e reutilizável de animar a largura de qualquer camada em conformidade com o protocolo ProgressAnimatable . Ele fornece controle sobre a duração e o ritmo da animação e inclui uma opção para uma ação de conclusão. Isso o torna adequado para várias necessidades de animação dentro da estrutura UIKit.


Nossos candidatos para estar em conformidade com este protocolo serão três camadas: Camada de Máscara Animada, Camada de Brilho e Camada Cintilante. Vamos em frente e implementá-lo.

ProgressMaskLayer

É hora de estar em conformidade com nosso protocolo, e a camada mais importante nesta configuração será nossa CAShapeLayer geral. Por que precisamos desta máscara? Por que não pode ser apenas um CALayer com largura animada? Como engenheiro iOS , especialmente aquele com foco no desenvolvimento front-end, muitas vezes você precisa antecipar possíveis casos de uso para seus componentes. No meu caso, há uma camada de fundo de progresso. Este não é um CALayer animado, mas e se fosse animado com algo como CAReplicatorLayer ? Se você estiver preocupado com o desempenho, provavelmente consideraria que tal camada deveria ser renderizada uma vez e então simplesmente executar sua animação. É por isso que esta máscara pode ser extremamente útil. Ele permite uma renderização eficiente e ao mesmo tempo oferece a flexibilidade necessária para vários cenários de animação.


 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") } }


Vamos dar uma olhada nesta linha:


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


Esta linha é crucial para nossa Barra de Progresso. Por padrão, CALayers são centralizados, com o anchorPoint localizado no centro da camada, e não no meio da borda esquerda, como comumente se supõe. O anchorPoint é o ponto em torno do qual ocorrem todas as transformações da camada. Para uma camada usada como barra de progresso, essa configuração normalmente significa que quando a largura da camada muda, ela se expande igualmente em ambas as direções a partir do anchorPoint . No entanto, se ajustarmos o anchorPoint para o meio da borda esquerda, a camada se expandirá apenas para a direita enquanto a borda esquerda permanecerá fixa no lugar. Este comportamento é essencial para uma barra de progresso, garantindo que o crescimento da barra apareça de um lado e não de ambos.


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


No trecho acima, inicializamos duas partes principais da Barra de Progresso: ProgressMaskLayer e ProgressBackgroundLayer . Vamos dar uma olhada no 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 você pode ver, é um CAGradientLayer com três cores. Esta implementação transforma uma cor injetada em um gradiente vertical, melhorando a visibilidade da barra de progresso e conferindo-lhe uma aparência mais volumosa.


Gradiente

Para tornar isso possível, preparei dois métodos em uma extensão CGColor . Por diversão, optei por não converter CGColor de volta para UIColor , mas jogar apenas dentro do domínio 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 você pode ver, não é muito complicado. Precisamos apenas obter colorSpace , components ( red , green , blue e alpha ) e model . Isso significa que com base no valor podemos calcular uma nova cor: mais escura ou mais clara.


Camada de brilho

Vamos avançar para tornar nossa barra de progresso mais 3D. Agora precisamos adicionar outra camada, que chamaremos de Glare.


É uma implementação simples, mas deve estar em conformidade com o 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") } } 


Com brilho


Para manter as coisas simples, precisamos apenas que a cor de fundo aqui seja branca com um alfa de 0,3.


Little Nice Shimmering (camada cintilante)

Eu sei que alguns podem dizer que o MacOS X ou iOS antes da versão 6 não tinha componentes de UI com efeitos brilhantes como este. Mas, como eu disse, é como um filme inspirado em uma história real. Este brilho acentua o efeito 3D e o torna mais visível.


 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") } } }


Aqui, precisamos de duas animações (Opacidade e Localização) combinadas em um CAAnimationGroup . Além disso, usar CAAnimationGroup nos permite organizar uma pausa entre as sessões de animação. Esse recurso é bastante útil porque os clientes também podem querer controlar essa propriedade.


Com cintilante


Como você pode ver na imagem, isso adiciona mais efeitos 3D, mas não é suficiente. Vamos seguir em frente.

Camada de fundo

Precisamos trabalhar com nossa camada de fundo estática, que fica abaixo da Linha de Progresso animada. Se você se lembra, temos duas subcamadas adicionais: a Inner Shadow e o Border Gradient.


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


Eu diria que essas duas camadas eram bastante populares nas consultas de pesquisa há vários anos, possivelmente devido às tendências da IU. Vamos criar nossas próprias versões e eternizá-las neste artigo para as gerações futuras :)

Camada de sombra interna

A classe foi projetada para criar uma camada com efeito de sombra interna, o que pode ser uma forma visualmente atraente de adicionar profundidade aos elementos da IU.


 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 } }


Precisamos de um CAShapeLayer porque precisamos definir shadowPath . Para isso, precisamos criar um caminho para a sombra que se estenda um pouco fora dos limites da camada usando insetBy frombounds. E então outro UIBezierPath — um caminho de recorte que é o inverso dos limites da camada.


Sombra interior


Na ilustração já está quase lá. Mas precisamos da última camada, que nos trará de volta ao MacOS X Leopard.

Camada de gradiente de borda

É uma subclasse de CAGradientLayer projetada para criar uma borda gradiente ao redor de uma camada. Ele usa um CAShapeLayer como máscara para obter o efeito de borda. O shapeLayer é configurado com uma cor de traço (inicialmente preto) e sem cor de preenchimento e é usado como máscara para BorderGradientLayer . Esta configuração permite que o gradiente seja visível apenas onde o shapeLayer tiver um traço, criando efetivamente uma borda gradiente.


 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 } }


O inicializador de BorderGradientLayer aceita um borderWidth e uma matriz de UIColor s para o gradiente. Ele configura as cores do gradiente, aplica a largura da borda ao shapeLayer e usa o shapeLayer como uma máscara para restringir o gradiente à área da borda.


O método layoutSublayers garante que o caminho de shapeLayer seja atualizado para corresponder aos limites da camada e ao raio do canto, garantindo que a borda se ajuste corretamente à camada. O método setBorderWidth permite o ajuste dinâmico da largura da borda após a inicialização.


Com borda


Muito melhor. OK. Vamos parar de fazer coisas visuais e vamos trabalhar na lógica.

Todos juntos

Agora é hora de combinar todas essas camadas e deixá-las ganhar 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 }


Vamos dar uma olhada em como vamos lidar com todas as camadas que devem mudar de largura com animação:


 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?() } }


Primeiro, precisamos preparar uma largura a partir de um valor. O valor deve ser menor que 0 e maior que 1. Além disso, precisamos levar em consideração uma possível largura da borda.


 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 o efeito 3D correto, as camadas brilhantes e cintilantes devem ter um pouco de preenchimento nos lados direito e esquerdo:


 newWidth - WIDTH_ANIMATABLE_INSET


E, finalmente, o método usa um grupo de animação ( DispatchGroup ) para sincronizar as animações de três camadas: shimmeringLayer , progressMaskLayer e glareLayer . A largura de cada camada é animada para a largura calculada (ajustada por WIDTH_ANIMATABLE_INSET para shimmeringLayer e glareLayer para ajustar o design) durante a duração especificada e com a curva de animação especificada.


As animações são coordenadas usando o DispatchGroup , garantindo que todas as animações comecem simultaneamente e o encerramento da conclusão seja chamado somente após todas as animações terem terminado. Este método fornece uma maneira visualmente atraente de atualizar o valor da barra de progresso com uma animação suave e sincronizada em suas diversas camadas decorativas.

Código fonte

A versão mais recente existe como um pacote e pod Swift. Então você pode obtê-lo aqui Repositório Retro Progress Bar . Aliás, contribuições são bem-vindas!

Uso

Crie uma instância de RetroProgressBar. Você pode adicioná-lo à sua hierarquia de visualização como faria com qualquer UIView.


 let progressBar = RetroProgressBar()


Costumização:


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


Para alterar o valor com animação:


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


Sem animação:


 progressBar.setValue(0.5)

Pensamentos finais

Recriar esta barra de progresso de estilo antigo foi um mergulho nostálgico na estética da qual sinto muita falta. Trabalhar com UIKit e CALayer me lembrou da versatilidade e do poder de nossas ferramentas como desenvolvedores. Este projeto não se tratava apenas de implementação técnica; foi uma viagem de volta a uma era de design adorada, provando que ainda podemos criar qualquer coisa, incluindo o charme atemporal de designs mais antigos.


Essa experiência foi um lembrete comovente de que, no design de UI , simplicidade e nostalgia podem coexistir lindamente, unindo o passado e o presente.


Boa codificação!