이미지 리사이즈 방법 총정리 – Swift

최근에 이미지 크기를 일괄적으로 줄여서 리사이징하는 간단한 도구를 만들어 봤는데, 요상하게 결과 이미지가 흐려졌다. 결과가 맘에 들지 않아서 좀 더 고품질의 결과를 얻는 방법을 찾기 위해 이것 저것 조사해보니, iOS/macOS에서는 이미지 크기를 변환하는 다양한 방법이 있다는 것을 알게 되었다. 비트맵 이미지를 리사이징하는 다양한 방법들에 대해서 살펴보도록 하자.

UIKit

iOS에서는 간단하니 비트맵이미지 처리는 제법 쉬운 편이다. UIKit의 이미지 생성 관련 함수들을 사용하면 손쉽게 비트맵 이미지를 만들 수 있다.  방법은 단순한데, 목표 크기를 가지고 해당 크기에 맞는 비트맵 컨텍스트를 생성한다. (그러면 메모리상에 비트맵데이터를 저장해둘 공간이 마련된다.) UIImage를 해당 컨텍스트에 크기를 줄여 그린 후에, 컨텍스트로부터 비트맵 이미지를 얻으면 된다. 여기서 사용되는 함수들은 다음과 같다.

대략 코드는 다음과 같다고 보면 되겠다.

/// UIKit에서 이미지 리사이징
/// 원본: UIImage, 결과: UIImage

func resize(image: UIImage, scale: CGFloat, completionHandler: ((UIImage?) -> Void)?) 
{
   let transform = CGAffineTransform(scaleX: scale, scaleY: scale)
   let size = image.size.applying(transform)
   UIGraphicsBeginBeginImageContext(size)
   image.draw(in: CGRect(origin: .zero, size: size))
   let resultImage = UIGraphicsGetImageFromCurrentContext()
   UIGraphicsEndImageContext()
   
   completionHandler(resultImage)
}

코어 그래픽

실질적으로 UIKit의 이미지 리사이징은 코어 그래픽을 사용하는 고수준API라 볼 수 있다. 직접 코어 그래픽 API를 사용하는 것은 조금 귀찮다고 생각할 수 있지만, macOS에서도 사용할 수 있고 세세한 옵션을 관리할 수 있다는 장점이 있다. 특히 여기서 관리하는 옵션 중에서는 interpolation 품질을 설정할 수 있기 때문에 보다 고품질의 결과를 얻을 수 있다는 장점이 있다.  참고로 macOS에서 이미지를 표현하는데 사용되는 NSImage는 비트맵 이미지에만 국한되어 사용되는 것이 아니기 때문에 이 예제에서는 CGImage를 사용하도록 한다.

/// Core Graphics를 사용하는 이미지 리사이징
/// 원본 : CGImage,  결과 : CGImage

