안녕하세요, 린다입니다.
작년부터.. 기획했던 모공이들과의 프로젝트가 정말
올해부터 부스터를 달았어요.
기획도 정해졌고, 디자인도 절반정도 마무리 된것 같아요.
이번에 디자이너와 함께 프로젝트를 구현해보면서
디자인 시스템을 도입하면 좋겠다. 라고 생각이 들었어요.
그래서 일단은.. 시작해보자라고 생각을 하고 시작을 하고 있습니다.
이게 올바른 디자인 시스템인지 아닌지는.. 잘.. 모르겠으나 (처음이니까여..)
도입에 의의를 두며 시작을 했고, 발전시켜볼 예정입니다..!
앱 규모가 작은 편이라서 컴포넌트가 몇개밖에 없긴합니다만..
저희는 Button, TextField, TextEditor, TextBubble(말풍선), Color, Font, PopupView
등등 을 디자인 시스템으로 구현하기로 했어요.
(TextBubble이 빠져있슴니다..)
이렇게 폴더링을 해서 작업을 했구요..
제가 작성한 코드만 블로깅을 해보려고 가져왔습니다.
팝업은 2가지 종류가 있습니다.
로그아웃/탈퇴 와 같이 유저가 선택할 수 있는 경우의 수가 있는 팝업과
일반적으로 유저에게 정보를 노티하는 팝업 으로 나뉘었습니다.
그래서 doubleButton과 guide로 종류를 만들었고...
수정 !!
팝업은 3종류가 있어요.
버튼이 1개인 경우 2개인 경우, 2개에서 버튼 형태가 다른 경우
그래서 enum을 3개로 두고 수정하여 적용해놓았습니다.
취소/확인은 눌렀을때 동작하도록 confirmHandler와 cancelHandler를
추가하여 ViewModifier를 만들었습니다.
public enum PopupType {
/// 버튼이 2개인 팝뷰, 왼쪽메인 오른쪽 그레이5
case doubleButton(leftTitle: String, rightTitle: String)
/// 가이드라인 팝뷰 (버튼1개)
case guide(title: String)
/// 버튼이 2개인 팝뷰, 왼쪽 그레이5 오른쪽메인
case switchColorDoubleBtn(leftTitle: String, rightTitle: String)
}
struct PopupView: ViewModifier {
@Binding var isShowing: Bool
/// 팝업 타입
let type: PopupType
/// 팝업뷰 제목
@State var title: String
/// 팝업뷰 소제목
@State var boldDesc: String
/// 팝업뷰 내용
@State var desc: String
/// 팝업뷰 확인 버튼 함수
let confirmHandler: () -> Void
/// 팝업뷰 취소 버튼 함수
let cancelHandler: () -> Void
init(isShowing: Binding<Bool>,
type: PopupType,
title: String,
boldDesc: String,
desc: String,
confirmHandler: @escaping () -> Void,
cancelHandler: @escaping () -> Void) {
self._isShowing = isShowing
self.type = type
self.title = title
self.boldDesc = boldDesc
self.desc = desc
self.confirmHandler = confirmHandler
self.cancelHandler = cancelHandler
}
public func body(content: Content) -> some View {
content
.overlay {
ZStack {
Color.black
.opacity(0.3)
.ignoresSafeArea()
switch type {
case .doubleButton(_, _):
ZStack {
VStack {
CommonDoubleBtnView(title: $title, boldDesc: $boldDesc, desc: $desc)
bottomView
}
.customPopupModifier()
}
case .guide:
ZStack {
VStack(alignment: .center) {
Text(title)
.font(PretendardFont.h5Bold)
Spacer().frame(height: 15)
Text(desc)
.font(PretendardFont.smallMedium)
.lineSpacing(1.5)
.multilineTextAlignment(.center)
Spacer().frame(height: 32)
bottomView
}
.customPopupModifier()
}
case .switchColorDoubleBtn(_, _):
ZStack {
VStack {
CommonDoubleBtnView(title: $title, boldDesc: $boldDesc, desc: $desc)
bottomView
}
.customPopupModifier()
}
}
}
.opacity(isShowing ? 1 : 0)
.animation(.easeInOut, value: isShowing)
}
}
@ViewBuilder private var bottomView: some View {
switch type {
case .doubleButton(let leftTitle, let rightTitle):
multiButtonView(leftTitle: leftTitle, rightTitle: rightTitle)
case .guide(let title):
guideView(title: title)
case .switchColorDoubleBtn(let leftTitle, let rightTitle):
switchColorView(leftTitle: leftTitle, rightTitle: rightTitle)
}
}
func multiButtonView(leftTitle: String, rightTitle: String) -> some View {
HStack(spacing: 20) {
Text(leftTitle)
.padding(.vertical, 15)
.frame(maxWidth: .infinity)
.foregroundColor(Color.white)
.background(Color.main)
.clipShape(RoundedRectangle(cornerRadius: 16))
.onTapGesture {
confirmHandler()
}
Text(rightTitle)
.padding(.vertical, 15)
.frame(maxWidth: .infinity)
.foregroundColor(Color.symGray5)
.background(Color.symGray1)
.clipShape(RoundedRectangle(cornerRadius: 16))
.onTapGesture {
cancelHandler()
}
}
.font(PretendardFont.h4Bold)
.padding(.horizontal, 10)
}
func guideView(title: String) -> some View {
VStack {
Text(title)
.padding(.vertical, 11)
.frame(maxWidth: .infinity)
.background(Color.bright)
.font(PretendardFont.bodyBold)
.clipShape(RoundedRectangle(cornerRadius: 30))
.onTapGesture {
cancelHandler()
}
}
}
func switchColorView(leftTitle: String, rightTitle: String) -> some View {
HStack(spacing: 20) {
Text(leftTitle)
.padding(.vertical, 15)
.frame(maxWidth: .infinity)
.foregroundColor(Color.symGray5)
.background(Color.symGray1)
.clipShape(RoundedRectangle(cornerRadius: 16))
.onTapGesture {
confirmHandler()
}
Text(rightTitle)
.padding(.vertical, 15)
.frame(maxWidth: .infinity)
.foregroundColor(Color.white)
.background(Color.main)
.clipShape(RoundedRectangle(cornerRadius: 16))
.onTapGesture {
cancelHandler()
}
}
.font(PretendardFont.h4Bold)
.padding(.horizontal, 10)
}
}
public extension View {
func popup(isShowing: Binding<Bool>,
type: PopupType,
title: String,
boldDesc: String,
desc: String,
confirmHandler: @escaping () -> Void,
cancelHandler: @escaping () -> Void)
-> some View {
self.modifier(PopupView(isShowing: isShowing,
type: type,
title: title,
boldDesc: boldDesc,
desc: desc,
confirmHandler: confirmHandler,
cancelHandler: cancelHandler))
}
}
그래서 어떻게 사용하나면...
struct PopupDemo: View {
@State private var isShowingGuide = false
var body: some View {
VStack(spacing: 10) {
Button(action: {
self.isShowingGuide.toggle()
}, label: {
Text("가이드 라인 팝업 뷰 사용 방법")
})
}
/// 가이드 라인 팝업 뷰 사용 방법
.popup(isShowing: $isShowingGuide,
type: .guide(title: "알겠어요"),
title: "생각이 잘 떠오르지 않으세요?",
boldDesc: "",
desc: "잠시동안 눈을 감고 그때의 상황을 떠올려봐요. 거창하지 않은 작은 생각이라도 좋아요!",
confirmHandler: {
self.isShowingGuide.toggle()
print("확인")
},
cancelHandler: {
print("취소 버튼")
self.isShowingGuide.toggle()
})
}
}
Extension으로 구현해 놓았기 때문에
.을 통해서 쉽게 사용할 수 있습니다.
팝업뷰 말고 TextField와 TextEditor도 구현은 했는데용
(여기는 글자와 색상값이 저희 시스템것 말고 날것으로.. 들어가있어유)
우선은 TextField는.. default, error 2가지만 사용하고 있고..
현재 textfield가 뱉는 에러가 이거 하나밖에 .. 없어서
일단은 저거 통채로 묶어놓은 상태입니다.
배경색과 테두리만 가져가는 코드로 변경하였습니다 ㅎㅎ..
여러 종류라면 참고하셔서 변경하시면 될것 같아요!
enum TFType {
/// 일반 텍스트 필드(회원가입 시 닉네임 입력받기)
case normal
/// 닉네임 입력시 발생하는 오류
case error
}
struct CustomTextFieldStyle: ViewModifier {
let type: TFType
func body(content: Content) -> some View {
switch type {
case .normal:
content
.padding(.vertical, 20)
.padding(.leading, 30)
.background(Color.symGray1)
.font(PretendardFont.bodyMedium)
.clipShape(RoundedRectangle(cornerRadius: 15))
case .error:
content
.padding(.vertical, 20)
.padding(.leading, 30)
.font(PretendardFont.bodyMedium)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 15))
.background(
RoundedRectangle(cornerRadius: 15)
.stroke(Color.symRed, lineWidth: 1)
)
}
}
}
사용하는 방법은 팝업뷰와 동일하게
extension으로 빼놓았기 때문에
// 사용예제
struct TextFieldDemo: View {
@State private var username: String = ""
@State private var password: String = ""
var body: some View {
VStack {
TextField("이름을 입력해주세요", text: $username)
.customTF(type: .normal)
.padding()
TextField("이름을 입력해주세요", text: $password)
.customTF(type: .error)
.padding()
}
}
}
.으로 손쉽게 사용할 수 있습니다.
마지막으로 TextEditor는..
저는 사실 TextEditor 작업을 이번에 처음해봐서
placeholder와 background 에 대한 작업이 필요할 것이라고 예상을 못하고 있었어요.
background에 대한 처리가 조금 복잡하던데..
다행히 저희 프로젝트는 최소 버전을 16으로 지원하고 있어서
15버전을 지원하는 프로젝트에 비해서 쉽게 구현할 수 있었습니다.
(최소 버전이 15이신 분들은 ... 다른 방법으로 구현하셔야 할거에요 😢)
TextEditor는 따로 종류가 없이 하나로 뷰를 그리기 때문에 enum 처리는 없습니다.
+) 글자수 추가
유저에게 글자수 제한을 주기 위해서 (200자) 그 부분을 추가했어요!
struct CustomTextEditorStyle: ViewModifier {
let placeholder: String
@Binding var text: String
func body(content: Content) -> some View {
if text.isEmpty {
ZStack(alignment: .topLeading) {
content
.padding(15)
.background(Color.symGray1)
.clipShape(RoundedRectangle(cornerRadius: 15))
.scrollContentBackground(.hidden)
.font(PretendardFont.bodyMedium)
Text(placeholder)
.lineSpacing(10)
.padding(20)
.padding(.top, 2)
.font(PretendardFont.bodyMedium)
.foregroundColor(Color.symGray3)
}
} else {
ZStack(alignment: .bottomTrailing) {
content
.padding(15)
.background(Color.symGray1)
.clipShape(RoundedRectangle(cornerRadius: 15))
.scrollContentBackground(.hidden)
Text("\(text.count) / 200")
.font(PretendardFont.smallMedium)
.foregroundColor(Color.symGray4)
.padding(.trailing, 10)
.padding(.bottom, 10)
}
}
}
}
extension TextEditor {
func customStyle(placeholder: String, userInput: Binding<String>) -> some View {
self.modifier(CustomTextEditorStyle(placeholder: placeholder, text: userInput))
}
}
여기에서 보이는 ZStack 부분이 placeholder 때문에 추가된 부분인데요.
스유의 TextEditor에는 아직 placeholder가 없기 때문에..
저는 그냥 ZStack으로 올렸고.. 위치가 안 맞는 부분은 padding으로 조절해줬어요.
Inropspect? 이라는 라이브러리가 있는것 같은데... 그냥 패딩으로 조절했습니다..
placeholder는 고정값이라서 그냥 let으로 받아서 사용하였고,
유저의 변경값을 바인딩으로 받아서 값이 들어오면 없어지도록 처리했습니다.
그리고 TextEditor 사용 시, 일반적인 Text에 background 주듯이 .background(Color.red) 하면
적용이 안 되는데요.. 그거를 해결하기 위해서 .scrollContentBackground(.hidden) 을 추가했습니다.
근데 이게 16부터 된다고 하더라구요. ... 그래서 16미만이신 분들은 다른 방법으로.. 하셔야합니다..
저 메서드의 의미는 ... 공식문서를 확인해보면
"이 보기 내에서 스크롤 가능한 보기에 대한 배경의 가시성을 지정합니다". 라고 합니다.
그리고 사용하는 방법은 동일하게 extension으로 빼놓았기 때문에
struct CustomTextView: View {
@State private var text: String = ""
private let holderTest: String = "예) 친구를 만나서 영화도 보고 맛있는것도 먹었어"
var body: some View {
VStack {
TextEditor(text: $text)
.customStyle(placeholder: holderTest, userInput: $text)
.frame(height: 200)
.padding()
.font(.system(size: 15))
}
}
}
. 을 통해서 사용하면 됩니다.
일단은.. 이렇게 구현을 해보았는데요..
맞는 방법인지는 실제로 나머지 뷰를 그려봐야 알 것 같아요(?)
근데 일단 먼저 블로깅을 해놓...고...
이상했다 싶으면 이 글 수정하러 오겠습니다.
올해는 디자인 시스템과 모듈화 도입을 꼭 해보고 싶다는 욕심이 있는데
어떻게 .. 성공을 할런지 잘 모르겠네요.
일단은 도전해봅니다. .. . ~~!!
디자인 시스템은 part2, 적용기로 또 돌아올게요.
혹시 저희 프로젝트가 궁금하시다면,,
https://github.com/Good-MoGong/SYM
GitHub - Good-MoGong/SYM: Speak Your Mind, 모공 프로젝트
Speak Your Mind, 모공 프로젝트. Contribute to Good-MoGong/SYM development by creating an account on GitHub.
github.com
놀러오세용!
'Swift' 카테고리의 다른 글
DispatchQueue(GCD)를 알아보자4, DispatchWorkItem과 Semaphore (0) | 2024.01.15 |
---|---|
DispatchQueue(GCD)를 알아보자3, DispatchGroup과 wait/enter/leave까지.. (1) | 2024.01.15 |
냅다 정리해보는 Combine 개념, (Combine을 알아보자 1) (0) | 2023.12.10 |
iOS Push Notification 구현하기 (feat, Firebase) (0) | 2023.12.01 |
apple sandbox push services 인증서를 신뢰하지 않음 해결하기 (3) | 2023.12.01 |