프로젝트를 진행하면서 말풍선을 구현해야 할 일이 생겨, 말풍선을 구현하고 그 과정을 게시물에 담으려고 합니다.
일반적으로 말풍선은 다음과 같은 요소로 구성됩니다.
- 말풍선 배경 및 외곽선
- 말풍선 내부 텍스트
- 말풍선 꼬리의 배경 및 외곽선
새로운 프로젝트를 생성한 후, 위 세 가지 요소를 고려하여 말풍선을 디자인하고, 사용자가 말풍선을 커스터마이징할 수 있도록 뷰(View)를 만들어보겠습니다.
1. 말풍선 UI 만들기
1.1 말풍선 배경 및 외곽선 만들기 + 말풍선에 들어가는 텍스트 추가 ( 말풍선 몸통 만들기 )
modifier를 사용하여 HStack 내부에 배경색을 추가하고, 외곽선을 적용했습니다.
또한, Color를 HEX 코드로 사용하기 위해 별도의 extension을 추가하여 활용했습니다.
HStack(content: {
Text("안녕하세요 반갑습니다").font(.system(size: 16).weight(.semibold))
})
.padding(.horizontal, 15)
.padding(.vertical, 15)
.background(Color(hex: "FEFFE3"))
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(Color(hex: "EA8B00"), lineWidth: 5)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
1.2 꼬리 만들기
말풍선의 꼬리를 만들기 위해서는 삼각형의 배경을 그리는 함수와 삼각형의 외곽선을 그리는 함수가 필요합니다.
1.2.1 삼각형의 배경을 그리는 함수
먼저, 삼각형을 구성하는 세 개의 꼭짓점을 정의해야 합니다.
- p1: x 좌표는 rect.width의 절반, y 좌표는 0으로 설정하여 삼각형의 꼭짓점이 맨 위 중앙에 위치하도록 합니다.
- p2: x 좌표는 rect.width의 1/3 지점, y 좌표는 rect.height로 설정하여 p1보다 왼쪽이면서 아래쪽에 위치하도록 합니다.
- p3: x 좌표는 rect.width, y 좌표는 0으로 설정하여 p1과 같은 높이에서 오른쪽 끝에 위치하도록 합니다.
이 세 개의 점을 연결하면 말풍선 꼬리처럼 보이는 삼각형을 만들 수 있습니다.
// MARK: - CustomTriangleShape ( 말풍선의 꼬리를 그리기 위한 커스텀 삼각형 )
struct CustomTriangleShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.width
let height = rect.height
// 삼각형의 꼭짓점 정의
let p1 = CGPoint(x: width / 2, y: 0) // 위쪽 꼭짓점
let p2 = CGPoint(x: width / 3, y: height) // 왼쪽 아래
let p3 = CGPoint(x: width, y: 0) // 오른쪽 아래
path.move(to: p1)
path.addLine(to: p2)
path.addLine(to: p3)
path.closeSubpath()
return path
}
}
오른쪽의 코드처럼 호출하면, 왼쪽과 같은 방식으로 꼬리 배경이 그려집니다.
1.2.2 삼각형의 외곽선을 그리는 함수
이제 삼각형의 외곽선을 그리는 함수를 만들어보겠습니다.
앞서 만든 배경 그리기 함수와 별도로 외곽선 전용 함수를 만드는 이유는, p1과 p3, 즉 삼각형의 맨 위 부분에서는 외곽선을 제거해야 하기 때문입니다.
따라서, 삼각형의 외곽선을 그리는 전용 함수를 구현해보겠습니다.
// MARK: - CustomTriangleEdgeShape( 말풍선 꼬리에 외곽선을 그리기 위함 삼각형의 윗면은 선을 그리지 않음 )
struct CustomTriangleEdgeShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.width
let height = rect.height
// 삼각형의 꼭짓점 정의
let p1 = CGPoint(x: width / 2, y: 0) // 위쪽 꼭짓점
let p2 = CGPoint(x: width / 3, y: height) // 왼쪽 아래
let p3 = CGPoint(x: width, y: 0) // 오른쪽 아래
path.move(to: p1)
path.addLine(to: p2)
path.addLine(to: p3)
return path
}
}
이 함수에서 달라지는 점은 path.closeSubPath()를 사용하지 않는다는 것입니다.
이를 통해 p1과 p3가 이어지지 않도록 구현합니다. 즉, 삼각형의 윗면에는 선을 그리지 않게 됩니다.
오른쪽과 같은 방법으로 호출하면, 왼쪽처럼 꼬리 외곽선이 그려지게 됩니다. p1과 p3를 잇지 않았기 때문에 윗면이 뚫려 있는 것을 확인할 수 있습니다.
1.2.3 두 결과물을 합쳐서 꼬리 완성하기
위에서 만든 말풍선 몸통과 어울리는 색을 선택한 후, ZStack을 사용해 꼬리 배경과 외각선을 겹쳐서 완성해보겠습니다.
1.3 말풍선과 말풍선 꼬리 합치기
말풍선과 말풍선 꼬리를 하나의 VStack 안에 배치했습니다.
.frame(width: 300) modifier를 사용하여 말풍선의 크기를 300으로 고정시켰고,
텍스트가 포함된 HStack 안에 Spacer()를 양옆에 배치하여 텍스트가 가운데 정렬되도록 하고, 빈 공간을 채우도록 설정했습니다.
현재 두 개의 요소가 맞붙어 있지만, 우리가 원하는 말풍선을 만들기 위해서는 꼬리가 조금 더 올라가야 합니다.
이를 통해 꼬리의 윗변이 없어지도록 할 수 있습니다.
.offset을 사용하여 꼬리 부분을 올려줍니다.
말풍선 꼬리를 x좌표는 35, y좌표는 -4만큼 움직여 위치시켰습니다.
현재 말풍선 꼬리가 잘 올라갔지만, 말풍선 꼬리가 말풍선 몸통을 침범하는 문제가 있습니다.
이 문제를 해결하기 위해 외곽선을 그리는 함수에서 p1과 p3의 y 좌표를 수정하여 꼬리를 조금 짧게 만듭니다.
p1과 p3의 y 좌표를 0에서 2로 수정하였더니, 말풍선 꼬리가 자연스럽게 바뀌었습니다.
텍스트가 길어져도 말풍선이 잘 늘어나는 것을 확인할 수 있습니다.
1.4 텍스트가 자동으로 넘어가는 문제 해결
텍스트가 말풍선의 너비를 초과할 경우 줄바꿈이 이루어져야 합니다. 그러나 기본적으로 SwiftUI에서는 공백을 기준으로 줄바꿈이 발생합니다. 이로 인해 긴 단어가 말풍선 너비를 초과할 경우 자동 줄바꿈이 되지 않고, 전체 단어가 한 줄 아래로 내려가는 문제가 발생합니다.
이를 해결하기 위해 각 문자 사이에 Zero-Width Space (\u{200B})를 삽입합니다. 이렇게 하면 개별 문자 단위로도 줄바꿈이 가능해져, 긴 단어가 말풍선 너비를 초과하더라도 자연스럽게 줄바꿈이 이루어집니다.
2. 커스터마이징 말풍선 View 만들기
위에서 만든 UI를 재활용하고, 사용자가 기호에 맞게 커스터마이즈할 수 있도록 SwiftUI View로 만들겠습니다.
우선 사용자가 커스터마이즈할 수 있는 요소를 생각해보았습니다.
- 말풍선에 들어갈 텍스트
- 말풍선의 배경색
- 말풍선의 외곽선 색
- 말풍선 꼬리의 너비
- 말풍선 꼬리의 높이
- 말풍선 꼬리의 시작 위치
위 6개를 바꿀수 있게 변수를 선언해줬습니다.
이후 init을 만들어 기본 값을 설정해주었습니다.
생성자에서 backgroundColor, borderColor, tailWidth, tailHeight, tailStartPosition에 ?(옵셔널)를 사용하여, 해당 값들이 입력되지 않을 수도 있음을 선언했습니다.
이렇게 하면 호출 시 특정 값을 생략할 수 있으며, 값이 nil일 경우에는 기본값이 자동으로 설정됩니다.
예를 들어, tailWidth가 nil이면 기본값으로 50이 사용되며, borderColor가 nil이면 기본값 .black이 적용됩니다.
text만 입력하면 기본적으로 말풍선이 생기도록 만들었고, 나머지 옵션은 사용자의 기호에 따라 설정할 수 있도록 구현했습니다.
/// 말풍선을 나타내는 뷰
/// 텍스트가 길어져도 문제없이 작동
///
/// - Parameters:
/// - text: 텍스트 내용
/// - backgroundColor: 배경색 ( 기본 값은 흰색 )
/// - borderColor: 테두리 색상 ( 기본 값은 검정색 )
/// - tailWidth: 말풍선 꼬리 너비 ( 기본 값은 50 )
/// - tailHeight: 말풍선 꼬리 높이 ( 기본 값은 25)
/// - tailStartPosition: 말풍선 시작 위치 ( 기본 값은 35 )
struct SpeechBubble: View {
let text: String // 말풍선에 들어갈 말
let backgroundColor: Color // 말풍선 배경색
let borderColor: Color // 말풍선 외곽선
let tailWidth: CGFloat // 말풍선 꼬리 너비
let tailHeight: CGFloat // 말풍선 꼬리 높이
let tailStartPosition: CGFloat // 말풍선 꼬리 시작 위치
init(text: String, backgroundColor: Color? = nil, borderColor: Color? = nil, tailWidth: CGFloat? = nil, tailHeight: CGFloat? = nil, tailStartPosition: CGFloat? = nil) {
self.text = text
self.backgroundColor = backgroundColor ?? .white
self.borderColor = borderColor ?? .black
self.tailWidth = tailWidth ?? 50
self.tailHeight = tailHeight ?? 25
self.tailStartPosition = tailStartPosition ?? 35
}
var body: some View {
VStack(spacing:0, content: {
// 말풍선
HStack(content: {
Spacer()
Text(insertZeroWidthSpace(text)).font(.system(size: 16).weight(.semibold))
Spacer()
})
.padding(.horizontal, 15)
.padding(.vertical, 15)
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(borderColor, lineWidth: 5)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
// 말풍선 꼬리
HStack(content: {
BubbleTail(backgroundColor: backgroundColor, borderColor: borderColor, tailWidth: tailWidth, tailHeight: tailHeight, tailStartPosition: tailStartPosition) // 말풍선 꼬리 부분
Spacer()
})
})
}
}
이후, 다른 값들을 전달하여 호출했습니다.
여러 스타일로 적용된 모습입니다.
더 바꾸고 싶은 부분이 있으면 아래 GitHub 코드에서 수정하여 사용하시면 됩니다.
GitHub - Leeyounhyoung/SpeechBubble: An app that implements a speech bubble working in SwiftUI.
An app that implements a speech bubble working in SwiftUI. - Leeyounhyoung/SpeechBubble
github.com
감사합니다.
'ios 개발일지' 카테고리의 다른 글
[SwiftUI, UIKit] 편지 구현하기: textEditor, UITextView, UIViewRepresentable, NSLayoutManager (0) | 2025.02.11 |
---|---|
[SwiftUI] CoreData를 사용하여 채팅창 구현하기: Update와 Delete (0) | 2024.07.20 |
[Swiftui] Core Data를 사용하여 채팅창 구현하기: Create와 Read (0) | 2024.07.08 |
[Xcode] 탭 간격, 들여쓰기 간격 변경 및 Re-Indent (0) | 2024.07.06 |
[Xcode] Simulator 이전 버전 추가 (0) | 2024.04.12 |