func resize(image: CGImage, scale: CGFloat, completionHandler: (CGImage?) -> Void)
{
  let size = CGSize(width: CGFloat(image.width), height: CGFloat(image.height))
  let context = CGContext( // #1
       data: nil,
       width: Int(size.width * scale),
       height: Int(size.height * scale),
       bitsPerComponent: 8,
       bytesPerRow: 0,
       space: CGColorSpaceCreateDeviceRGB(),
       bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
  context.interpolationQuality = .high // #2
  context.draw(image, in: CGRect(origin:.zero, size:size))
  let resultImage = context.makeImage()
  completionHandler(resultImage)
}

실제로 이미지뷰에서 뷰보다 더 큰 이미지를 줄여서 표시하는 경우에 내부적으로는 이 방식과 비슷하게 현재 컨텍스트에 캐시된 비트맵을 빠르게 그려준다고 보면 된다. 물론 속도를 위해서 어느 정도 품질을 하락시키는데, 특히 큰 이미지를 작은 크기의 NSImageView에 넣었을 때, 실제로 위 방법으로 리사이징 한 것보다 더 품질이 나빠진다. (보간 옵션이 .low인 것 같음.)

사실 썸네일을 만드는 데 있어서 이 방법은 간단하기는 하나, 큰 이미지를 적절한 크기로 줄여서 사용하려는 경우, 이미지가 흐려지는 것이 체감될 정도로 품질은 좋지 못하다. (UIKit의 방법들도 비슷한 퀄리티 문제를 안고 있다.) 코어 그래픽에서 내부적으로 사용하는 기본 샘플링 방식의 한계로 보인다. 그래서 보다 고품질의 결과물을 만들 수 있는 방법을 추가로 소개하고자 한다.

코어 이미지

디지털 이미지를 확대/축하거나 왜곡하는 등의 변형을 가하게 되면 각 픽셀의 수학적인 거리가 변경되는데, 이것을 다시 비트맵으로 환산하기 위해서는 벌어진 픽셀 사이를 새로운 픽셀로 메꾸거나, 혹은 겹쳐진 두 개 이상의 픽셀을 하나의 픽셀로 대체/치환해야 한다. interpolation은 이렇게 이미지를 변환할 때 픽셀을 보정하는 것인데, 이와 관련해서는 다양한 알고리듬들이 개발되어 쓰인다. 속도를 우선한다면 계산이 간단한 대신에 빠르게 적용할 수 있는 보간법을 적용할 것이고, 품질을 중시한다면 보다 복잡한 계산을 요하는 이미지처리 알고리듬을 사용할 것이다. UIKit이나 코어 그래픽에서 사용하는 기본 보간법도 보통의 이미지에서는 충분히 괜찮은 품질을 보여주지만, 아무래도 조금 흐릿해지는 경향이 있다. (Bilinear  혹은 Bicubric 보간을 쓰지 않나 싶다.)

코어 이미지에서는 이미지의 변형을 담당하는 필터들이 있는데, 이중 CILanczosScaleTransform 필터는 Lanczos 보간법을 사용하여 보다 좋은 품질의 결과를 만들 수 있다. CIImageCGImageUIImage와 상호변환이 쉬우며, 이 방법은 여느 CIFilter를 사용하는 방식과 동일하기 때문에 유용한 방법이다. 단 CIImage를 실제로 렌더링하기 위해서는 CIContext 객체가 필요한데, 이것을 생성하는 비용이 제법 크게 들어가므로 컨텍스트 객체는 계속 재사용하는 것이 권장된다.

이 필터의 파라미터는 inputImage, inputScale, inputAspectRatio 이며, 앞서 소개한 방법과 달리 특정 크기에 맞추는 것이 아니라 비율을 유지하고 n배로 스케일한다.

/// Core Image를 사용하여 리사이징 (고화질)
/// 입력: CIImage, 결과: CIImage
func resize(image: CIImage, scale: CGFloat, completionHandler: (CIImage?) -> Void) 
{
  let filter: CIFilter = { 
    let f = CIFilter(name: "CILanczosScaleTransform")
    f.setValue(image, forKey: kCIInputImageKey)
    f.setValue(scale, forKey: kCIInputScaleKey)
    f.setValue(1.0, forKey: kCIInputAspectRatioKey)
    return f
  }()
  let resultImage = f.outputImage()
  completionHandler(resultImage)
}

Lanczos 보간을 사용하는 경우, 경계면의 콘트라스트가 두드러지면서 전체적으로 이미지가 과도하게 날카롭고 거친 느낌을 준다. 한가지 문제라면, 오른쪽 끝에 1픽셀짜리 흰줄이 생기는 경우가 종종 있다는 점이다.

Image IO를 사용하는 방법

Image IO는 AppKit, UIKit 과 별개로 보다 다양한 포맷의 이미지 파일을 다룰 수 있게 하는 프레임워크이다. 다만 이 프레임워크는 코어파운데이션 기반의 C로 작성된 것이며, 레퍼런스나 가이드 문서가 그다지 잘 구비되어 있지는 않은 편이다. 이미지 소스를데이터나 URL로부터 생성해서 이로부터 썸네일 이미지를 생성하면 된다. (이 프레임워크는 말 그대로 파일로 저장되어 있는 형태의 이미지를 읽고, 프로세싱하여 다시 파일로 쓰는 것에 특화되어 있다.)

CGImageSourceCreateThumbnailAtIndex(_:_:_:) 함수를 사용하여 이미지 소스로부터 축소된 썸네일을 얻을 수 있다. 이 때 필요한 것은 이미지 소스와 옵션 값(CFDictionary)이다.

이미지 소스는 URL이나 데이터로부터 생성할 수 있는데, 여기에는 몇가지 옵션 값을 넘겨주어야 한다. 옵션키에 대해서는 소스 옵션 키 문서를 참조하도록 하고, 간단히 예제 코드만 소개하겠다. 참고로 옵션을 넘겨줄 때의 타입은 CFDictionary 인데, 이는 Dictionary<String:AnyObject> 타입에서 as로 캐스팅하여 넘겨줄 수 있다.

import ImageIO  // #1

/// ImageIO를 사용하여 이미지를 리사이징한다. 
/// 입력: CGImage, 출력: CGImage

func resize(image: CGImage, pixelSize: Int, completionHandler: (CGImage?) -> Void)
{
  // CGImage의 비트맵데이터 얻기
  guard case let rep = NSBitmapImageRep(cgImage: image),
        let data = rep.tiffRepresentation(using: .none, factor: 1.0)
  else { return }

  let imageSourceOptions = [
     kCGImageSourceShouldCache : true,
     kCGImageSourceShouldAllowFloat: true
  ] as CFDictionary

  guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions)
  else { return }

  // 썸네일 옵션
  let thumbnailOptions = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceThumbnailMaxPixelSize: pixelSize,
    kCGImageSourceCreateThumbnailWithTransform: true
  ] as CFDictionary

  // 썸네일 생성
  let resultImage = CGImageSourceCreateThumbnail(imageSource, 0, thumbnailOptions)
  completionHandler(resultImage)
}

