Конкурс телеграм на создание медиаредактора
Конкурс телеграм на создание медиаредактора

Задача состояла в создании автономного приложения для редактирования медиафайлов на Swift без использования сторонних UI-фреймворков. Конкурсные функции и интерфейсы должны быть созданы с нуля. Использование сторонних реализаций медиаредакторов с похожими функциями строго запрещено.

Плавность анимации и общая стабильность приложения будут играть важную роль. И последнее, но не менее важное — отсутствие существенных недостатков (включая сбои, проблемы с производительностью, визуальные проблемы, заметное мигание элементов, несовершенство макета и многое другое).

Если вы хотите узнать, что же было реализовано, какие ошибки и выводы я сделал после конкурса, то заходите.

Примечание. Если статья покажется интересной, то вот тут я пишу об iOS-разработке и о том, что с ней связано.


Подготовка и оценка собственных сил и возможностей

Поскольку одним из требований являлась поддержка iOS 13 и выше, то многие функции стандартного редактора, в том числе и инструмент для выбора цветов UIColorPickerViewController нужно было реализовывать самостоятельно.

Для галереи требовалось реализовать собственный компонент с плавной анимацией с помощью pinchgesture в стиле эпловского приложения Photos.

Дополнительная и немаловажная часть контеста заключалась в ограничении по времени, поэтому некоторые элементы из требуемых макетов так или иначе пришлось бы отложить на будущее.

Самым важным (и критическим требованием как оказалось позже) стала отрисовка линии, а все дополнительные инструменты типа карандаша, маркера и так далее имели значительно меньший импакт на итоговую оценку.

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

Кроме этого, важно было привести анимации к виду, показанному на видео.

Галерея (отображение фото и видео пользователя)

Требование — показывать контроллер, если нет доступа, и реализовать переходы между различными типа лэйаута в зависимости от жестов.

Для экрана с запросом разрешений вероятно, большинство использовало один и тот же компонент Lottiе (или rlottie — использовал его, так как в своих рабочих проектах для большого количества анимацией он выигрывает по производительности).

Для реализации поддержки свайпов опирался на UIPinchGestureRecognizer и текущий масштаб.

Для этого запоминаем текущую ячейку, а во время свайпа перестраиваем лэйаут в зависимости от текущего коэффициента масштабирования.

let potentialScale:CGFloat = min(maxScale, max(scaleStart * pinchReco.scale, minScale))
let scaleToRound: Double = potentialScale
let scale = scaleToRound.round(nearest: 0.05)
if scale != currentScale {
    currentScale = scale
    сvGallery.collectionViewLayout.invalidateLayout()
    guard let path = pathToScroll else {
        return
    }
    cvGallery.scrollToItem(at: path, at: .centeredVertically, animated: false)
}

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

Сами запросы к галерее реализованы через fetchAssetCollections, а медиафайлы кэшились через PHImageManager. В зависимости от текущего лэйаута динамически перезапрашивался размер для более плавного скролла и в целом повышения производительности.

Кроме этого, для кейсов с небольшими ячейками требовалось скрывалась длительность ассетов.

Медиаредактор

В этом разделе пойдёт речь о принятых решениях, связанных с выбором цвета, отрисовкой линии, текстовом редакторе и комбинации всего перечисленного.

Выбор цвета

Было принято решение реализовывать всё с нуля, включая управление холстом, изображением и слоями, также написать свой собственный компонент для выбора цвета:

Как выглядит компонент выбора цвета в моей реализации?

Для повторения нужно было сделать три раздела: grid, spectrum и sliders.

Экран с переключением типов выбора цвета

Я реализовал его с помощью UIStackView + UIPageViewController. В стэквью в зависимости от выбранного типа отрисовывается коллекция, спектр (реализован на основе картинки и жеста для определения позиции) или же набор слайдеров с полями ввода.

Для слайдера и шахматки используется кастомный контрол:

Реализация шахматки в слайдере
private func renderCheckerboardPattern(colors: (dark: UIColor, light: UIColor), height:CGFloat) -> UIColor {
    let size = height/3
    let image = UIGraphicsImageRenderer(size: CGSize(width: size * 2, height: size * 2)).image { context in
        colors.dark.setFill()
        context.fill(CGRect(x: 0, y: 0, width: size * 2, height: size * 2))
        colors.light.setFill()
        context.fill(CGRect(x: size, y: 0, width: size, height: size))
        context.fill(CGRect(x: 0, y: size, width: size, height: size))
    }
    return UIColor(patternImage: image)
}

Высота делится на 3, так как нужно три ряда. Саму картинку генерим с помощью UIGraphicsImageRenderer.

Как определяются цвета слева и справа на слайдерах, до этого конкурса не знал, но ради этого и стоит участвовать, разве нет? В итоге было написано несколько вспомогательных функций для генерации правильных диапазонов слайдера:

var rgba: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
    var red: CGFloat = 0
    var green: CGFloat = 0
    var blue: CGFloat = 0
    var alpha: CGFloat = 0
    getRed(&red, green: &green, blue: &blue, alpha: &alpha)
    
    return (red, green, blue, alpha)
}

func max(red:Bool = false, green:Bool = false, blue:Bool = false) -> UIColor {
    let rgba = self.rgba
    return UIColor(red: red ? 1 : rgba.red, green: green ? 1 : rgba.green, blue: blue ? 1 : rgba.blue, alpha: rgba.alpha)
}

func min(red:Bool = false, green:Bool = false, blue:Bool = false) -> UIColor {
    let rgba = self.rgba
    return UIColor(red: red ? 0 : rgba.red, green: green ? 0 : rgba.green, blue: blue ? 0 : rgba.blue, alpha: rgba.alpha)
}

static func colorFrom(rgba: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)) -> UIColor {
    return UIColor(red: rgba.red, green: rgba.green, blue: rgba.blue, alpha: rgba.alpha)
}

Не исключаю, что есть решение проще, но я посчитал, что если мы управляем заданным цветом, то достаточно выставлять ему 0 и 1 и сохранять остальные компоненты цвета для получения нужного.

Для задания нужного текстового компонента используется UITextField (а что же ещё).

Сохранение и управление цветами

Для этого используем UIPageViewController с расчётом количества цветов на страницу.

func chunked(into size: Int) -> [[Element]] {
    return stride(from: 0, to: count, by: size).map {
        Array(self[$0 ..< Swift.min($0 + size, count)])
    }
}

При выходе с экрана массив цветов сохраняется в UserDefaults в виде hex-строки. Из неё же и формируем заданные цвета в дальнейшем.

Реализация пипетки (оно же увеличительное стекло, или же лупа)

Для этого реализовал связку из жеста и отрисовки магнифай-эффекта в заданной точке.

public override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext() else { return }

    context.translateBy(x: radius, y: radius)
    context.scaleBy(x: scale, y: scale)
    context.translateBy(x: -magnifiedPoint.x, y: -magnifiedPoint.y)
    context.interpolationQuality = .none
    removeFromSuperview()
    magnifiedView?.layer.render(in: context)
    magnifiedView?.superview?.addSubview(self)
}

