paint-brush
UI 复古:使用 CALayers 创建 iOS 复古进度条经过@maxkalik
680 讀數
680 讀數

UI 复古:使用 CALayers 创建 iOS 复古进度条

经过 Max Kalik22m2023/12/17
Read on Terminal Reader

太長; 讀書

本指南探讨了在 iOS 中创建复古风格的进度条,使用 CALayers 来营造怀旧的感觉。它涵盖了从设计每一层到实现动画的分步过程,提供了将老式美学与现代 iOS 开发相结合的见解。本文非常适合开发人员和 UI 设计师,为独特的 UI 组件提供了技术深度和创意灵感。源代码:https://github.com/maxkalik/RetroProgressBar
featured image - UI 复古:使用 CALayers 创建 iOS 复古进度条
Max Kalik HackerNoon profile picture
0-item


我喜欢构建 UI 组件。在构建完成后看到和使用它们给我一种审美上的乐趣。这也与我对 MacOS/iOS 生态系统整体的热爱相结合。最近,我的任务是使用UIKit创建自定义进度条。这不是什么大问题,但可能 99% 的iOS 开发者在其职业生涯的某个阶段都曾从头开始开发过一款应用。就我而言,我想做的不仅仅是创建几个带有基于 0 到 1 值范围的动画的CALayer 。我的目标是找到一个适合许多需要进度条的场景的解决方案。


我想让自己回到 Apple 世界中 UI 组件看起来像这样的日子:


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


反思 UI 设计的演变,自 2014 年 iOS 7 推出以来,苹果世界的 UI 元素采用扁平化设计已经近十年了。因此,让我们沉迷于对 Mac OS 和 iOS 早期版本的怀念,并创造一个漂亮的进度条在一起。


如果您喜欢使用UIKit但仍不确定如何操作 CALayers,那么本文将对您有用。在这里,我们将解决典型的布局、动画和绘图挑战,提供见解和解决方案来帮助您一路前进。

从 UIView 深入到 CALayers API。

进度条可能看起来很简单,但如果我们深入研究细节,您就会明白它需要很多子层。


复古进度条


我不打算在这里详细研究CALayers 。您可以查看这篇文章,其中我简要描述了CALayers是什么以及它们如何工作:https: //hackernoon.com/rolling-numbers-animation-using-only-calayers


那么,让我们把它分成几个小层:

  1. 一般背景层
  2. 遮罩层(可动画部分)
  3. 进度背景层
  4. 眩光层
  5. 闪光层


背景层有两个额外的子层:

  1. 内阴影层
  2. 边框渐变层


让我们正确地构建它:


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


简而言之,它的工作原理如下:进度条应该对 0.0 到 1.0 之间的值做出反应。当该值发生变化时,我们需要根据该值调整图层的宽度:


 bounds.width * value


通过这个简单的计算,我们可以使用CATransaction修改宽度。但在执行此操作之前,我们需要了解当值发生变化时哪些层应该发生变化。让我们重新审视该结构并确定需要响应值变化的层。


 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]


看来我们需要开发一种协议,供需要基于值的动画宽度的层采用。

ProgressAnimatable 协议

在协议中,我们只需要两种方法:使用动画和不使用动画来更改图层的宽度。


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


执行:


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


两种方法都使用CATransaction 。第一种方法可能看起来很奇怪,因为我们只需要更改一个值而不需要动画。那么,为什么需要CATransaction呢?


尝试创建一个基本的CALayer并在没有任何动画的情况下更改其宽度或高度。当您运行构建时,您会注意到CALayer随动画变化。这怎么可能?在核心动画中,对图层属性(例如边界、位置、不透明度等)的更改通常根据与该属性关联的默认操作进行动画处理。此默认行为旨在提供平滑的视觉过渡,而不需要为每个属性更改提供显式动画。


这意味着我们需要显式禁用CALayer中的操作。通过设置CATransaction.setDisableActions(true) ,您可以确保图层的宽度立即更新为新值,而无需任何中间动画过渡。


第二种方法提供了一种灵活且可重用的方式来对符合ProgressAnimatable协议的任何层的宽度进行动画处理。它提供对动画的持续时间和节奏的控制,并包括完成操作的选项。这使得它非常适合 UIKit 框架内的各种动画需求。


我们符合该协议的候选层将是三层:动画掩模层、眩光层和闪烁层。让我们继续实施它。

进度掩模层

是时候遵守我们的协议了,此设置中最重要的层将是我们的通用CAShapeLayer 。为什么我们需要这个面具?为什么它不能只是一个具有动画宽度的CALayer ?作为一名iOS 工程师,尤其是专注于前端开发的工程师,您通常需要预测组件的潜在用例。在我的例子中,有一个进度背景层。这不是一个动画CALayer ,但是如果用CAReplicatorLayer之类的东西来制作动画会怎么样?如果您担心性能,您可能会认为这样的图层应该渲染一次,然后简单地执行其动画。这就是为什么这款面膜非常有用。它可以实现高效渲染,同时仍然提供各种动画场景所需的灵活性。


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


让我们看一下这一行:


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


这条线对于我们的进度条至关重要。默认情况下, CALayers居中, anchorPoint位于图层的中心,而不是通常假设的左边缘的中间。 anchorPoint是图层的所有变换都围绕其发生的点。对于用作进度条的图层,此设置通常意味着当图层的宽度发生变化时,它会从anchorPoint向两个方向均匀扩展。但是,如果我们将anchorPoint调整到左边缘的中间,则图层将仅向右扩展,而左边缘保持固定不动。此行为对于进度条至关重要,可确保进度条的增长出现在一侧而不是两侧。


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