참고로 ImageI/O의 CGImageSource는 디스크등으로부터 이미지 데이터를 읽어오는 작업을 추상화하는 객체이다. 반대로 이미지를 쓰기 위해서는 CGImageDestination을 사용한다.  ImageIO를 사용하면 CG이미지를 다음과 같이 CGImageDestinationFinalize() 함수에 넘겨서 파일로 저장할 수 있다. (macOS에서 실행하려는 경우, 프로젝트 설정에서 앱의 샌드박스 옵션을 꺼야 한다.)

DispatchQueue.global().async {
  let imageType = "public.jpeg" // #1
  let url = <# ... #>
 
  // #2
  guard let imageDest = CGImageDestinationCreateWithURL(url as CFURL, imageType, 1, nil)
  else { return }

  // #
  let options= [
    kCGImageDestinationLossyCompressionQuality: 1.0,
    kCGImageDestinationBackgroundColor: CGColor.white
  ] as CFDictionary
  CGImageDestinationAddImage(imageDest, cgImage, options)
  _ = CGImageDestinationFinalize(imageDest)
}

Image I/O는 속도가 빠르며, 특히 수백~수천만 픽셀의 매우 큰 이미지에서 강점을 보인다.

vImage

이번에는 또다른 Apple의 잘 알려지지 않은 프레임 워크인 Accelerate를 사용하는 방법이다. 이 프레임워크는 CPU의 Vector 연산 유닛을 사용하여 고성능에 최적화된 연산을 제공하는데, 여기에는 특정 분야에 쓰이는 고속 연산 API들이 모여있다. 이중, vImage라는 고속 이미지 연산을 사용해서 대용량 이미지도 거뜬하게 리사이징할 수 있다. (그리고 역시 품질도 매우 우수하다.) 다만 vImage를 사용하여 이미지를 제어하려는 경우에는 이미지 프레임 버퍼를 수동으로 다루어야 한다는 불편함이 있다.

vImage를 사용하여 이미지를 변환하기 위해서는 다음의 단계를 거쳐야 한다.

  1. CGImage의 비트맵 데이터를 vImage의 프레임 버퍼로 변환하기 위해서는 이미지 포맷이라는 데이터를 만들어야 한다. CGImage는 통상 4개의 8비트 채널로 이루어지기 때문에 하드코딩된 설정으로 이를 생성할 수 있다. 이미지 포맷을 기준으로 비트맵데이터와 프레임버퍼간의 변환이 이루어진다.
  2. 원본 이미지를 가지고 소스 프레임을 생성한다.
  3. 조정된 크기만큼의 대상 프레임을 생성한다.
  4. vImage API를 사용하여 소스프레임을 변환하여 대상프레임으로 복사한다.
  5. 대상 프레임을 다시 비트맵 이미지로 변환한다.

각 과정에 대해서 애플은 4개의 문서를 할애해서 설명하고 있다. 아래 코드는 이 과정을 정리한 것이다.

import Accelerate

/// 이미지 축소

