Возврат к пользовательскому интерфейсу: создание ретро-индикатора выполнения для iOS с помощью CALayers
18 декабря 2023 г.
Мне нравится создавать компоненты пользовательского интерфейса. Мне доставляет своего рода эстетическое удовольствие видеть и использовать их после сборки. Это также сочетается с моей любовью к экосистеме MacOS/iOS в целом. Недавно мне было поручено создать собственный индикатор выполнения с помощью UIKit
. Это не имеет большого значения, но, вероятно, 99% разработчиков iOS на каком-то этапе своей карьеры создавали его с нуля. . В моем случае я хотел сделать немного больше, чем просто создать пару CALayer
с анимацией на основе диапазона значений от 0 до 1. Я стремился к решению, которое подходило бы для многих сценарии, в которых необходим индикатор выполнения.
Я хочу вернуться в те времена, когда компоненты пользовательского интерфейса в мире Apple выглядели так:
Говоря об эволюции дизайна пользовательского интерфейса, прошло почти десять лет с тех пор, как элементы пользовательского интерфейса в мире Apple приняли плоский дизайн с появлением iOS 7 в 2014 году. Итак, давайте предаемся ностальгии по более ранним версиям Mac OS и iOS и создадим красивый индикатор выполнения вместе.
Если вам нравится использовать UIKit
, но вы все еще не знаете, как манипулировать CALayers, эта статья будет вам полезна. Здесь мы рассмотрим типичные задачи макетирования, анимации и рисования, предоставив идеи и решения, которые помогут вам в этом.
Из UIView вглубь CALayers API.
Индикатор выполнения может показаться простым, но если мы углубимся в детали, вы поймете, что он требует множества подслоев.
Я не буду здесь подробно углубляться в 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]
Похоже, нам нужно разработать протокол, который может быть принят слоями, которым нужна анимированная ширина в зависимости от значения.
Протокол 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.
Нашими кандидатами на соответствие этому протоколу будут три слоя: слой анимированной маски, слой бликов и слой мерцания. Давайте продолжим и реализуем это.
ProgressMaskLayer
Пришло время соответствовать нашему протоколу, и самым важным слоем в этой настройке будет наш общий 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
, компоненты
(красный
, зеленый
, синий
, и альфа
) и модель
. Это означает, что на основе значения мы можем рассчитать новый цвет: более темный или более светлый.
Слой бликов
Давайте перейдем к тому, чтобы сделать наш индикатор выполнения более трехмерным. Теперь нам нужно добавить еще один слой, который мы назовем Блики.
Это простая реализация, но она должна соответствовать протоколу 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.
Little Nice Shimmering (Мерцающий слой)
Я знаю, что некоторые могут сказать, что в 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")
}
}
}
Здесь нам нужны две анимации (непрозрачность и местоположение), объединенные в 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
from borders. А затем еще один 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 и модуля. Вы можете получить его отсюда репозиторий 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
напомнила мне об универсальности и мощи наших инструментов как разработчиков. Этот проект касался не только технической реализации; это было путешествие назад в любимую эпоху дизайна, доказывающее, что мы все еще можем создавать что угодно, включая вечное очарование старых моделей.
Этот опыт стал ярким напоминанием о том, что в дизайне пользовательского интерфейса простота и ностальгия могут прекрасно сосуществовать, соединяя прошлое и настоящее.
Приятного кодирования!
Оригинал