Ах да, довольно необычно было преобразовывать заданный компонент от дизайнеров Telegram от чёрного к нужному. Для этого было использовано решение с помощью CIFilter:

  static func filteredImage(cgImage: CGImage, size:CGSize) -> UIImage? {
      if let matrixFilter = CIFilter(name: "CIColorMatrix") {
          matrixFilter.setDefaults()
          matrixFilter.setValue(CIImage(cgImage: cgImage), forKey: kCIInputImageKey)
          let rgbVector = CIVector(x: 0, y: 0, z: 0, w: 0)
          let aVector = CIVector(x: 1, y: 1, z: 1, w: 0)
          matrixFilter.setValue(rgbVector, forKey: "inputRVector")
          matrixFilter.setValue(rgbVector, forKey: "inputGVector")
          matrixFilter.setValue(rgbVector, forKey: "inputBVector")
          matrixFilter.setValue(aVector, forKey: "inputAVector")
          matrixFilter.setValue(CIVector(x: 1, y: 1, z: 1, w: 0), forKey: "inputBiasVector")

          if let matrixOutput = matrixFilter.outputImage, let cgImage = CIContext().createCGImage(matrixOutput, from: matrixOutput.extent) {
              return UIImage(cgImage: cgImage).resizedImage(with: size)
          }

      }
      return nil
  }

То что получилось, под спойлером ниже.

А вот так выглядит этот эффект в реальности

Реализация компонента для выбора цвет с экрана рисования