do {

  guard let sourceImage = getSourceImage() // 소스 이미지는 이미 있다고 가정한다.

  // 이미지 포맷 데이터 생성
  var format = vImage_CGImageFormat(
      bitsPerComponent: 8,
      bitsPerPixel: 8 * 4,
      colorSpace: nil, // Unmanaged.passRetained(CGColorSpaceCreateDeviceRGB())
      bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.first.rawValue),
      version: 0,
      decode: nil,
      renderingIntent: .defaultIntent)

  /* 또는 원본 이미지로부터 생성할 수도 있다.
  guard let sourceColorSpace = cgImage.colorSpace 
  else {
    print("Unable to initialize cgImage or ColorSpace")
    break
  }

  var sourceImageFormat = vIamge_CGImageFormat(
    bitsPerComponent: UInt32(sourceImage.bitsPerComponent),
    bitsPerPixel: UInt32(sourceImage.bitsPerPixel),
    colorSpace: Unmanaged.passRetained(sourceColorSpace),
    bitmapInfo: sourceImage.bitmapInfo,
    version: 0,
    decode: nil,
    renderingIntent: sourceImage.renderingIntent)
  */

  // 소스 프레임 버퍼를 만든다.
  var error = kvImageNoError
  error = vImageBuffer_InitWithCGImage(
    &sourceBuffer,
    &format,
    nil,
    image,
    vImage_Flags(kvImageNoFlags))

  guard error == kvImageNoError else { fatalError("Error in vImageBuffer_InitWithCGImage: \(error)") }
  
  // 대상 프레임 버퍼를 만든다. 
  let targetWidth = sourceImage.width / 3
  let targetHeight = sourceImage.height / 3
  var destinationBuffer = vIamge_Buffer()
  error = vImageBuffer_Init(
    &destinationBuffer,
    targetHeight,
    targetWidth,
    format.bitsPerPixel,
    vImage_Flags(kvImageNoFlags))
  guard error == kvImageNoError else {
    fatalError("Error in vImageBuffer_Init")
  }

  // 이미지를 리사이징한다.
  error = vImageScale_ARGB8888(
    &sourceBuffer,
    &destinationBuffer,
    nil,  // temp buffer
    vImage_Flags(kvImageHighQualityResampling))
  guard error == kvImageNoError else {
    fatalError("Error in vImageScale_ARGB8888")
  }


  // 대상 버퍼로부터 이미지를 만들기
  let resultImage = vImageCreateCGImageFromBuffer(
    &destinationBuffer,
    &format,
    nil, // callback
    nil, // userData
    vImage_Flags(kvImageNoFlags),
    &error)
  guard error == kvImageNoError else {
    fatalError("Error in vImageCreateCGImageFromBuffer")
  }

  // 버퍼를 해제한다. 사실 이 부분은 defer { ... } 를 사용해서 위에서 선언해야 한다.
  free(sourceBuffer.data)
  free(destinationBuffer.data)

  // resultImage는 Unmanged 객체이다. 따라서 최종 이미지는 이를 리테인한다.
  let result = resultImage.takeRetained()
}

이상으로 iOS/macOS에서 사용할 수 있는 이미지 크기 변환에 관한 방법을 정리해 보았다. 널리 쓰이는 방법들이 아무래도 가장 쉬운 길이기는 하지만, 고품질/고성능의 리사이징을 사용하고 싶다면 이 글이 도움이 되길 바란다. 개인적으로 체감했을 때, vImage를 사용하는 방법이 빠르고, 결과의 품질 면에서도 가장 좋은 것 같다.

트랙킹 캔버스 뷰 만들기 (Cocoa)

예전에 코어 그래픽을 사용해서 UIView위에 손가락으로 그림을 그릴 수 있는 간단한 핑거 드로잉 캔버스를 구현해본 바 있는데, 똑같은 내용을 NSView에 적용해보고자 한다. 이전글이 Objective-C로 작성되어 있는데, 이번에는 Swift로 간단하게 작성해보려 한다. 원리는 동일하다. CGLayer를 하나 만들고, 마우스를 사용해서 뷰를 긁을 때(드래그할 때)마다  코어 그래픽을 사용해서 레이어에 그림을 그리고, 다시 뷰 리드로잉 사이클에서는 뷰에 레이어를 그리는 것이다.

class TrackDrawCanvasView: NSView {
  var previousPoint: NSPoint? = nil
  lazy var drawingLayer: CGLayer? = { [unowned self] in
    let cs = CGColorSpace(name:CGColorSpace.sRGB)!
    let ctx = CGContext(data: nil,
                        width: 100,
                        height: 100,
                        bitsPerComponent: 8,
                        bytesPerRow: 0,
                        space: cs,
                        BitmapInfo: CGImageAlphaInfo.nonSkipLast.rawValue)
   if let ctx = ctx {
     let layer = CGLayer(ctx, size: self.bounds.size, auxiliaryInfo: nil)
     return layer
   }
   return nil
  }()

  lazy var drawingContext: CGContext? = { [unowned self] in
    let ctx = self.drawingLayer?.context
    // 그래픽 컨텍스트 셋업
    ctx?.setStrokeColor(NSColor.green.cgColor)
    ctx?.setLineWidth(3.0)
    // 이벤트 위치를 프레임만큼 보정 
    ctx?.translateBy(x:-self.frame.origin.x, y:-self.frame.origin.y)
    return ctx
  }
}

