paint-brush
Возврат к пользовательскому интерфейсу: создание ретро-индикатора выполнения для iOS с помощью CALayersк@maxkalik
684 чтения
684 чтения

Возврат к пользовательскому интерфейсу: создание ретро-индикатора выполнения для iOS с помощью CALayers

к Max Kalik22m2023/12/17
Read on Terminal Reader

Слишком долго; Читать

В этом руководстве рассматривается создание индикатора выполнения в стиле ретро в iOS с использованием CALayers для придания ностальгического оттенка. Он описывает пошаговый процесс, от проектирования каждого слоя до реализации анимации, предлагая идеи сочетания эстетики старой школы с современной разработкой для iOS. Эта статья идеальна для разработчиков и дизайнеров пользовательского интерфейса. Она содержит как техническую глубину, так и творческое вдохновение для создания уникальных компонентов пользовательского интерфейса. Исходный код: https://github.com/maxkalik/RetroProgressBar.
featured image - Возврат к пользовательскому интерфейсу: создание ретро-индикатора выполнения для iOS с помощью CALayers
Max Kalik HackerNoon profile picture
0-item


Мне нравится создавать компоненты пользовательского интерфейса. Мне доставляет своего рода эстетическое удовольствие видеть и использовать их после сборки. Это также сочетается с моей любовью к экосистеме MacOS/iOS в целом. Недавно мне было поручено создать собственный индикатор выполнения с помощью UIKit . Это не имеет большого значения, но, вероятно, 99% разработчиков iOS в какой-то момент своей карьеры создавали его с нуля. В моем случае я хотел сделать немного больше, чем просто создать пару CALayer с анимацией на основе диапазона значений от 0 до 1. Я стремился к решению, которое подходило бы для многих сценариев, где необходим индикатор выполнения. .


Я хочу вернуться в те времена, когда компоненты пользовательского интерфейса в мире Apple выглядели так:


источник: https://www.smashingmagazine.com/2010/08/free-wireframing-kits-ui-design-kits-pdfs-and-resources/


Говоря об эволюции дизайна пользовательского интерфейса, прошло почти десять лет с тех пор, как элементы пользовательского интерфейса в мире Apple приняли плоский дизайн с появлением iOS 7 в 2014 году. Итак, давайте предаемся ностальгии по более ранним версиям 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 }


В приведенном выше фрагменте мы инициализируем две основные части индикатора выполнения: 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.


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


Здесь нам нужны две анимации (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 напомнила мне об универсальности и мощи наших инструментов как разработчиков. Этот проект касался не только технической реализации; это было путешествие назад в любимую эпоху дизайна, доказывающее, что мы все еще можем создавать что угодно, включая вечное очарование старых дизайнов.


Этот опыт стал ярким напоминанием о том, что в дизайне пользовательского интерфейса простота и ностальгия могут прекрасно сосуществовать, соединяя прошлое и настоящее.


Приятного кодирования!