이번 포스팅에선 아래와 같이 iPhone에서 제공하는 SliderBar를 직접 구현해보자! 

자세한 것은 해당 링크에서 확인해볼 수 있습니다.

https://github.com/jungseokyoung-cloud/VideoTrimmingSliderBar

 

GitHub - jungseokyoung-cloud/VideoTrimmingSliderBar

Contribute to jungseokyoung-cloud/VideoTrimmingSliderBar development by creating an account on GitHub.

github.com

 

 

우선, 해당 VideoTrimmingSliderBar를 State를 정의할 수도 있고, send-Action을 활용할 수 있어, UIControl로 구현해주었다.

final class VideoTrimmingSliderBar: UIControl { ... }

기본적인 프로퍼티들부터 보자면, 다음과 같다. 

/// 현재 slider의 최솟값
private var minimumValue: Double = 0
/// 현재 slider의 최댓값
private var maximumValue: Double = 100

/// 현재 slider에서 선택학 영역의 최솟값
private(set) var lowerValue: Double = 0.0 {
  didSet {
    updateThumbFrame(lowerSlider)
    delegate?.lowerValueDidChanged(self, value: lowerValue)
  }
}

/// 현재 slider에서 선택한 영역의 최댓값
private(set) var upperValue: Double = 100 {
  didSet {
    updateThumbFrame(upperSlider)
    delegate?.upperValueDidChanged(self, value: upperValue)
  }
}

 

 

영상 길이 조절 기능 

영상길이를 조절할 수 있는 것은 다음과 같이 구성된다.

우선, 좌우에서 영상 길이를 조절할 수 있는 sliderThumb와 

sliderThumb 사이 길이에 맞춰 길이가 늘어나고 줄어드는 ViewA가 있다.

 

해당 ViewA와 좌우의 sliderThumb를 하나로 묶을 수 있지만, 

실제로 움직이는 곳은 VideoTrimmingSliderBar이기 때문에, 

묶게 되면 좌 우 sliderThumb의 위치 파악이 어려워지는 단점이 있다.

 

따라서, VideoTrimmingSliderBar안에 다음과 같이 2개의 Thumb를 별도로 구성해주어야 한다. 

private let lowerThumb = SliderThumb(tintColor: .systemYellow)
private let upperThumb = SliderThumb(tintColor: .systemYellow)

 

 

View A의 UI

다음으로 ViewA를 그려보자. 이때 View를 그리는 방법에는 크게 3가지로 구상했다. 

 

UIView

가장 간단한 방법의 UIView로 구성하는 것이다. 

말그대로 너무나 간단하고 동작역시 매끄럽다. 

private let topView = UIView()
private let bottomView = UIView()

func updateThumbFrame(_ thumb: SliderThumb) {
  ...
  updateTopAndBottomView()
}


func updateTopAndBottomView() {
  topView.frame = CGRect(
    x: lowerThumb.center.x,
    y: 0,
    width: upperThumb.center.x - lowerThumb.center.x,
    height: 5
  )
  
  bottomView.frame = CGRect(
    x: lowerSlider.center.x,
    y: bounds.height - 5,
    width: upperThumb.center.x - lowerThumb.center.x,
    height: 5
  )
}

 

Core Graphics

다음 방법으로는 CPU기반 렌더링 방식인 Core Graphics다. 

// MARK: - Draw
override func draw(_ rect: CGRect) {
  super.draw(rect)
  guard let context = UIGraphicsGetCurrentContext() else { return }
  
  let maskedRect = CGRect(
    x: lowerThumb.center.x,
    y: 0,
    width: upperThumb.center.x - lowerThumb.center.x,
    height: 5
  )
  
  context.setFillColor(UIColor.systemYellow.cgColor)
  context.fill(maskedRect)
}

이것 역시 매끄럽고 잘 동작한다. 

 

Core Animation

마지막으로 Core Animation방식이다. 

Core Animation에선 다음과 같이 구성된다. 

func updateThumbFrame(_ thumb: SliderThumb) {
  ...
  updateTopAndBottomLayer()
}