터치할 때 마우스 위치와 선이 그려지는 위치를 맞추기 위해서 좌표계를 뷰의 위치만큼 거꾸로 이동시켰다. 다음은 화면을 그릴 차례이다. NSEvent는 드래그에 대해서 이전 위치를 가지고 있지 않기 때문에 이전 위치를 추적해 나가야 한다.

///
var previousPoint: NSPoint? = nil

override func mouseDown(wit틀h event: NSEvent) {
    previousPoint = event.locationInWindow
}

override func mouseDown(with event: NSEvent) {
    previousPoint = nil
}

override func mouseDragged(with event: NSEvent) {
    let currentPoint = event.locationInWindow
    drawingContext?.beginPath()
    drawingContext?.move(to: previousPoint)
    drawingContext?.addLine(to: currentPoint)
    drawingContext?.strokePath()
    previousPoint = currentPoint
    needsDisplay = true
}

최종적으로 뷰를 그릴 때는 레이어를 그려주면 된다.

override func draw(_ dirtyRect: NSRect) {
  super.draw(dirtyRect)
  if let ctx = NSGraphicsContext.current?.cgContext,
  let layer = drawingLayer {
    ctx.draw(layer, at: CGPoint.zero)
  }
}

인터페이스 빌더에서 뷰를 윈도에 하나 올린 후 드래실행해보자. 끝!

참고

 

[iOS] 간단한 시계 만들기

심심해서 만들어보는 아날로그 시계

꼭 심심해서라기보다는 코어그래픽 예제 쯤으로… 사실 이렇게 노가다로 그림 그리는 앱을 별로 만들어보지는 못한 것 같아서 시작해본다. 만들고자 하는 앱은 아날로그 시계이며, 유형을 꼭 선택하라면 Pie Clock 쯤 되겠다. 즉, 시/분/초를 나타내는 파이 그래프를 중첩하여 현재 시간을 표현하는 것이다. 이는 의의로 상당히 간단하게 표현할 수 있다. [iOS] 간단한 시계 만들기 더보기

CGLayer를 사용한 핑거 드로잉 구현 (Objective-C)

코어 그래픽(Core Graphics)은 저수준의 드로잉 명령 API들을 통해서 화면이나 비트맵이미지, PDF 등에 시각적 요소를 그릴 수 있게 하는 프레임워크이다. 예전에는 Quartz, CoreGraphics라는 이름으로 분리되어 있었는데 iOS5 부터 UIKit의 일부로 완전히 편입되었다. 간단한 모양의 시각적 오브제를 표현하기 위해 비트맵 이미지를 사용하는 것보다 런타임에 오브제를 빠르게 그리고, 이를 재사용할 수 있게 하는 등의 기능을 제공한다. 실제로 많은 앱들이 현재에도 코어 그래픽을 사용해서 UI를 표현하는 경우가 많이 있다. 이번 글에서는 코어 그래픽 API를 사용해서 손가락으로 화면에 그림을 그리는 간단한 캔버스 앱을 구현하는 방법을 살펴보기로 하겠다.

코어 그래픽을 사용할 때에는 이 프레임워크의 핵심 객체인 그래픽 컨텍스트에 대한 이해가 필요하다. 그래픽 컨텍스트는 개념상, 가상의 캔버스라 생각하면 된다. 우리는 그래픽 컨텍스트라는 이 가상의 캔버스에 그림을 그리게 되고, 쿼츠 엔진은 이 가상의 캔버스에 그려진 그림을 필요한 출력으로 가져다 렌더링한다. 따라서 그래픽 컨텍스트는 장치 독립적인 성격을 가지며, 하나의 그래픽 컨텍스트는 다른 장치를 위한 그래픽으로 쉽게 전환이 가능하다. 따라서 그래픽 컨텍스트에 적용된 그래픽은 아이폰 및 아이패드용 화면 출력 뿐 아니라, 인쇄나 PDF를 만들기도 쉽게 지원된다. 실제로 macOS를 보면 모든 뷰는 PDF로 변환이 가능하고, 화면에 그려지는 모든 것이 PDF인 동시에 PNG일 수 있는데, 이것은 macOS의 드로잉 체계가 컨텍스트라는 개념을 중심으로 장치독립적으로 추상화되어 있기 때문에 가능한 것이다.