Для этого сделал собственный контрол с поддержкой двух жестов (для длинного касания и короткого — для открытия пикера).

  func addShortAndLongTaps(needShort:Bool = false, needLong:Bool = false) {
      if needShort {
          let tapGesture = UITapGestureRecognizer(target: self, action: #selector(onShort))
          addGestureRecognizer(tapGesture)
      }

      if needLong {
          let longGesture = UILongPressGestureRecognizer(target: self, action: #selector(onLong))
          longGesture.minimumPressDuration = 0.3
          addGestureRecognizer(longGesture)
      }
  }

Анимации для переключения карандашей (и их реализация)

Что получилось

База — это UICollectionView с ячейками на автолэйауте. Сама ячейка состоит из фона для карандаша, оверлэя (грифель) и кастомного компонента для тощины карандаша.

Для анимации решил скрывать все ячейки через уменьшение высоты карандашей с задержкой в зависимости от их позиции в коллекции одновременно с выдвижением нужного на позицию посередине (через изменение позиций ячеек). При этом также используется плавное увеличение толщины в зависимости от выставленного коэффициента.

Для компонента ластика также используется дополнительная картинка (блюр или стирание объектов).

При переключении между выбранными режимами используется комбинация трансформ-анимации слайдера. Ещё хотелось сделать показ и сворачивание слайдера в одну точку на экране, но я помнил об ограничениях конкурса по времени.

Не используйте магические числа в проде!
let delay:Double = Double(tool.type.rawValue) * 0.05
var constantBottom:CGFloat = -16
if tool.current {
    constantBottom = CGFloat.leastNormalMagnitude
}
bottomTool.constant = constantBottom
heightColor.constant = tool.width
topOffsetIconEraser.constant = 20
widthToolConstraint.constant = defaultWidthTool
topOffsetColor.constant = topOffsetForTool(tool: tool)
centerToolContraint.constant = CGFloat.leastNormalMagnitude

Текстовый редактор

Как говорится, уделите внимание мелочам (но только в конце). Одна из моих ошибок в процессе решения задания, что иногда так или иначе зацикливался на некоторых сложных моментах.

Как бы вы реализовали эффект скрытия левой и правой части коллекции при скролле?

Что сделал я
let transparent = UIColor.clear
let opaque = UIColor.black

let gradient = CAGradientLayer()
gradient.frame = bounds
gradient.colors = [
    transparent.cgColor,
    opaque.cgColor,
    opaque.cgColor,
    opaque.withAlphaComponent(0).cgColor
]
gradient.locations = [0, 0.2, 0.8, 1]
gradient.startPoint = CGPoint(x: 0, y: 0.5)
gradient.endPoint = CGPoint(x: 1, y: 0.5)
gradient.name = GRADIENT_HARD
layer.mask = needMask ? gradient : nil

Использую градиент с заданными локациями в качестве маски. needMask нужен для режима, когда у нас видна клавиатура (и по макету там этого эффекта быть не должно).

Какие эффекты для текста были реализованы

Заливка цветом, тень у букв, изменение размера текста, полупрозрачная заливка (да, тут есть разница с обычной). Дублирование слоёв, перемещение их по стеку, редактирование и удаление. А также поддержка всех этих эффектов при выборе слоя.

Скриншот с результатом

Отрисовку текста реализовал с помощью CATextLayer и комбинации нужных эффектов.

Например, тень у букв реализована следующим способом:

context.setTextDrawingMode(CGTextDrawingMode.stroke)
context.setLineWidth(0.25*fontSize)
context.setLineJoin(CGLineJoin.round)
context.setLineCap(CGLineCap.butt)
context.setStrokeColor(UIColor.black.cgColor)
var attrsFill = attributes
attrsFill[.foregroundColor] = UIColor.black
(self.text as NSString).draw(in: drawRect, withAttributes: attrsFill)

context.setTextDrawingMode(.fill)
context.setFillColor(UIColor.clear.cgColor)
let attrsStroke = attributes
let drawRect = CGRect(x: 0.5, y: 0, width: bounds.width + 1, height: bounds.height)
(self.text as NSString).draw(in: drawRect, withAttributes: attrsStroke)

А вот заливка куда более сложная. Для этого был использован NSLayoutManager, формирование прямоугольников с контентом для текста и проход по ним для отрисовки нужных скруглённых границ. Можно было бы сделать проще, используя свойство заливки атрибут-строки, но это совсем не то. В примере ниже част формирования пути для отрисовки.

  if a.x - c.x >= 2*R {
      let addPath = UIBezierPath(arcCenter: CGPointMake(a.x - R, a.y + R), radius:R, startAngle:Double.pi/2 * 3, endAngle:0, clockwise:true)
      addPath.append(UIBezierPath(arcCenter: CGPointMake(a.x + R, a.y + R) , radius:R, startAngle:Double.pi, endAngle:3 * Double.pi/2, clockwise:true))
      addPath.addLine(to: CGPointMake(a.x - R, a.y))
      path.append(addPath)
  }
  
  if a.x == c.x {
      path.move(to: CGPointMake(a.x, a.y - R))
      path.addLine(to: CGPointMake(a.x, a.y + R))
      path.addArc(withCenter: CGPointMake(a.x + R, a.y + R), radius: R, startAngle: Double.pi, endAngle: Double.pi/2 * 3, clockwise: true)
      path.addArc(withCenter: CGPointMake(a.x + R, a.y - R), radius: R, startAngle: Double.pi/2, endAngle: Double.pi, clockwise: true)
  }

Для формирования границ у слоя используется довольно тривиальная окантовка с помощью UIBezierPath.

Для дублирования слоёв парадоксально нельзя сделать copy. Если можно, напишите в комментариях. Я использовал решение с помощью NSKeyedArchiver/NSKeyedUnarchiver и переназначения нужных свойств.

Для трансформа используются целых три жеста - для поворота через UIRotationGestureRecognizer, движения через Pan и масштабирования UIPinchGestureRecognizer.

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

Не знаю, как именно подходили другие участники к комбинации поворотов оригинального изображения и текстовых слоёв, но задача любопытная.

Отрисовка линий или то, к чему я пришёл в итоге

Возможно, более правильным решением было бы использовать PencilKit, но я пошёл по другому пути.

Для отрисовки линии используется интересный метод, требующий выхода из плоскости. Вместо того, чтобы представлять нарисованный штрих одной кривой Безье, я его представлял как закрашенную область между двумя путями Безье, каждый компонент пути которого слегка смещеа по обе стороны от воображаемой кривой, очерченной пальцем.

Если изменять смещения контрольных точек, определяющих эти две кривые Безье, мы сможем добиться очень правдоподобного эффекта плавно изменяющейся ширины пера.

То, что требуется сделать
То, что требуется сделать
И математика, которая для этого требуется
И математика, которая для этого требуется

Потрясающая статья на эту тему написана здесь. Да, по требованиям нужен Swift, а не Objective-C, но проблемы адаптировать алгоритм нет.

Пример текста из статьи
Пример текста из статьи

Я оптимизировал отрисовку в фоновом потоке и добавил необходимые расчёты, из-за которых появилась задержка в 0.3 секунды, что, возможно, и стало критической частью для жюри конкурса. Несмотря на это, была добавлена анимация для расширения строк и динамической ширины в целом.

Реализация маркера, карандаша и эффекта неона

Для неона к стандартному пути (и CAShapeLayer в итоге) добавил тень, что и позволило добиться нужного эффекта:

layerTemp.shadowRadius = widthShadow
layerTemp.shadowOpacity = shadowOpacity
layerTemp.shadowColor = currentColor.cgColor

Параметр для ширины тени и прозрачности также можно задавать в зависимости от выбранного цвета.

Для эффекта карандаша использован pattern от картинки

UIColor(patternImage: pencilImage).cgColor

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

Реализация эффекта ластика (и бэкграунд блюра).

Одно из необычных решений, как мне кажется, заключалось в том, что вместо реализации непростой библиотеки с помощью пересечения слоёв было решено использовать оригинальную картинку как маску и рисовать этой картинкой вместо ластика (эффект будет тем же, а сил затрачено гораздо меньше!).

Для бэкграунд блюра решение схожее, но с большим условием. Для формирования такой картинки нужно заранее сохранять картинку, по которой и происходит отрисовка.

Если вы попробуете повторить этот путь, то будьте осторожны, производительность начнёт проседать на большом количестве слоёв.

Сохранение (и отображение) видео с изменениями

Для отображения видео используется связка AVPlayer/AVPlayerLayer. Слой для отображения видео накладываем на картинку и зацикливаем его (инстаграм-эффекты, привет).

Для сохранения:

let videoComp = AVMutableVideoComposition()
videoComp.renderSize = videoSize
videoComp.frameDuration = CMTimeMake(value: 1, timescale: 30)
videoComp.animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: videoLayer, in: parentLayer)

let instruction = AVMutableVideoCompositionInstruction()

instruction.timeRange = CMTimeRangeMake(start: CMTime.zero, duration: mixComposition.duration)

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

parentLayer - слой, на который мы накладываем все наши изменения.

Кроме этого, крайне важно сохранить оригинальный масштаб и не потерять пропорции при сохранении

let scale : CGAffineTransform = CGAffineTransform(scaleX: videoSize.width / overlayLayer.bounds.width, y: videoSize.height / overlayLayer.bounds.height)
overlayLayer.setAffineTransform(scale)
layerInstruction.setTransform((clipVideoTrack.preferredTransform), at: CMTime.zero)

instruction.layerInstructions = [layerInstruction]
videoComp.instructions = [instruction]

Галерея обновит наши ассеты с помощью PHPhotoLibraryChangeObserver.

Финальный результат

Пожалуй, лучше всего посмотреть на видео, что удалось реализовать в итоге

Видео с результом

Выводы после конкурса

Требование про плавность линий оказалось приоритетным. Внимание к деталям, анимации и дополнительным инструментам стоило отложить. Как сообщает заголовок, мой результат в этом конкурсе — отсутствие призового места, но несмотря на это, хочется верить, что удалось справиться с конкурсным заданием и получить необычный опыт и прокачать знания сразу в нескольких областях.


Что ж, если вам интересны такие истории, подписывайтесь на мой канал о разработке, решений и подходов.

Авторский канал об iOS-разработке
Авторский канал об iOS-разработке

Комментарии (2)


  1. eltardowut
    11.11.2022 13:11
    +1

    Очень клёво и продуманно выглядит, кстати.


  1. dyadyaSerezha
    12.11.2022 11:14
    +1

    Никакое место - это типа NaN (not a number)? ????