在上面的代码片段中,我们初始化了进度条的两个主要部分: ProgressMaskLayerProgressBackgroundLayer 。我们来看看第二个。


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


正如您所看到的,它是一个具有三种颜色的CAGradientLayer 。此实现将注入的颜色转换为垂直渐变,增强了进度条的可见性并赋予其更大的外观。


坡度

为了实现这一点,我在CGColor扩展中准备了两个方法。为了好玩,我选择不将CGColor转换回UIColor ,而是仅在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 } }


正如您所看到的,它并不太复杂。我们只需要获取colorSpacecomponentsredgreenbluealpha )和model 。这意味着根据该值,我们可以计算出新的颜色:更深或更浅。


眩光层

让我们努力让进度条更加 3D 化。现在,我们需要添加另一个图层,我们将其称为眩光。


这是一个简单的实现,但它应该符合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") } } 


有眩光


为了简单起见,我们只需要这里的背景颜色为白色,alpha 为 0.3。


小漂亮闪闪发光(闪闪发光层)

我知道有些人可能会说版本 6 之前的 MacOS X 或 iOS 没有像这样具有闪烁效果的 UI 组件。但是,正如我所说,这就像一部受真实故事启发的电影。这种闪烁增强了 3D 效果并使其更加明显。


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


在这里,我们需要将两个动画(不透明度和位置)组合成一个CAAnimationGroup 。此外,使用CAAnimationGroup允许我们在动画会话之间安排暂停。此功能非常有用,因为客户端可能也想控制此属性。


带着闪烁的


正如您在图片中看到的,这增加了更多 3D 效果,但这还不够。让我们继续前进。

背景层

我们需要使用位于动画进度线下方的静态背景层。如果您还记得的话,我们还有两个额外的子图层:内阴影和边框渐变。


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


我想说,几年前,这两个层在搜索查询中非常流行,可能是由于 UI 趋势所致。让我们创建自己的版本,并在本文中为子孙后代永垂不朽:)

内阴影层

该类旨在创建具有内部阴影效果的图层,这可以是一种在视觉上吸引人的方式来增加 UI 元素的深度。


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


我们需要一个CAShapeLayer因为我们必须设置shadowPath 。为此,我们需要使用边界中的insetBy为阴影创建一条稍微延伸到图层边界之外的路径。然后是另一个UIBezierPath - 与图层边界相反的剪切路径。


内心的阴影


在插图中,它已经快到了。但我们需要最后一层,这将使我们回到 MacOS X Leopard。

边框渐变层

它是CAGradientLayer的子类,旨在在图层周围创建渐变边框。它使用CAShapeLayer作为遮罩来实现边框效果。 shapeLayer配置有描边颜色(最初为黑色),没有填充颜色,并用作BorderGradientLayer的遮罩。此设置允许渐变仅在shapeLayer有笔划的地方可见,从而有效地创建渐变边框。


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


BorderGradientLayer的初始化程序接受borderWidth和用于渐变的UIColor数组。它设置渐变颜色,将边框宽度应用于shapeLayer ,并使用shapeLayer作为遮罩将渐变限制为边框区域。


layoutSublayers方法确保更新shapeLayer的路径以匹配图层的边界和角半径,确保边框正确适合图层。 setBorderWidth方法允许在初始化后动态调整边框的宽度。


有边框


好多了。好的。让我们停止制作视觉内容,让我们研究逻辑。

全部一起

现在,是时候将所有这些层结合起来并让它们融入生活了。


 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 }


让我们看一下如何处理所有应该通过动画改变宽度的图层:


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


首先,我们需要根据一个值准备一个宽度。该值应小于 0 且大于 1。另外,我们需要考虑可能的边框宽度。


 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 }


为了获得正确的 3D 效果,眩光和闪烁层的左右两侧应有一些填充:


 newWidth - WIDTH_ANIMATABLE_INSET


最后,该方法使用动画组( DispatchGroup )来同步三个层的动画: shimmeringLayerprogressMaskLayerglareLayer 。每个图层的宽度都会在指定的持续时间内并使用指定的动画曲线动画到计算的宽度(通过shimmeringLayerglareLayerWIDTH_ANIMATABLE_INSET进行调整以适应设计)。


使用DispatchGroup协调动画,确保所有动画同时启动,并且仅在所有动画完成后调用完成闭包。此方法提供了一种视觉上吸引人的方式,通过跨各个装饰层的平滑、同步的动画来更新进度条的值。

源代码

最新版本以 Swift Package 和 Pod 的形式存在。所以你可以从这里的Retro Progress Bar 存储库中获取它。顺便说一句,欢迎贡献!

用法

创建 RetroProgressBar 的实例。您可以将其添加到视图层次结构中,就像使用任何 UIView 一样。


 let progressBar = RetroProgressBar()


定制:


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


要使用动画更改值:


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


没有动画:


 progressBar.setValue(0.5)

最后的想法

重新创建这个旧式进度条是对我深深怀念的美学的怀旧探索。使用 UIKit 和CALayer让我意识到我们的工具作为开发人员的多功能性和强大功能。该项目不仅仅涉及技术实施;还涉及技术实施。这是一次回到受人喜爱的设计时代的旅程,证明我们仍然可以制作任何东西,包括旧设计的永恒魅力。


这次经历深刻地提醒我们,在UI 设计中,简约和怀旧可以完美共存,连接过去和现在。


快乐编码!