모든 화면에 출력되는 모든 뷰는 콘텐츠를 시각적으로 표현하는 도구이며, 그 콘텐츠를 뷰에 제공해주는 주체가 바로 그래픽 컨텍스트이다. 가상의 캔버스인 컨텍스트에 코어 그래픽 API를 사용하여 그림을 그리면 이 데이터가 그래픽 버퍼로 덤프되고, 그 결과 이미지가 화면에 뿌려지게 된다. 보통 특정한 뷰에 이렇게 그림을 그리기 위해서는 해당 뷰에서 “현재 컨텍스트”를 얻고 여기에 그림을 그리면 된다. 물론 컨텍스트는 별도로 생성할 수 있다. 별도로 생성한 컨텍스트에 그림을 그리는 것은 일종의 그래픽 데이터를 메모리 내에 준비하고 있는 것이 된다.

만약 추가적으로 생성한 그래픽 컨텍스트와 CGLayer를 결합하면, 컨텍스트의 데이터가 레이어의 콘텐츠를 제공하게 되고, 다시 이 레이어는 다른 컨텍스트에서 일종의 스탬프처럼 찍어서 사용할 수 있다. 이런식으로 직접 화면에 그리는 것이 아니라, 컨텍스트에 미리 그림을 그려놓고 이것을 재활용하여 반복적인 문양을 그리는 것을 (왜냐면 CGLayer는 재사용할 때 캐시된다!) 오프스크린 드로잉이라고 한다. 특히 오프스크린 드로잉은 백그라운드 스레드에서 처리가 가능하다는 장점이 있다. (iOS에서는 화면에 무언가를 그리기 위해서는 반드시 메인스레드에서 작업해야 한다.) 따라서 워커 스레드에서 미리 콘텐츠를 제작해놓고 메인스레드에서는 최종 결과물만 업데이트하는 식으로 처리하여 화면의 빠른 드로잉이 가능하게 할 수 도 있다.

플로우

핑거 드로잉은 말 그대로 화면에 손가락을 터치하고, 손가락이 터치해서 움직이는 경로를 따라 화면에 선이나 무늬를 그려넣는 것을 말한다. 즉, 가장 기본적인 인터랙티브 드로잉 방법이다. 이 핑거 드로잉을 지원하는 캔버스 뷰를 작성해보도록 하겠다. 기본 개념은 다음과 같다.

┌───CanvasView───┐                  ┌─Offscreen Layer─┐
│  View'sContext │                  │ Layer's Context │  
│                │    touch --->    │                 │
│                │                  │                 │
│                │                  │                 │
│                │       Draw       │                 │
└────────────────┘ <--------------- └─────────────────┘
  1. 뷰와 크기가 똑같은 레이어를 하나 준비한다.
  2. 손가락이 움직이는 궤적은 레이어에 그려진다.
  3. 레이어에 그림이 그려진 후에는 뷰에 레이어를 그린다.

실제로 손가락이 움직이는 궤적은 눈에 보이지 않는 캔버스에 그림을 그리는 것이다. 그리고 이렇게 그려진 데이터를 뷰에 언제 그리느냐에 따라서 손가락을 움직이는 사이사이에 선이 그려지게 할 것인지, 아니면 손가락을 떼는 시점에 그림이 나타나게 할 것인지를 결정할 수도 있다.

프로젝트 시작

새 프로젝트를 하나 만든다. 어차피 UIView 클래스를 새로 하나 만드는 것이 사실 구현의 전부이므로, Single View App으로 시작한다. 프로젝트를 생성하였으면, 새 파일을 추가한다. Objective-C 을 언어로 정하고, 클래스는  UIView를 선택한다. 이름은 CanvasView 정도가 좋을 것 같다.

앱 실행시에 해당 뷰가 전면에 표시되도록 이 캔버스 뷰가 들어갈 뷰 컨트롤러의 파일에서 viewDidLoad를 다음과 같이 수정해서 루트 뷰에 캔버스뷰를 추가한다. (혹은 UI빌더에서 UI뷰를 하나 삽입하고, 그 클래스를 CanvasView로 선택해도 된다.)

viewcontroller.h 수정하기

기본으로 세팅된 메인 뷰가 로드되면 캔버스뷰를 만들어서 자기 위에 얹도록 코드를 작성한다. 만약 스토리보드에서 메인 뷰의 클래스를 Canvas로 변경했다면 이 코드는 작성하지 않는다.

#import "CanvasView"
/* ... */
-(void)viewDidLoad {
    CanvasView *canvas = [[CanvasView alloc] 
                         initWithFrame:self.view.frame];
    [self.view addSubView:canvas];
}

캔버스뷰의 인스턴스 변수 정의