func updateTopAndBottomLayer() {
  topLayer.frame = CGRect(
    x: lowerThumb.center.x,
    y: 0,
    width: upperThumb.center.x - lowerThumb.center.x,
    height: 5
  )
  
  bottomLayer.frame = CGRect(
    x: lowerThumb.center.x,
    y: bounds.height - 5,
    width: upperThumb.center.x - lowerThumb.center.x,
    height: 5
  )
}

하지만, 해당 방식에서는 다음과 같은 Delay가 존재하게 되었다. 

GPU 기반의 랜더링인 Core Animation이 CPU기반의 Core Graphics보다 빠르면 더 빨랐지, 느릴일은 없다고 생각했다. 하지만 위와 같은 딜레이를 맞이하고 stackOverFlow에 질문글을 올리게 되었다. 

https://stackoverflow.com/questions/79264407/why-does-core-animation-have-a-delay-compared-to-core-graphics

 

Why does Core Animation have a delay compared to Core Graphics?

I am trying to implement a custom UISlider on an iOS device. First, the ThumbSlider is positioned on both ends, and the desired behavior is as follows: When the ThumbSlider moves left or right, the

stackoverflow.com

 

해당 답변에 의하면, Core Animation에서는 implicit하게 animation을 실행하는 것들이 있으며, frame이 이에 해당된다. 따라서, 위의 딜레이는 Animation에 의한 딜레이이고, 이를 해결하기 위해선 다음과 같이 코드를 작성해야 한다. 

func updateTopAndBottomLayer() {
  CATransaction.begin()
  CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)
  topLayer.frame = CGRect(
    x: lowerThumb.center.x,
    y: 0,
    width: upperThumb.center.x - lowerThumb.center.x,
    height: 5
  )
  
  bottomLayer.frame = CGRect(
    x: lowerThumb.center.x,
    y: bounds.height - 5,
    width: upperThumb.center.x - lowerThumb.center.x,
    height: 5
  )
  CATransaction.commit()
}

 

Core Animation 선정 이유

우선적으로, 해당 View는 아무런 이벤트가 없기 때문에, Responder Chain을 활용할 이유가 없다고 생각했다. 

또한, UIView는 Core Animation과 Core Graphics보다 무겁다. 

따라서, UIView의 방식은 우선적으로 배제했다. 

 

다음으로 Core Graphics방식과 Core Animation 방식이다. 

해당 ViewA는 ThumbSlider가 움직일때마다, drawing이 일어나게 된다. 

즉, 자주 일어나기 때문에, CPU기반보단 GPU기반이 성능적의 이점이 있다고 판단했다. 

 

실제 테스트 해봤을 때의 결과는 CPU 사용률은 1~2퍼 정도만 차이가 났다. 

그럼에도 불구하고, 조금이나마의 성능 최적화를 위해 Core Animation으로 선정했다.

Core Graphics (최대 7)

 

 

Core Animation (최대 6)

UIView (최대 8)

 

위치 계산

다음으로는 ThumbSlider를 움직였을 때의 위치 변환이다. 

 

View 좌표계를 초 단위로 변경

앞서 살펴보았듯, ThumbSlider의 값들은 lowerValue, upperValue로 들고 있으며 이들은 초단위이다.

하지만, 이때 움직이는 것은 View의 좌표계 단위로 움직이기 때문에, 이들을 초단위로 매핑해주어야 한다. 

 

우선 다음과 같이 UIControlcontinueTacking(_:with:) 메서드에 다음을 추가해주자.

override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
  let location = touch.location(in: self)
  defer { previousLocation = location }
  
  guard let thumb = currentHighlightedThumb else { return false }
  
  let thumbDeltaValue = deltaValue(from: previousLocation, to: location)
  
  
  if thumb === lowerThumb {
    self.lowerValue = updatedLowerValue(moved: thumbDeltaValue)
  } else if thumb === upperThumb {
    self.upperValue = updatedUpperValue(moved: thumbDeltaValue)
  }
  sendActions(for: .valueChanged)
  
  return true
}

 

해당 동작을 살펴보면 다음과 같다.

let sliderDeltaValue = deltaValue(from: previousLocation, to: location)

func deltaValue(from previous: CGPoint, to current: CGPoint) -> Double {
  let deltaLocation = Double(current.x - previous.x)
  
  return (maximumValue - minimumValue) * deltaLocation / Double(totalLength)
}

 움직인 거리를 초단위로 변환해준다. 

 

