백준 티어 배지를 직접 구현해보기
백준 프로필의 IOS 버전을 개발하던 도중, 티어 배지를 Swift UI 의 Shape 프로토콜을 사용하는 커스텀 도형으로 직접 구현해보기로 하였다.
티어 배지는 위와 같이 생겼으며, 물론 어도비 XD 를 이용해 svg파일을 직접 추출해도 되지만 6가지의 티어 (브론즈, 실버, 골드, 플래티넘, 다이아, 루비) 색상과, 1~5단계에 해당하는 티어 단계로 인해 만약 위와 같은 방식으로 구현할 경우 총 30가지의 svg 파일을 추출해야 한다. Android 버전은 티어 단계를 텍스트 뷰로 구현해 총 6가지의 svg 파일만으로 구현을 완료했지만, IOS 버전에서는 직접 저 모양을 그려서 구현해보았다.
Swift UI Shape 프로토콜
Swift UI 에서 사각형, 원, 둥근 사각형 등은 각각 Rectangle, Circle, RoundedRectangle 등으로 구현되어 간단히 사용할 수 있다. 해당 도형은 모두 Shape 프로토콜을 따르는 구조체로 구현되있으며, Shape 프로토콜의 정의는 다음과 같다.
public protocol Shape : Animatable, View {
func path(in rect: CGRect) -> Path
}
Shape 프로토콜은 기본적으로 Path 를 반환하는 함수를 구현해야 하는데, 해당 함수는 CGRect 형태의 사각형을 받아 그 사각형에 그려질 것을 정의하는 Path 객체를 반환한다. 여기서 CGRect 형태의 사각형이 도형이 그려질 캔버스라고 생각하면 된다. 이 사각형은 우리가 View 의 frame 을 통해 지정해주는 사이즈를 가지고 있으며, 화면상의 좌표에 대한 정보를 가지고 있다.
Path 인스턴스는 포인트 간의 좌표를 지정하고 그려질 선을 정의하여 2차원 도형을 제공한다. 포인트 간의 직선은 직선, 3차 및 2차 베지어 곡선, 호, 타원, 그리고 사각형을 사용하여 그릴 수 있다.
아래 예제를 통해 직접 Shape 프로토콜을 따르는 사각형을 구현해보자. 우선 Shape 프로토콜을 구현하는 구조체를 선언해주자
struct CustomRectangle : Shape {
func path(in rect: CGRect) -> Path {
}
}
이제 Path 인스턴스를 생성해, 사각형을 그리게 작업하면 된다.
struct CustomRectangle : Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.closeSubpath()
return path
}
}
위 코드를 해석해보자
- move()
- 위 메소드를 사용하여 시작점의 좌표로 경로가 시작된다. 시작점의 좌표는 캔버스의 좌측 상단 좌표인, rect.minX, rect.minY 좌표로 지정된다.
- addLine(to: CGPoint)
- 직전 좌표에서 to 좌표까지 직선을 추가한다.
- closeSubpath()
- 끝점과 시작점을 연결하여 경로를 닫는다.
즉 먼저 캔버스의 좌측 상단으로 좌표를 지정한 다음, 캔버스의 좌측 하단으로 선을 추가하고, 그 다음 좌측 하단 -> 우측 하단으로 선을 추가하고, 우측 하단 -> 우측 상단으로 선을 추가한뒤 우측 상단 -> 좌측 상단을 연결하여 경로를 닫는 것이다.
위 방법을 사용해 간단히 사각형을 구현했다. 직접 사용해보자.
struct ContentView: View {
var body: some View {
CustomRectangle()
.frame(width: 50, height: 100, alignment: .leading)
}
}
아이폰 시뮬레이터에서 해당 사각형을 사용해보았다. frame 을 통해 넓이를 50, 높이를 100으로 지정해줬으며, 이것은 위에서 path 함수에 전해질 캔버스의 크기를 지정해준것이다. 따라서 rect 의 maxX 는 50이 되며, maxY는 100이 된다. 만약 넓이와 높이를 똑같이 50으로 지정해주면 정사각형이 그려질 것이다.
위 예제는 간단하게 직선만을 추가했지만, 곡선, 호 등도 추가할 수 있으며, 자세한 내용은 아래 사이트를 참고하자.
Apple Developer Documentation
developer.apple.com
티어 배지를 구현하기
티어 배지의 모양이 저렇기 때문에, 두 가지 모양을 만들어서 ZStack 으로 합치는 방법을 선택했다. 일단 첫 번째 모양은
위와 같고, 5개의 path 만 그려주면 되기 때문에 비교적 쉽게 구현할 수 있었다.
struct TierBadgeShape : Shape {
func path(in rect: CGRect) -> Path {
let path = Path { path in
let bottomOfRect : CGFloat = rect.width * 1.09 //직사각형의 바닥 좌표
let heightOfMask = rect.midX / 1.8 //직사각형 밑 삼각형의 높이
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: bottomOfRect))
path.addLine(to: CGPoint(x: rect.midX, y: bottomOfRect + heightOfMask))
path.addLine(to: CGPoint(x: rect.maxX, y: bottomOfRect))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.closeSubpath()
}
return path
}
}
그 다음 두 번째 모양인 V 자 모양을 만들기 위해 다음과 같이 구현했다.
struct TierBadgeMask : Shape {
func path(in rect: CGRect) -> Path {
let bottomOfRect : CGFloat = rect.width * 1.09 //직사각형의 바닥 좌표
let height = bottomOfRect * 0.1 //V 자 모양의 높이
let heightOfMask = rect.midX / 1.8 //직사각형 밑 삼각형의 높이
let startOfMaskY = bottomOfRect - (height * 2) //V자 모양 시작 Y좌표
var path = Path()
path.move(to: CGPoint(x: rect.minX, y:startOfMaskY ))
path.addLine(to: CGPoint(x: rect.minX, y: startOfMaskY + height))
path.addLine(to: CGPoint(x: rect.midX, y: startOfMaskY + heightOfMask + height))
path.addLine(to: CGPoint(x: rect.maxX, y: startOfMaskY+height))
path.addLine(to: CGPoint(x: rect.maxX, y: startOfMaskY))
path.addLine(to: CGPoint(x: rect.midX, y: startOfMaskY+heightOfMask))
path.closeSubpath()
return path
}
}
그 다음 위 두 가지 모양을 ZStack 으로 합치고, Text 뷰를 추가하여 손쉽게 티어 배지 모양을 구현할 수 있었다.
struct TierBadge: View {
let width : CGFloat
let tier: Int
var body: some View {
ZStack(alignment:.center) {
TierBadgeShape()
.frame(width:width,height: width)
.foregroundColor(Profile.getTierColor(tier: tier))
.shadow(radius:1.5)
TierBadgeMask()
.frame(width: width, height: width)
.foregroundColor(.white)
Text(String(Profile.getTierNumber(tier:tier)))
.fontWeight(.bold)
.font(.system(size: 100))
.minimumScaleFactor(0.0001)
.foregroundColor(.white)
.frame(width: width, height: width, alignment: .center)
}
}
}