CanvasView의 헤더를 작성하자. 두 개의 인스턴스 변수를 선언한다. 뷰 내에서 오프스크린 드로잉을 담당할 레이어를 위한 CGLayerRef 변수와, 그 레이어에 그림을 그릴 수 있는 CGContext 타입 변수를 선언한다. 이 두 변수는 OpaqueType 이며, Objective-C 클래스가 아니므로 * 를 붙이지 않음에 유의하자.

@import UIKit;

@interface CanvasView: UIView
{
  CGContextRef layerContext;
  CGLayerRef drawingLayer;
} @end

초기화

초기화작업은 통상의 UIView의 초기화 프로세스를 따른 후, 두 개의 인스턴스 변수에 대한 초기화를 수행한다. 이 때의 순서는 다음과 같다.

  1. 비트맵 컨텍스트를 하나 생성한다.
  2. 1의 컨텍스트를 기반으로 CGLayer를 생성한다. 사실, 1에서 컨텍스트를 만들지 않고 참조 컨텍스트로는 NULL을 넣어도 상관없다. 하지만 넣어주는 경우에 조금 더 최적화된다고 한다.
  3. 2에서 생성한 레이어로부터 실제 레이어의 콘텐츠를 담을 컨텍스를 얻어서, 이를 drawingContext 값으로 대입한다.
  4. drawingContext를 설정한다. 선의 색이나, 굵기, 끝모양 등의 정보를 지정할 수 있다.
#import "CanvasView.h"

@implementation CanvasView
-(id) initWithFrame: (CGRect)frame
{
  self = [super initWithFrame: frame];
  if (self) {
    [self initContext];
  }
}

-(id) initWithCoder: (NSCoder*)aDecoder
{
  self = [super initWithCoder: aDecoder];
  [self initContext];
}

-(void) initContext {
  CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
  CGContextRef ctx = CGBitmapContextCreate(
                  NULL, 
                  10, 
                  10, 
                  8, 
                  0, 
                  cs, 
                  kCGBitmapAlphaPremulipliedLast);
  // 각각의 파라미터는 다음과 같은 의미이다.
  // 1 : NULL - 비트맵을 저장할 메모리 블럭, NULL을 넘겨주면 이 함수가 자동으로 할당한다.
  // 2, 3 : 10, 10 은 컨텍스트의 픽셀 크기이다. 
  // 4 : 8 - 컴포넌트당 비트. RGBA가 각각 8비트, 총 32비트 트루컬러를 사용할 것이다.
  // 5 : 0 - 한 줄당 바이트. 픽셀당 4바이트를 사용하고 총 10픽셀 폭이니 40이 될 것이다. 이는 1번 파라미터가 NULL인 경우
  //         0을 넘겨서 자동계산하게 할 수 있다. 
  // 6 : cs - 컬러스페이스
  // 7 : 비트맵정보 - 미리 정의된 CGImageAlphaInfo 상수를 사용한다.

  // 레이어와 컨텍스트 생성/초기화.
  drawingLayer = CGLayerCreateWithContext(ctx, self.bounds.size, NULL);
  drawingContext = CGLayerGetContext(drawingLayer);
  // 컨텍스트에 그림을 그리는 방법을 세팅한다.
  CGContextSetStrokeColorWithColor(drawingContext, [[UIColor redColor] CGColor]);
  CGContextSetLineWidth(drawingContext, 4.8f);

  // 참조용으로 생성한 객체들을 정리한다. 
  CGContextRelease(ctx);
  CGColorSpaceRelease(cs);
}

이 코드에서 주목해야 할 점은 맨 처음 비트맵 컨텍스트를 생성할 때, 사이즈를 화면 크기가 아니라 제멋대로 주었다는 점이다. 이 컨텍스트를 기반으로 레이어를 만들 때, 레이어만 뷰의 크기와 일치시켰다. CGLayerCreateWithContext() 함수에서 흔히 잘못알고 있는 점은 이 함수에서 넘겨지는 그래픽 컨텍스트 객체는 레이어에 그려지는 그림과는 실제 무관할 수 있다는 점이다. 실제로 비트맵 컨텍스트를 만들지 않고 NULL을 전달하여도 코드는 정상적으로 동작한다. 그렇다면 이는 왜 필요한 것일까?

그래픽 컨텍스트는 장치독립적인 페이지이며, 이 페이지는 앱의 윈도에 그려지거나, 프린터로 출력되거나 혹은 비트맵 이미지로 고정될 수 있다. 이 때 각각의 출력 디바이스에 따라서 다른 정보들이 사용되고, 이는 내부적인 타입이 구분되어 사용되는 것으로 이해할 수 있다. CGLayerCreateWinContext()함수에서 전달받는 컨텍스트 인자는 CGLayer를 생성할 때 캔버스로 사용할 컨텍스트를 받는 것이 아니다. 레이어를 생성할 때, 레이어의 컨텍스트는 별도로 생성되는데, 이 때 인자로 전달받은 컨텍스트의 속성을 참조하여 생성되는 컨텍스트를 최적화한다. (실제로 이를 NULL로 전달하는 것보다, 이렇게 간단하게 만들어서 전달하는 경우, 드로잉 성능이 더 좋다.) 즉, ctx != CGLayerGetContext(drawingLayer) 이다.

 

