예를 들어 데이터가 있고, 데이터를 바탕으로 다음과 같은 그래프를 그리고 싶다고 가정합니다.

// Weekly sample data
var graphPoints = [4, 2, 6, 4, 5, 8, 3]

이 그래프 디자인 특징으로는 그래프 선 아래에 새로운 그라데이션이 있고, 각 그래프의 일정 범위마다 수치가 큰 크기의 점(원)으로 그려져있다는 점입니다.

 

이 디자인에 맞춰 코드를 작성하고 있고, 현재 아래 그림까지 완성된 UIView가 있습니다.

@IBDesignable
class GraphView: UIView {
    
    // Weekly sample data
    var graphPoints = [4, 2, 6, 4, 5, 8, 3]
    
    // 1
    @IBInspectable var startColor: UIColor = .red
    @IBInspectable var endColor: UIColor = .green
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override func draw(_ rect: CGRect) {
        let width = rect.width
        let height = rect.height
        
        let path = UIBezierPath(
          roundedRect: rect,
          byRoundingCorners: .allCorners,
          cornerRadii: Constants.cornerRadiusSize
        )
        path.addClip()
        
        // 2
        guard let context = UIGraphicsGetCurrentContext() else {
            return
        }
        let colors = [startColor.cgColor, endColor.cgColor]
        
        // 3
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        
        // 4
        let colorLocations: [CGFloat] = [0.0, 1.0]
        
        // 5
        guard let gradient = CGGradient(
            colorsSpace: colorSpace,
            colors: colors as CFArray,
            locations: colorLocations
        ) else {
            return
        }
        
        // 6
        let startPoint = CGPoint.zero
        let endPoint = CGPoint(x: 0, y: bounds.height)
        context.drawLinearGradient(
            gradient,
            start: startPoint,
            end: endPoint,
            options: []
        )
        
        // Calculate the x point
            
        let margin = Constants.margin
        let graphWidth = width - margin * 2 - 4
        let columnXPoint = { (column: Int) -> CGFloat in
          // Calculate the gap between points
          let spacing = graphWidth / CGFloat(self.graphPoints.count - 1)
          return CGFloat(column) * spacing + margin + 2
        }
        
        // Calculate the y point
            
        let topBorder = Constants.topBorder
        let bottomBorder = Constants.bottomBorder
        let graphHeight = height - topBorder - bottomBorder
        guard let maxValue = graphPoints.max() else {
          return
        }
        let columnYPoint = { (graphPoint: Int) -> CGFloat in
          let yPoint = CGFloat(graphPoint) / CGFloat(maxValue) * graphHeight
          return graphHeight + topBorder - yPoint // Flip the graph
        }
        
        // Draw the line graph

        UIColor.white.setFill()
        UIColor.white.setStroke()
            
        // Set up the points line
        let graphPath = UIBezierPath()

        // Go to start of line
        graphPath.move(to: CGPoint(x: columnXPoint(0), y: columnYPoint(graphPoints[0])))
            
        // Add points for each item in the graphPoints array
        // at the correct (x, y) for the point
        for i in 1..<graphPoints.count {
          let nextPoint = CGPoint(x: columnXPoint(i), y: columnYPoint(graphPoints[i]))
          graphPath.addLine(to: nextPoint)
        }

        graphPath.stroke()
        
        // Create the clipping path for the graph gradient
            
        // 2 - Make a copy of the path
        guard let clippingPath = graphPath.copy() as? UIBezierPath else {
            return
        }
        
        clippingPath.addLine(to: CGPoint(x: columnXPoint(graphPoints.count - 1), y: height))
        clippingPath.addLine(to: CGPoint(x: columnXPoint(0), y: height))
        clippingPath.close()
        
        // 4 - Add the clipping path to the context
        clippingPath.addClip()
        
        // 5 - Check clipping path
        let highestYPoint = columnYPoint(maxValue)
        let graphStartPoint = CGPoint(x: margin, y: highestYPoint)
        let graphEndPoint = CGPoint(x: margin, y: bounds.height)
                
        context.drawLinearGradient(
          gradient,
          start: graphStartPoint,
          end: graphEndPoint,
          options: [])
		  
	// Draw the line on top of the clipped gradient
        graphPath.lineWidth = 1.5
        graphPath.stroke()
 
        
        // .. 추가 작성 필요 .. //
        
    }
}

여기까지 완성된 UIView입니다.

 

이제 여기서 원으로 된 점을 넣을 차례이고, 다음 코드를 draw 함수의 최하단에 작성하였습니다. 그 후 결과를 보면 점이 제대로 표시되지 않는 문제가 발생합니다.

// Draw the circles on top of the graph stroke
for i in 0..<graphPoints.count {
  var point = CGPoint(x: columnXPoint(i), y: columnYPoint(graphPoints[i]))
  point.x -= Constants.circleDiameter / 2
  point.y -= Constants.circleDiameter / 2

  let circle = UIBezierPath(
    ovalIn: CGRect(
      origin: point,
      size: CGSize(
        width: Constants.circleDiameter,
        height: Constants.circleDiameter)
    )
  )
  circle.fill()
}

 

이 문제가 발생하는 이유는 무엇일까요?