이후, 다음과 같이 움직인 거리만큼 업데이트를 하고, 이들을 최솟값과 최댓값으로 바운드처리를 해준다.

func updatedLowerValue(moved delta: Double) -> Double {
  return (lowerValue + delta).bound(lower: minimumValue, upper: upperValue - gapBetweenSliders)
}

func updatedUpperValue(moved delta: Double) -> Double {
  return (upperValue + delta).bound(lower: lowerValue + gapBetweenSliders, upper: maximumValue)
}

 

초 단위를 View의 좌표계로 변경

다음으로 이 lowerValue, upperValue를 가지고 실제 View에서의 위치를 표기해야 한다. 

실제 imageFrame이 위치하는 곳은 파란색 부분이다. 하지만, SliderThumb는 ImageFrame보다 바깥쪽에서 움직일 수 있게 된다. 

즉, 위와 같이 왼쪽 기준을 각각의 value가 위치하는 곳이라 치면,

2개의 Slider가 맞닿으면, 실제로 SliderThumb의 넓이만큼의 초가 남아있게 된다. 

func updateSliderFrame(_ slider: EditSlider) {
  let width = Constants.sliderWidth
  
  let leading = slider === lowerSlider ? leading(of: lowerValue) : leading(of: upperValue)
  
  slider.frame = CGRect(
    x: leading,
    y: 0,
    width: width,
    height: bounds.height
  )
  updateTopAndBottomLayer()
}

func leading(of value: Double) -> Double {
  return totalLength * value / maximumValue // totalLength는 움직일 수 있는 실제 거리
}

 

위의 코드로 실제 view의 frame에 매핑을 해주면 된다. 

 

영상 위치 조절 기능 

다음으로는 영상 위치를 나타내는 seekThumb를 구현해보자. 

private let seekThumb = SliderThumb(tintColor: .white, hightlightedColor: .systemGray4)

 

마찬가지로 seekValue를 초단위이다.

private(set) var seekValue: Double = 0 {
  didSet {
    updateSeekThumbFrame()
    delegate?.seekValueDidChanged(self, value: seekValue)
  }
}

 

위치 계산

View의 좌표계에서 초 단위로 변환

lowerThumb, upperThumb와 마찬가지로, Devide상에서의 위치 변화를 초단위인 seekValue로 변환해주어야 한다. 

override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
  ...

  let seekThumbDeltaValue = deltaValue(from: previousLocation, to: location)
  
  if thumb === seekThumb {
    self.seekValue = updatedSeekValue(moved: seekThumbDeltaValue)
  }
  ...
  
  sendActions(for: .valueChanged)
  return true
}

 

func updatedSeekValue(moved delta: Double) -> Double {
  return (seekValue + delta).bound(lower: lowerValue, upper: upperValue)
}

이때 주의할점은 seekValue의 경우에는 bound가 lowerValue서부터, upperValue까지이다. 

 

초 단위를 view의 좌표계으로 변환

마지막으로 seekValue를 view의 좌표값 단위로 변경해주어야 한다. 

 

이때 주의 할점은 seekThumb와 lowerThumb, upperThumb의 움직일 수 있는 범위가 다르다는 것이다.

따라서, seekThumb의 leading을 구해주는 코드는 lowerValue로부터 떨어져 있는 값을 실제 Device에서의 위치로 변환을 해주면 된다.

func leadingForSeek(of value: Double) -> Double {
  let range = upperValue - lowerValue
  guard range > 0 else { return 0 }
  
  let proportion = (value - lowerValue) / range
  return Double(upperThumb.frame.minX - lowerThumb.frame.maxX) * proportion
}

 

Frame이미지 생성 

마지막으로 ImageFrame을 생성하는 코드이다.

이때, 처음으로 생각했던 방법은 다음과 같이 ImageView를 Frame 갯수만큼 생성해주어, 

이미지를 넣어주는 방식이다.

하지만, Frame 갯수만큼 ImageView가 생성되기 때문에, 최적화를 위해

각 Frame의 이미지를 하나로 합치기로 결정했다. 