터치 동작 구현

UIView에서 터치가 움직일 때, 뷰는 touchesMoved:withEvent 메시지를 받는다. 이 메소드를 오버라이딩하여 움직인 만큼 부분에 선을 그려넣도록 한다. 선을 그려넣는 작업은 현재 뷰의 컨텍스트가 아닌 drawingContext이다. 여기에 콘텐츠를 그려넣은 다음에 뷰에는 레이어를 그려넣으면 된다.

- (void)touchesMoved:(NSSet<NSTouch *>*)touches withEvent:(UIEvent *)event
{
  CGPoint lastTouch, currentTouch;
  UITouch *touch = [touches anyObject];
  lastTouch = [touch previousLocationInView: self];
  currentTount = [touch locatioinInView: self];

  // 선을 그리자. 
  // 이전 위치로 이동후 현재 위치로 선을 추가한다. 
  CGContextBeginPath(drawingContext);
  CGContextMoveToPoint(drawingContext, lastTouch.x, lastTouch.y);
  CGContextAddLineToPoint(drawingContext, currentTouch.x, currentTouch.y);
  CGContextStrokePath(layerContext);

  // 그려진 레이어를 뷰에 반영하기 위해 뷰 업데이트를 스케줄링한다.
  [self setNeedsDisplay];
} 

이상의 구현에서 특별한 점은 없다. 여기서는 터치가 조금씩 움직이는 주기마다 컨텍스트에 선을 추가하고, 뷰를 업데이트하도록 한다. 만약 손가락을 뗐을 때만 뷰가 업데이트되도록 하려면 [self setNeedsDisplay];를 touchesEnded:withEvent:에서 호출하도록 한다.

뷰 업데이트

setNeedsDisplay 메시지를 받으면 뷰는 자신의 상태가 유효하지 않다는 것을 감지하고 뷰 영역을 새로 그리려고 시도한다. 이는 drawRect: 메시지를 호출하여 그리게 된다. 이미 지금까지 그려놓은 모든 페인팅은 drawingContext에 남아있고, 이를 뷰에 찍기 위해서는 drawingLayer를 그려주면 된다. 여기서는 뷰의 현재 컨텍스트를 이용해서 레이어를 그리면 된다.

- (void)drawRect: (CGRect) rect
{
  [super drawRect:rect];
  CGContext ctx = UIGraphicsGetCurrentContext();
  CGContextDrawLayerInRect(ctx, self.bounds, drawingLayer);
}

정리

이제 모든 소스 내용을 검토해보자.

  1. 앱이 시작되면 메인 뷰에 캔버스 뷰 인스턴스를 만들어서 붙인다.
  2. 캔버스 뷰는 생성되면 자신의 크기와 같은 오프스크린 레이어와 레이어에 그림을 그릴 컨텍스트를 생성한다.
  3. 뷰가 터치를 받고, 터치가 움직이면 이 궤적을 따라서 선을 만들 수 있고, 이 선은 레이어의 컨텍스트에 추가된다.
  4. 선을 그릴 때마다 뷰는 업데이트 요청을 받고, 컨텍스트의 비트맵 데이터가 투영된 레이어가 뷰에 찍힌다.

조금 더 깊이

코어 그래픽 컨텍스트는 장치 독립적인 가상 캔버스라고 했다. 그리고 어떤 장치를 통해서 표현되느냐에 따라서 비트맵 형식일 수도 있고, PDF 형식일수도 있다. CGLayerCreateWithContext() 에서 넘겨지는 컨텍스트는 실제로 생성된 레이어의 컨텍스트를 특정 타입으로 최적화하기 위해 필요하며, 생성된 레이어에 대해 CGLayerGetContext()로 얻게되는 컨텍스트와는 동일할 수도, 그렇지 않을 수도 있다. 따라서 이 둘이 같은 것이라는 어떠한 가정도 해서는 안된다. 실제로 drawingContext를 비트맵 컨텍스트로 생성한 후, 이 컨텍스트를 참조로 레이어를 만든다면 컨텍스트에 그린 그림이 레이어에 전혀 반영되지 않을 것이다.