정답은 중간에 클리핑된 path가 존재하기 떄문입니다.

  • clippingPath.addClip()

이 명령은 clippingPath의 범위로 클립을 자른 뒤, 컨텍스트를 해당 영역으로 한정시키는 역할을 합니다. 다음 초록색으로 표시된 클리핑 영역 밖으로는 컨텍스트가 그릴 수 있는 범위가 아니며, 따라서 클리핑 영역을 벗어난 바깥에는 점이 그려지지 않는 것입니다.

 

그렇다면 점을 clippingPath보다 미리 그리면 되지 않을까요?

점을 clippingPath보다 먼저 그리면 반대로 클리핑 영역에서 점이 표시되지 않습니다. 이 문제를 해결하려면 그래픽 상태의 통칭인 GState(graphics state) 라는 개념이 필요합니다.

 

점이 이상하게 나타나는 이유는 상태(GState)와 관련이 있습니다. 그래픽 컨텍스트는 상태를 저장할 수 있습니다. 상태에 저장되는 정보는 채우기 색상, 변환 매트릭스, 색상 공간 또는 클리핑 영역 등이 있는데, 여기에서 클리핑 영역도 상태 정보라는것을 주목해야 합니다.

현재 상태에 클리핑 영역이 있기 때문에 컨텍스트는 클리핑 영역 내에서만 동작하는 것입니다.

 

그렇다면 클리핑 영역에 관계없이 점을 온전한 형태로 표시하려면 어떻게 해야 할까요? 컨텍스트에서 제공하는 두 개의 메소드를 이용하면 됩니다.

현재 그래픽 상태의 복사본을 상태 스택에 푸시하는 context.saveGState()를 사용하여 상태를 저장할 수 있습니다. 그래프의 클리핑 단계 전에 context.saveGState()를 삽입합니다.

// 1 - Save the state of the context
context.saveGState()
    
// 2 - Make a copy of the path
guard let clippingPath = graphPath.copy() as? UIBezierPath else {
    return
}

clippingPath.addLine(to: CGPoint(x: columnXPoint(graphPoints.count - 1), y: height))
clippingPath.addLine(to: CGPoint(x: columnXPoint(0), y: height))
clippingPath.close()

// ... //

// 4 - Add the clipping path to the context
clippingPath.addClip()

상태가 스택에 push되어 저장되었습니다. 상태 스택에 저장된 최신 정보는 클리핑되기 전의 상태를 담고 있습니다.

다음, 점을 그리기 전에 context.restoreGState() 을 삽입합니다.

context.restoreGState()

// Draw the line on top of the clipped gradient
graphPath.lineWidth = 1.5
graphPath.stroke()

// Draw the circles on top of the graph stroke
for i in 0..<graphPoints.count {
  var point = CGPoint(x: columnXPoint(i), y: columnYPoint(graphPoints[i]))
  point.x -= Constants.circleDiameter / 2
  point.y -= Constants.circleDiameter / 2

  let circle = UIBezierPath(
    ovalIn: CGRect(
      origin: point,
      size: CGSize(
        width: Constants.circleDiameter,
        height: Constants.circleDiameter)
    )
  )
  circle.fill()
}

앞서 저장된 상태에서는 컨텍스트가 클리핑 전의 상태였는데 이것을 스택에서 pop해서 컨텍스트의 상태 정보를 되돌려놓습니다.

그렇다면 현재 상태는 클리핑되기 전이므로 컨텍스트가 그릴 수 있는 범위는 clippingPath 가 아니라 rect 전체가 됩니다.

따라서 점이 잘리지 않고 다음과 같이 온전하게 그려지는 것을 알 수 있습니다.

 

위의 사례 예외도 이 상태 스택을 응용하면 복잡한 그리기가 쉽게 될 것입니다. 다만, 상태가 context 내의 모든 정보를 담고 있는 것은 아닙니다. 애플 공식문서에 따르면, 클리핑되는 상태 정보는 다음 목록으로 한정됩니다.

  • CTM (current transformation matrix)
  • clip region
  • image interpolation quality
  • line width
  • line join
  • miter limit
  • line cap
  • line dash
  • flatness
  • should anti-alias
  • rendering intent
  • fill color space
  • stroke color space
  • fill color
  • stroke color
  • alpha value
  • font
  • font size
  • character spacing
  • text drawing mode
  • shadow parameters
  • the pattern phase
  • the font smoothing parameter
  • blend mode

여기에 따르면 패스(path) 자체는 그래픽 상태에 포함되지 않는 것을 알 수 있습니다. 앞서 예제에서 컨텍스트가 그릴 수 있는 영역은 그래픽 상태이지만 패스 자체는 contextaddPath를 했다고 해도 패스 저장되지 않았다는 것을 알 수 있습니다. 따라서 path는 변수로 관리해야 합니다.

출처 – https://www.raywenderlich.com/10946920-core-graphics-tutorial-gradients-and-contexts


문의 | 코멘트 또는 yoonbumtae@gmail.com  donaricano-btn

카테고리: Swift

답글 남기기

이메일 주소를 발행하지 않을 것입니다. 필수 항목은 *(으)로 표시합니다