func frameImage(
  with generator: AVAssetImageGenerator,
  times: [CMTime],
  frameWidth: CGFloat
) async -> UIImage {
  var resultImages = Array(repeating: UIImage(), count: Constants.frameCount)
  
  await withTaskGroup(of: Void.self) { group in
    for (index, time) in times.enumerated() {
      group.addTask {
        guard let image = try? await generator.generateUIImage(at: time) else { return }
        resultImages[index] = image
      }
    }
  }
  // 이미지를 하나로 합치기
}

우선, frame 갯수에 맞는 이미지들을 AVAssetImageGenerator를 통해 이를 생성한다. 

마지막으로 이미지를 하나로 합쳐줘야 한다. 

이때, 합치는 방법에는 크게 CPU기반과 GPU기반이 있다. 

둘의 시간 측정을 하기 위해서 영상길이를 4분 4초이고, frame 갯수는 500개로 설정했다.

 

CPU 기반 랜더링 

extension Array where Element: UIImage {
  func concatImagesHorizontaly() -> UIImage {
    let maxWidth = self.compactMap { $0.size.width }.max()
    let maxHeight = self.compactMap { $0.size.height }.max()
    
    let maxSize = CGSize(width: maxWidth ?? 0, height: maxHeight ?? 0)
    let totalSize = CGSize(width: maxSize.width * (CGFloat)(self.count), height: maxSize.height)
    
    return UIGraphicsImageRenderer(size: totalSize).image { context in
      for (index, image) in self.enumerated() {
        
        let rect = CGRect(
          x: maxSize.width * CGFloat(index),
          y: 0,
          width: maxSize.width,
          height: maxSize.height
        )
        
        image.draw(in: rect)
      }
    }
  }
}

위와 같은 CPU기반 랜더링 방식에선, 0.36초가 늘었다. 

 

GPU 기반 랜더링

extension Array where Element: UIImage {
  func concatImagesHorizontallyGPU() -> UIImage {
    let context = CIContext(options: [CIContextOption.useSoftwareRenderer: false])

    let ciImages = self.compactMap { CIImage(image: $0) }

    guard !ciImages.isEmpty else { return UIImage() }

    let maxWidth = ciImages.compactMap { $0.extent.width }.max() ?? 0
    let maxHeight = ciImages.compactMap { $0.extent.height }.max() ?? 0
    
    let maxSize = CGSize(width: maxWidth, height: maxHeight)
    let totalSize = CGSize(
      width: maxSize.width * (CGFloat)(self.count),
      height: maxSize.height
    )
    let finalRect = CGRect(origin: .zero, size: totalSize)
    
    let outputImage = ciImages.enumerated().reduce(CIImage()) { result,  element in
      let (index, image) = element
      let xOffset = maxWidth * CGFloat(index)
      
      let translatedImage = image.transformed(by: CGAffineTransform(
          translationX: xOffset,
          y: (maxHeight - image.extent.height) / 2
        )
      )
      return result.composited(over: translatedImage)
    }
    
    // 결과를 UIImage로 변환
    guard let cgImage = context.createCGImage(outputImage, from: finalRect) else { return UIImage() }
    return UIImage(cgImage: cgImage)
  }
}

위와 같은 GPU 랜더링 방식에선, 동일한 조건에서 0.09초가 소요됐다. 

 

 

 

이미지 블러 처리

마지막으로 이미지의 블러처리이다. 

아래 사진과 같이 lowerThumb와 upperThumb밖에 있는 영역에 있는 이미지는 위와 같이 Blur처리를 해주어야 한다.

이를 처리 하기 위해서, VideoTrimmingSliderBar 최상단에 BlurView를 하나 추가했다. 

해당 BlurView는 다음과 같이 된다. 

final class ImageFrameBlurView: UIView {
	var selectedRect: CGRect = .zero {
		didSet { setNeedsDisplay() }
	}
	
	init() {
		super.init(frame: .zero)
		self.backgroundColor = .clear
	}
	
	override func draw(_ rect: CGRect) {
		super.draw(rect)
		guard let context = UIGraphicsGetCurrentContext() else { return }
		
		context.setFillColor(UIColor.black.withAlphaComponent(0.4).cgColor)
		context.fill(self.bounds)
		
		context.setBlendMode(.clear)
		context.fill(selectedRect)
		
		context.setBlendMode(.normal)
	}
	...
}

 

즉, selectedRect 외의 영역은 blur처리로 fill을 해주고, 외의 영역을 clear로 처리했다. 

복사했습니다!