Мне нравится создавать компоненты пользовательского интерфейса. Мне доставляет своего рода эстетическое удовольствие видеть и использовать их после сборки. Это также сочетается с моей любовью к экосистеме MacOS/iOS в целом. Недавно мне было поручено создать собственный индикатор выполнения с помощью UIKit
. Это не имеет большого значения, но, вероятно, 99% разработчиков iOS в какой-то момент своей карьеры создавали его с нуля. В моем случае я хотел сделать немного больше, чем просто создать пару CALayer
с анимацией на основе диапазона значений от 0 до 1. Я стремился к решению, которое подходило бы для многих сценариев, где необходим индикатор выполнения. .
Я хочу вернуться в те времена, когда компоненты пользовательского интерфейса в мире Apple выглядели так:
Говоря об эволюции дизайна пользовательского интерфейса, прошло почти десять лет с тех пор, как элементы пользовательского интерфейса в мире Apple приняли плоский дизайн с появлением iOS 7 в 2014 году. Итак, давайте предаемся ностальгии по более ранним версиям Mac OS и iOS и создадим красивый индикатор выполнения вместе.
Если вам нравится использовать UIKit
, но вы все еще не знаете, как манипулировать CALayers, эта статья будет вам полезна. Здесь мы рассмотрим типичные задачи макетирования, анимации и рисования, предоставив идеи и решения, которые помогут вам на этом пути.
Индикатор выполнения может показаться простым, но если мы углубимся в детали, вы поймете, что для этого требуется множество подслоев.
Я не буду здесь подробно углубляться в CALayers
. Вы можете прочитать эту статью, где я кратко описал, что такое CALayers
и как они работают: https://hackernoon.com/rolling-numbers-animation-using-only-calayers .
Итак, разобьем его на небольшие слои:
Фоновый слой имеет два дополнительных дочерних слоя:
Давайте структурируем его правильно:
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]
Похоже, нам нужно разработать протокол, который может быть принят слоями, которым нужна анимированная ширина в зависимости от значения.
По протоколу нам понадобится всего два метода: изменить ширину слоя с анимацией и без.
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 }
В приведенном выше фрагменте мы инициализируем две основные части индикатора выполнения: ProgressMaskLayer
и ProgressBackgroundLayer
. Давайте посмотрим на второй.
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 } }
Как видите, это не слишком сложно. Нам просто нужно получить colorSpace
, components
( red
, green
, blue
и alpha
) и model
. Это означает, что на основе значения мы можем рассчитать новый цвет: либо более темный, либо более светлый.
Давайте перейдем к тому, чтобы сделать наш индикатор выполнения более трехмерным. Теперь нам нужно добавить еще один слой, который мы назовем Блики.
Это простая реализация, но она должна соответствовать протоколу 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") } }
Для простоты нам нужно, чтобы цвет фона был белым с альфа-значением 0,3.
Я знаю, что некоторые могут сказать, что в MacOS X или iOS до версии 6 не было компонентов пользовательского интерфейса с такими мерцающими эффектами. Но, как я уже сказал, это похоже на фильм, вдохновленный реальной историей. Это мерцание подчеркивает 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") } } }
Здесь нам нужны две анимации (Opacity и Location), объединенные в CAAnimationGroup
. Также использование CAAnimationGroup
позволяет нам устраивать паузу между сеансами анимации. Эта функция весьма полезна, поскольку клиенты также могут захотеть контролировать это свойство.
Как вы можете видеть на картинке, это добавляет больше 3D-эффектов, но этого недостаточно. Давайте двигаться вперед.
Нам нужно поработать со статическим фоновым слоем, который находится под анимированной линией прогресса. Если вы помните, у нас там есть два дополнительных подслоя: Внутренняя тень и Градиент границы.
|--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer]
Я бы сказал, что эти два слоя были довольно популярны в поисковых запросах несколько лет назад, возможно, из-за тенденций пользовательского интерфейса. Давайте создадим свои версии и увековечим их в этой статье для будущих поколений :)
Класс предназначен для создания слоя с эффектом внутренней тени, который может быть визуально привлекательным способом добавления глубины элементам пользовательского интерфейса.
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
frombounds. А затем еще один 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
) для синхронизации анимации трех слоев: shimmeringLayer
, progressMaskLayer
и glareLayer
. Ширина каждого слоя анимируется до расчетной ширины (регулируемой WIDTH_ANIMATABLE_INSET
для shimmeringLayer
и glareLayer
в соответствии с дизайном) в течение указанной продолжительности и с указанной кривой анимации.
Анимации координируются с помощью 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
напомнила мне об универсальности и мощи наших инструментов как разработчиков. Этот проект касался не только технической реализации; это было путешествие назад в любимую эпоху дизайна, доказывающее, что мы все еще можем создавать что угодно, включая вечное очарование старых дизайнов.
Этот опыт стал ярким напоминанием о том, что в дизайне пользовательского интерфейса простота и ностальгия могут прекрасно сосуществовать, соединяя прошлое и настоящее.
Приятного кодирования!