안녕하세요!
이번에는 UIBezierPath(베지에 경로)를 이용해서 원을 그려보려고 합니다.
저번 글에서는 UIBezierPath를 사용해서 직선을 긋는거까지 성공했는데요. (링크)
이 경로를 응용해서 뷰의 프레임 크기에 맞는 원으로 만들겁니다.
// 동그란 원으로 만들기 위해(프레임의 높이와 너비 중 작은 값으로 통일)
let diameter = min(frame.width, frame.height)
// 베지에 경로
let circularPath = UIBezierPath(arcCenter: CGPoint(x: frame.width / 2, y: frame.height / 2),
radius: diameter / 2 - 5,
startAngle: -CGFloat.pi / 2,
endAngle: (-CGFloat.pi / 2) + (2 * CGFloat.pi),
clockwise: true)
코드가 복잡해보이는데요
하나씩 짚어보면 그렇게 어렵지는 않습니다.
- diameter
주석에 적힌대로 값을 통일하기 위해서 넣은 값입니다.
- arcCenter
원의 중심을 지정하는 CGPoint 값입니다.
파라미터로 CGPoint x값과 y값을 받네요.
x값과 y값은 프레임의 높이와 너비를 2로 나눈값입니다.
그러니까 프레임의 가운데가 원의 중심이 될겁니다.
만약에 높이가 600이고 너비가 400인 직사각형의 경우
가운데 좌표는 x: 200, y: 300이겠네요!!
- radius
원의 반지름을 나타냅니다.
코드에서 값이 diameter / 2 - 5라고 되어있는데요.
diameter가 프레임의 너비나 높이니까반을 나누면 반지름이 되는건 그렇다치고 왜 -5를 할까요?
전능하신 GPT한테 물어보니
GPT:
원의 반지름을 설정할 때 지름의 절반에서 조금 작은 값을 선택한 이유는 원이 그려지는 영역을 조정하기 위해서입니다.
일반적으로 원을 그릴 때는 원의 경계가 뷰의 경계와 겹치지 않도록 약간의 여백이 필요합니다. 따라서 원의 반지름을 지름의 절반에서 작은 값으로 설정하여 원이 뷰의 경계 안쪽에 딱 맞도록 조정합니다.
흠 여백이 필요하다네요?
그럼 5를 빼지말고 여백을 없애면 어떻게 되나 볼까요?
네 저 빨간 네모가 프레임의 영역인데 여백을 주지않고 딱 맞게 반지름을 설정하면
저렇게 원이 프레임을 뚫고 나옵니다.
그래서 적절한 여백을 줘야하는군요.
- startAngle
- endAngle
원의 시작 각도와 끝 각도를 라디안 단위로 나타냅니다.
타이머가 어디서 시작해서 어디서 끝나는지 정해야겠죠?
아래 그림은 라디안 각도로 원의 네방향을 표시한건데
2pi 라디안이 한바퀴라고 합니다.
보통 타이머가 그렇듯 위에서 시작해서
다시 시작한 부분으로 돌아오도록 하려고 합니다.
그럼 시작 각도는 CGFloat.pi / 2이어야 할텐데
startAngle: -CGFloat.pi / 2 로 음수가 되어있죠?
왜 그런가 궁금해서 또 물어봤습니다.
GPT:
startAngle: -CGFloat.pi / 2에서 음수를 사용하는 이유는 기본적으로 좌표 평면에서 각도의 기준이 다릅니다.
보통 우리가 생각하는 좌표 평면의 각도는 반시계 방향으로 양수이고, 시계 방향으로 음수입니다. 하지만 iOS 또는 macOS의 그래픽 시스템에서는 일반적인 수학적인 좌표 평면과는 반대로, 시계 방향이 양수이고 반시계 방향이 음수입니다.
그래서 원을 그릴 때, 보통 우리가 상단에서 시작해서 시계 방향으로 그리려면 음수 각도를 사용해야 합니다. -CGFloat.pi / 2는 수학적인 시계 방향으로 90도를 의미합니다. 이것이 왜 음수를 사용하는지에 대한 이유입니다.
...
정확히는 모르겠는데 iOS에서는 -를 붙여줘야 위의 그림과 똑같은
방향으로 설정되는것 같습니다.
그리고 이제 endAngle을 정해줘야 하는데
단순하게 시작지점으로 돌아온다고 -CGFloat.pi / 2를 똑같이 넣으면...
한바퀴를 돌고 돌아오는게 아니라 원의 길이가 0이 되어버립니다.
그래서 endAngle은 (-CGFloat.pi / 2 = 시작지점) + (2 * CGFloat.pi = 한바퀴)
를 해주면 한바퀴를 돌고 돌아옵니다!
- clockwise
진행방향이 시계방향인지 결정합니다.
자 여기까지 베지에 경로로 원을 그리는 방법을 알아봤습니다!.....가 아니라
경로만 설정하면 실제로 화면에는 아무것도 보이지 않습니다.
이제 경로에 보이는 부분을 위해 변수를 두개 선언해줄겁니다.
설정한 경로에 레이어 추가
// 진행상황 레이어
private var progressLayer = CAShapeLayer()
// 경로 레이어
private var trackLayer = CAShapeLayer()
// 레이어의 경로 설정
trackLayer.path = circularPath.cgPath
// 레이어 색상
trackLayer.strokeColor = UIColor.lightGray.cgColor
// 레이어 넓이
trackLayer.lineWidth = 10
// 레이어 안을 채우는 색상
trackLayer.fillColor = UIColor.clear.cgColor
// 라인 끝부분 모양?
trackLayer.lineCap = .round
// 레이어의 경로 설정
progressLayer.path = circularPath.cgPath
// 레이어 색상
progressLayer.strokeColor = UIColor.blue.cgColor
// 레이어 넓이
progressLayer.lineWidth = 10
// 레이어 안을 채우는 색상
progressLayer.fillColor = UIColor.clear.cgColor
// 라인 끝부분 모양?
progressLayer.lineCap = .round
// 채워진 정도?
progressLayer.strokeEnd = 0
layer.addSublayer(trackLayer)
layer.addSublayer(progressLayer)
trackLayer는 설정한 경로의 원 모양으로 나타날거고
progressLayer는 채워지면서 얼만큼 진행이 되었는가를 나타낼겁니다.
두 변수는 CALayer타입인데
대충 베지에 곡선의 좌표를 따라 그려주는 선 같은 겁니다.
나머지 부분은 주석대로인데
lineCap설정은 선의 끝부분?에 대한 설정으로 아래의 예시를 참고하시면 되겠습니다.
그리고 마지막으로 뷰에 기본으로 존재하는 layer에
.addSublayer를 해줌으로써 UIBezierPath와 관련된 모든 설정이 끝났습니다.
타이머처럼 애니메이션 처리
func setProgress(timeValue: Double, animated: Bool) {
if animated {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.toValue = 1
animation.duration = timeValue // Animation duration in seconds
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
progressLayer.add(animation, forKey: "progressAnimation")
} else {
progressLayer.strokeEnd = 1
}
}
CABasicAnimation이라는걸 이용해서 아래의 설정을 가진 애니메이션을 만듭니다.
toValue:진행도 끝의 값 1
duration: 파라미터로 받아온 timeValue 시간만큼 진행
timingFunction: CAMediaTimingFunctionName.linear로 일정한 속도로 진행되도록 설정
이제 이걸 progressLayer에 .add합니다.
이제 이 커스텀 뷰의 setProgress(timeValue: Double, animated: Bool) 함수의
파라미터 timeValue를 외부에서 지정해준만큼 타이머가 원형으로 돌아갑니다.
그럼 마지막으로 전체 코드를 확인해볼까요?
아래의 코드를 복사하셔서 오토 레이아웃을 지정해주시기만 하면 바로 사용 가능합니다!
//
// CircularProgressView.swift
//
// Created by Kang on 2/9/24.
//
import UIKit
class CircularProgressView: UIView {
// 진행상황 레이어
private var progressLayer = CAShapeLayer()
// 경로 레이어
private var trackLayer = CAShapeLayer()
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
setupLayers()
}
// 레이어 설정
private func setupLayers() {
// 동그란 원으로 만들기 위해(높이와 너비 중 작은 값으로 통일)
let diameter = min(frame.width, frame.height)
// 베지에 경로 설정
let circularPath = UIBezierPath(arcCenter: CGPoint(x: frame.width / 2, y: frame.height / 2),
radius: diameter / 2 - 5,
startAngle: -CGFloat.pi / 2,
endAngle: (-CGFloat.pi / 2) + (2 * CGFloat.pi),
clockwise: true)
trackLayer.path = circularPath.cgPath
trackLayer.strokeColor = UIColor.lightGray.cgColor
trackLayer.lineWidth = 10
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.lineCap = .round
progressLayer.path = circularPath.cgPath
progressLayer.strokeColor = UIColor.blue.cgColor
progressLayer.lineWidth = 10
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.lineCap = .butt
progressLayer.strokeEnd = 0
layer.addSublayer(trackLayer)
layer.addSublayer(progressLayer)
}
// start timer
func setProgress(timeValue: Double, animated: Bool) {
if animated {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.toValue = 1
animation.duration = timeValue // Animation duration in seconds
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
progressLayer.add(animation, forKey: "progressAnimation")
} else {
progressLayer.strokeEnd = 1 }
}
}
추가로 왜 setupLayers() 함수를 layoutSubviews() 시점에
동작되게 하였냐면 저 setupLayers() 함수를 init()에서 호출하면
저 타이머 작은거 보이세요?
타이머의 높낮이를 프레임을 기준으로 잡았는데
외부에서 오토 레이아웃으로 크기를 정해주니
init()시점에서는 프레임이 잡히지 않나봅니다.
그래서 타이머는 프레임이 잡히기전에 크기가 정해지고
이후에 뷰의 프레임이 정해져서 저렇게
뷰의 프레임은 알맞게 나오지만 타이머의 크기가 이상한거죠.
저는 이제 저 타이머를 멈춤 기능을 넣고
또 다른 타이머와 연동하는? 작업을 해야하네요
하다가 정리할만한 내용이다 싶으면 또 글을 써보겠습니다.
긴 글 읽어주셔서 감사합니다~