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

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

18 декабря 2023 г.

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

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

source: 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.

Индикатор выполнения может показаться простым, но если мы углубимся в детали, вы поймете, что он требует множества подслоев.

Retro Progress Bar

Я не буду здесь подробно углубляться в CALayers. Вы можете прочитать эту статью, где я кратко описал, что такое CALayers и как они работают: https://hackernoon.com/rolling-numbers-animation-using-only-calayers.

Итак, разобьем его на небольшие слои:

  1. Общий фоновый слой
  2. Слой маски (анимируемая часть)
  3. Фоновый слой прогресса
  4. Слой бликов
  5. Мерцающий слой
  6. Фоновый слой имеет два дополнительных дочерних слоя:

    1. Слой внутренней тени
    2. Слой градиента границы
    3. Давайте структурируем его правильно:

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

      Gradient

      Чтобы сделать это возможным, я подготовил два метода в расширении 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")
          }
      }
      

      With glare

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

      With shimmering

      Как вы можете видеть на картинке, это добавляет больше 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  — путь вырезания, обратный границам слоя.

      Inner shadow

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

      With border

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

      Все вместе

      Теперь пришло время объединить все эти слои и воплотить их в жизнь.

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

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

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


      Оригинал