UIBezierPath를 이용해 Squircle(스쿼클) 이미지 뷰를 만드는 방법
'요즘' 유행한다고 이야기 하기엔 조금 시간이 지났지만 Squircle(스쿼클)을 이용해 프로필 이미지를 보여주는 것이 트렌드입니다. Squircle은 Square(정사각형)과 Circle(원)의 합성어로 문자 그대로 정사각형과 원 중간의 어딘가 느낌의 도형입니다.
국내에서는 카카오톡에서 프로필 이미지를 보여줄 때 사용하고 있어 용어를 처음 듣더라도 형태는 익숙할 것이고, 해외에서는 올해 초 반짝 했던 클럽하우스에서 사용하고 있습니다.
현재 제가 참여하고 있는 프로젝트(디자인시스템 구축)에서도 스쿼클을 프로필에 사용하기로 했기 때문에 이 모양을 iOS 어플에서도 구현해야 합니다. 포토샵이나 일러스트레이터, 피그마에서 벡터를 만져본 적은 있어도 코드로 벡터를 만져야 한다고 하니 눈 앞이 캄캄해집니다. 누가 이런 디자인을 채택했냐고 탓하기엔 제가 이번 프로젝트에서 디자인도 맡았기 때문에 탓할 사람도 없습니다.
저는 프로필 이미지를 위한 뷰를 만들어야 하므로 UIImageView를 상속받은 ProfileImageView를 만들었습니다. 이어서 해야할 스텝은 아래와 같습니다.
1. UIBezierPath를 이용해 스쿼클 모양의 패스 만들기
2. 이미지 뷰를 마스킹할 CAShapeLayer를 만들고 (1)번에서 만든 path를 마스킹 레이어의 path로 설정하기
3. 이미지 뷰의 layer의 마스킹 레이어를 (2)번에서 만든 마스킹 레이어로 설정하기
1. UIBezierPath를 이용해 스쿼클 모양의 패스 만들기
가장 어려운 단계입니다. 여기서는 쉽게 가는 방법과 어렵게 가는 방법이 있는데 우선 쉽게 가는 방법 먼저 소개하겠습니다.
1-1. 쉬운 방법: SVG를 UIBezierPath로 convert 해주는 사이트(링크) 이용하기
UIBezierPath의 메소드를 이용해 일일이 벡터 작업을 하기엔 너무 노가다이기 때문에 미리 스쿼클을 svg로 출력한 후 위 사이트를 이용해 변환하는 방법이 제일 간단합니다.
해당 svg 파일을 텍스트편집기에서 열어주시고
사진 속에 커서로 강조된 <path d="OOOOOOOO"> 부분을 복사해주세요.
그리고 swiftvg 에 들어가셔서 위쪽에 해당 코드를 붙여넣으면 아래쪽에 그에 맞는 UIBezierPath 코드가 생성됩니다.
생성된 코드를 복사해서 붙여넣고 줄 바꿈을 해주면 이렇게 'shape' 라는 이름으로 UIBezierPath 객체가 생성됩니다.
1-2. 어려운 방법: UIBezierPath를 이용해 직접 그리기
사실 저는 위 방법이 있는 줄 모르고 새벽 다섯 시까지 UIBezierPath 관련 문서를 찾아보며 이 방법으로 스쿼클을 완성했습니다.
우선 베지어 곡선에 대해 알아야 하지만 그걸 일일이 설명하는 것은 섹시하지 않기 때문에, 그리고 당장 필요한 스쿼클을 만드는 일이 제일 중요하기 때문에 필요한 부분만 짚고 넘어갑니다.
cubic-bezier 사이트에서 직접 베지어 곡선을 쉽게 그려볼 수 있습니다. 하얀 두 점은 출발점과 끝점이고, 분홍색 점과 푸른색 점은 이를 보조하는 control point입니다. 백문이 불여일견이니 직접 만져보며 본인이 만들어야 하는 곡선을 확인해보는 것이 좋습니다. 제가 그려야 하는 곡선은 사진에 첨부한 모양입니다.
다음으로 애플 문서의 베지어 커브에 관해 소개하는 이미지를 봅니다. 스쿼클을 만들기 위해 봐야하는 부분은 왼쪽인데, start point와 end point가 존재하고 이를 보조하는 control point가 두 개 있습니다. 바로 위의 cubic-bezier 사이트에서 확인한 내용과 동일합니다.
제가 그려야 하는 스쿼클의 벡터 구조는 위 사진과 같습니다. 총 4곳에 포인트가 있고, 각 고정 포인트를 4개의 곡선으로 잇습니다. 위에 있는 포인트에서 오른쪽 포인트로, 오른쪽 포인트에서 아래 포인트로, 아래 포인트에서 왼쪽 포인트로, 왼쪽 포인트에서 다시 위쪽 포인트로 총 4개가 필요합니다. 그리고 각 곡선마다 곡률을 표현하기 위한 컨트롤 포인트가 2개씩 존재합니다.
cubic-bezier 사이트에서 만든 곡선이 90도씩 회전해서 총 4개가 있는 모양입니다. 이제 코드로 넘어가보겠습니다.
private func makeSquirclePath(_ width: CGFloat, insetRatio: CGFloat) -> UIBezierPath {
let radius = width/2
if insetRatio > 1 || insetRatio < 0 {
assertionFailure("""
makeSquirclePath()
insetRatio 값은 0에서 1 사이로 넣어주세요.
""")
}
let topPoint = CGPoint(x: radius, y: 0)
let rightPoint = CGPoint(x: radius*2, y: radius)
let bottomPoint = CGPoint(x: radius, y: radius*2)
let leftPoint = CGPoint(x: 0, y: radius)
let inset = radius*insetRatio
let topLeftControlPoint = CGPoint(x: inset, y: 0)
let topRightControlPoint = CGPoint(x: radius*2-inset, y: 0)
let rightTopControlPoint = CGPoint(x: radius*2, y: inset)
let rightBottomControlPoint = CGPoint(x: radius*2, y: radius*2-inset)
let bottomRightControlPoint = CGPoint(x: radius*2-inset, y: radius*2)
let bottomLeftControlPoint = CGPoint(x: inset, y: radius*2)
let leftBottomControlPoint = CGPoint(x: 0, y: radius*2-inset)
let leftTopControlPoint = CGPoint(x: 0, y: inset)
let path = UIBezierPath()
path.move(to: topPoint)
path.addCurve(
to: rightPoint,
controlPoint1: topRightControlPoint,
controlPoint2: rightTopControlPoint
)
path.addCurve(
to: bottomPoint,
controlPoint1: rightBottomControlPoint,t
controlPoint2: bottomRightControlPoint
)
path.addCurve(
to: leftPoint,
controlPoint1: bottomLeftControlPoint,
controlPoint2: leftBottomControlPoint
)
path.addCurve(
to: topPoint,
controlPoint1: leftTopControlPoint,
controlPoint2: topLeftControlPoint
)
path.close()
return path
}
앞서 설명한 원리를 이용해 스쿼클 패스를 만들어주는 함수를 작성했습니다. 한 부분씩 살펴보겠습니다.
private func makeSquirclePath(_ width: CGFloat, insetRatio: CGFloat) -> UIBezierPath {
let radius = width/2
if insetRatio > 1 || insetRatio < 0 {
assertionFailure("""
makeSquirclePath()
insetRatio 값은 0에서 1 사이로 넣어주세요.
""")
}
우선 스쿼클의 width(너비)와 insetRatio를 받았습니다. width는 말 그대로 너비입니다. 계산 편의를 위해 2를 나눠 radius(반지름)으로 다루겠습니다.
insetRatio는 앞서 설명한 control point의 위치에 관한 값입니다. 변수 명을 조금 잘못 지은 것 같기도 한데 마땅히 좋은 이름이 생각나지 않아 이렇게 지었습니다.
위의 사진은 오늘 그려야 할 path를 확대한 사진입니다. control point가 전체 반지름의 80% 수준에 위치해있고, 좌측엔 20%가 남아있습니다. insetRatio에는 여기서 20%에 해당하는 '0.2'를 넣어주면 됩니다.
이렇게까지 하지 않아도 되지만 이왕 한 번 짜둔 코드인 만큼 앞으로 재사용이 용이하도록 이 부분까지도 매개변수로 받았습니다.
let topPoint = CGPoint(x: radius, y: 0)
let rightPoint = CGPoint(x: radius*2, y: radius)
let bottomPoint = CGPoint(x: radius, y: radius*2)
let leftPoint = CGPoint(x: 0, y: radius)
let inset = radius*insetRatio
let topLeftControlPoint = CGPoint(x: inset, y: 0)
let topRightControlPoint = CGPoint(x: radius*2-inset, y: 0)
let rightTopControlPoint = CGPoint(x: radius*2, y: inset)
let rightBottomControlPoint = CGPoint(x: radius*2, y: radius*2-inset)
let bottomRightControlPoint = CGPoint(x: radius*2-inset, y: radius*2)
let bottomLeftControlPoint = CGPoint(x: inset, y: radius*2)
let leftBottomControlPoint = CGPoint(x: 0, y: radius*2-inset)
let leftTopControlPoint = CGPoint(x: 0, y: inset)
이어서 4개의 point와 8개의 control point 위치를 잡아줬습니다. 이 부분은 디자인 시안으로 넘어온 path나 앞서 소개드린 cubic-bezier 사이트를 보시면 이해가 될 것 같습니다.
let path = UIBezierPath()
path.move(to: topPoint)
path.addCurve(
to: rightPoint,
controlPoint1: topRightControlPoint,
controlPoint2: rightTopControlPoint
)
path.addCurve(
to: bottomPoint,
controlPoint1: rightBottomControlPoint,
controlPoint2: bottomRightControlPoint
)
path.addCurve(
to: leftPoint,
controlPoint1: bottomLeftControlPoint,
controlPoint2: leftBottomControlPoint
)
path.addCurve(
to: topPoint,
controlPoint1: leftTopControlPoint,
controlPoint2: topLeftControlPoint
)
path.close()
return path
}
점의 좌표를 다 찍어줬으니 이제 path를 그릴 차례입니다. 우선 path 라는 이름으로 UIBezierPath 객체를 생성시켜줍니다. 그리고 move 메소드를 이용해 path 그리기를 시작할 점으로 이동해줍니다. 이어 addCurve 메소드를 이용해 총 4개의 패스를 그려줍니다.
여기서 애플의 문서 속 이미지를 다시 한 번 보고 가겠습니다. start point는 현재 path가 위치해있는 점입니다. 제일 처음 path의 경우엔 move 메소드를 이용해 topPoint로 이동했기 때문에 start point는 topPoint가 됩니다. addCurve의 매개변수로 넘겨주는 to에는 위 사진의 EndPoint가 들어가면 되고, controlPoint1, 2는 사진에 보이는 것과 동일하게 넣어주면 됩니다.
4개의 곡선을 다 그렸다면 다시 시작점으로 돌아갔을태니 close() 메소드를 호출해 path를 닫아주고 마지막으로 path를 return 해줍니다.
사실 UIBezierPath를 그리는 일은 코드로 이해하기 보단 우선 포토샵이나 일러스트레이터에서 보이는 path를 직접 그려보아야 이해가 빠릅니다. 그게 힘들다면 이 글에서 계속해서 참고하고 있는 cubic-bezier 사이트에서 곡선을 직접 만져보는 것이 그나마 도움이 될 것 같습니다.
2. 이미지 뷰를 마스킹할 CAShapeLayer를 만들고 (1)번에서 만든 path를 마스킹 레이어의 path로 설정하기
3. 이미지 뷰의 layer의 마스킹 레이어를 (2)번에서 만든 마스킹 레이어로 설정하기
// setMaskLayer()
// mask layer를 만들고 적용시킵니다.
private func setMaskLayer(path: UIBezierPath) {
let maskLayer = CAShapeLayer()
maskLayer.frame = self.bounds
maskLayer.path = path.cgPath
self.layer.mask = maskLayer
}
우선 maskLayer 역할을 할 CAShapeLayer 객체를 만들어줍니다. 그리고 frame을 설정해준 후 path에는 (1)번 단계에서 만든 UIBezierPath 객체를 할당해줍니다.
마지막으로 self(이 경우엔 UIImageView)의 layer의 mask를 방금 만든 maskLayer로 설정해줍니다. 이렇게 하면 우리가 만든 UIBezierPath의 안쪽 영역, 즉 스쿼클 영역을 따라서 이미지가 보이게 됩니다.
4. Border를 설정해보자
UIImageView(아마 UIView를 상속받는 객체는 모두 동일하겠지요)의 layer에 마스킹 레이어를 적용시켜 스쿼클 모양을 만들었기 때문에 일반적인 방법으로는 border를 설정할 수 없습니다. 평소 하듯이 self.layer.borderWidth 나 self.layer.borderColor 를 이용해 border를 만든다면 스쿼클 모양과 다른 border가 설정되는 것을 볼 수 있습니다.
이 부분은 border만을 그리는 CAShapeLayer 객체를 하나 생성한 후 addSublayer 함수를 이용해 UIImageView에 추가하는 방식으로 해결할 수 있습니다.
// setBorderLayer()
// border layer를 만들고 적용시킵니다.
// 앞서 생성한 스쿼클 모양 path를 매개변수로 넣어주세요.
private func setBorderLayer(path: UIBezierPath) {
let newBorderLayer = CAShapeLayer()
newBorderLayer.frame = self.bounds
newBorderLayer.path = path.cgPath
newBorderLayer.fillColor = UIColor.clear.cgColor
// 여기에 borderWidth를 넣어주세요.
newBorderLayer.lineWidth = Constant.Border.normal
// 여기에 borderColor를 넣어주세요.
newBorderLayer.strokeColor = YDSColor.borderNormal.cgColor
if let oldBorderLayer = borderLayer {
self.layer.replaceSublayer(oldBorderLayer, with: newBorderLayer)
} else {
self.layer.addSublayer(newBorderLayer)
}
borderLayer = newBorderLayer
}
// borderLayer: CALayer?
// 현재 상태의 borderLayer를 저장합니다.
// size가 바뀜에 따라 새 borderLayer가 필요해지면
// replaceSublayer() 함수에서
// 이 변수에 저장된 주소를 이용해
// 기존의 borderLayer를 새 borderLayer로 바꿔줍니다.
private var borderLayer: CALayer?
(1)단계에서 만든 스쿼클 모양 path를 매개변수로 넣어줍니다. 그 다음 (2)단계에서 한 것처럼 CAShapeLayer 객체를 생성해주고, frame과 path를 설정해줍니다. 이어서 fillColor는 UIColor.clear.cgColor로 설정해줍니다. Border만을 그리는 layer를 만드는 것이 목적이기 때문에 내부는 비워주는 겁니다. 그리고 lineWidth에 borderWidth를, strokeColor에 borderColor를 넣어줍니다.
마지막으로 self.layer.addSublayer(newBorderLayer)를 해주면 되는데, 저의 경우엔 프로필 이미지의 크기가 계속해서 바뀌기 때문에 언제든 border layer를 갈아끼울 수 있도록 replaceSublayer를 함께 사용했습니다.
스쿼클을 이용한 UIImageView를 만들고 테스트 어플에서 확인해보니 의도대로 잘 나타났습니다. 프로젝트 상에서 추가로 size라는 옵션도 있어 이 부분도 함께 구현해줬습니다.
이번 프로젝트에서 쓰인 스쿼클 모양 UIImageView의 전체 코드는 아래와 같습니다. 필요하신 분들은 각자 상황에 맞게 사용하시길 바랍니다!
import UIKit
public class YDSProfileImageView: UIImageView {
public var size: ProfileImageViewSize = .small {
didSet { setImageSize() }
}
public enum ProfileImageViewSize: Int {
case small = 36
case medium = 48
case large = 72
case extraLarge = 96
}
public init() {
super.init(frame: CGRect.zero)
setupViews()
setImageSize()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupViews() {
self.contentMode = .scaleAspectFill
}
private func setImageSize() {
self.snp.updateConstraints {
$0.height.width.equalTo(size.rawValue)
}
setSquirclePathAccordingToSize()
}
// setSquirclePathAccordingToSize()
// size에 따른 squircle path를 만들고
// 마스킹과 테두리를 적용시킵니다.
private func setSquirclePathAccordingToSize() {
let path = makeSquirclePath(CGFloat(size.rawValue), insetRatio: 0.2)
setMaskLayer(path: path)
setBorderLayer(path: path)
}
// setMaskLayer()
// mask layer를 만들고 적용시킵니다.
private func setMaskLayer(path: UIBezierPath) {
let maskLayer = CAShapeLayer()
maskLayer.frame = self.bounds
maskLayer.path = path.cgPath
self.layer.mask = maskLayer
}
// setBorderLayer()
// border layer를 만들고 적용시킵니다.
private func setBorderLayer(path: UIBezierPath) {
let newBorderLayer = CAShapeLayer()
newBorderLayer.frame = self.bounds
newBorderLayer.path = path.cgPath
newBorderLayer.fillColor = UIColor.clear.cgColor
newBorderLayer.lineWidth = Constant.Border.normal
newBorderLayer.strokeColor = YDSColor.borderNormal.cgColor
if let oldBorderLayer = borderLayer {
self.layer.replaceSublayer(oldBorderLayer, with: newBorderLayer)
} else {
self.layer.addSublayer(newBorderLayer)
}
borderLayer = newBorderLayer
}
// borderLayer: CALayer?
// 현재 상태의 borderLayer를 저장합니다.
// size가 바뀜에 따라 새 borderLayer가 필요해지면
// replaceSublayer() 함수에서
// 이 변수에 저장된 주소를 이용해
// 기존의 borderLayer를 새 borderLayer로 바꿔줍니다.
private var borderLayer: CALayer?
// makeSquirclePath()
// width는 스쿼클의 너비, insetRatio는 곡률(0에서 1 사이로 넣어주세요)입니다.
// 디자인 요구 사안에 맞는 UIBezierPath를 return합니다.
private func makeSquirclePath(_ width: CGFloat, insetRatio: CGFloat) -> UIBezierPath {
let radius = width/2
if insetRatio > 1 || insetRatio < 0 {
print("""
assertionFailure()
insetRatio 값은 0에서 1 사이로 넣어주세요.
""")
}
let topPoint = CGPoint(x: radius, y: 0)
let rightPoint = CGPoint(x: radius*2, y: radius)
let bottomPoint = CGPoint(x: radius, y: radius*2)
let leftPoint = CGPoint(x: 0, y: radius)
let inset = radius*insetRatio
let topLeftControlPoint = CGPoint(x: inset, y: 0)
let topRightControlPoint = CGPoint(x: radius*2-inset, y: 0)
let rightTopControlPoint = CGPoint(x: radius*2, y: inset)
let rightBottomControlPoint = CGPoint(x: radius*2, y: radius*2-inset)
let bottomRightControlPoint = CGPoint(x: radius*2-inset, y: radius*2)
let bottomLeftControlPoint = CGPoint(x: inset, y: radius*2)
let leftBottomControlPoint = CGPoint(x: 0, y: radius*2-inset)
let leftTopControlPoint = CGPoint(x: 0, y: inset)
let path = UIBezierPath()
path.move(to: topPoint)
path.addCurve(
to: rightPoint,
controlPoint1: topRightControlPoint,
controlPoint2: rightTopControlPoint
)
path.addCurve(
to: bottomPoint,
controlPoint1: rightBottomControlPoint,
controlPoint2: bottomRightControlPoint
)
path.addCurve(
to: leftPoint,
controlPoint1: bottomLeftControlPoint,
controlPoint2: leftBottomControlPoint
)
path.addCurve(
to: topPoint,
controlPoint1: leftTopControlPoint,
controlPoint2: topLeftControlPoint
)
path.close()
return path
}
}