diff --git a/TnT/Projects/DesignSystem/Sources/Components/Divider/TDivider.swift b/TnT/Projects/DesignSystem/Sources/Components/Divider/TDivider.swift new file mode 100644 index 0000000..6035688 --- /dev/null +++ b/TnT/Projects/DesignSystem/Sources/Components/Divider/TDivider.swift @@ -0,0 +1,31 @@ +// +// TDivider.swift +// DesignSystem +// +// Created by 박민서 on 1/14/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI + +/// TnT 앱 내에서 전반적으로 사용되는 커스텀 디바이더입니다 +public struct TDivider: View { + /// Divider 높이 + let height: CGFloat + /// Divider 컬러 + let color: Color + + /// - Parameters: + /// - height: Divider의 두께 (기본값: `1`) + /// - color: Divider의 색상 + public init(height: CGFloat = 1, color: Color) { + self.height = height + self.color = color + } + + public var body: some View { + Rectangle() + .fill(color) + .frame(height: height) + } +} diff --git a/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextEditor.swift b/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextEditor.swift new file mode 100644 index 0000000..56c9a54 --- /dev/null +++ b/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextEditor.swift @@ -0,0 +1,194 @@ +// +// TTextEditor.swift +// DesignSystem +// +// Created by 박민서 on 1/14/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI + +/// TnT 앱 내에서 전반적으로 사용되는 커스텀 텍스트 에디터 컴포넌트입니다. +public struct TTextEditor: View { + + /// TextEditor 수평 패딩 값 + private static let horizontalPadding: CGFloat = 16 + /// TextEditor 수직 패딩 값 + private static let verticalPadding: CGFloat = 12 + /// TextEditor 기본 높이값 + public static let defaultHeight: CGFloat = 130 + + /// 하단에 표시되는 푸터 뷰 + private let footer: Footer? + /// Placeholder 텍스트 + private let placeholder: String + /// 텍스트 필드 상태 + @Binding private var status: Status + /// 입력된 텍스트 + @Binding private var text: String + + /// 내부에서 동적으로 관리되는 텍스트 에디터 높이 + @State private var textHeight: CGFloat = defaultHeight + /// 텍스트 에디터 포커스 상태 + @FocusState var isFocused: Bool + + /// TTextEditor 생성자 + /// - Parameters: + /// - placeholder: Placeholder 텍스트 (기본값: "내용을 입력해주세요"). + /// - text: 입력된 텍스트를 관리하는 바인딩. + /// - textEditorStatus: 텍스트 에디터 상태를 관리하는 바인딩. + /// - footer: Textfield 하단에 표시될 `TTextEditor.FooterView`를 정의하는 클로저. + public init( + placeholder: String = "내용을 입력해주세요", + text: Binding, + textEditorStatus: Binding, + footer: () -> Footer? = { nil } + ) { + self.placeholder = placeholder + self._text = text + self._status = textEditorStatus + self.footer = footer() + } + + public var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .topLeading) { + TextEditor(text: $text) + .autocorrectionDisabled() + .scrollDisabled(true) + .focused($isFocused) + .font(Typography.FontStyle.body1Medium.font) + .lineSpacing(Typography.FontStyle.body1Medium.lineSpacing) + .kerning(Typography.FontStyle.body1Medium.letterSpacing) + .tint(Color.neutral800) + .frame(minHeight: textHeight, maxHeight: .infinity) + .padding(.vertical, TTextEditor.verticalPadding) + .padding(.horizontal, TTextEditor.horizontalPadding) + .background(Color.common0) + .scrollContentBackground(.hidden) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(status.borderColor(isFocused: isFocused), lineWidth: status.borderWidth(isFocused: isFocused)) + ) + .onChange(of: text) { + withAnimation { + textHeight = getNewHeight(geometry: geometry) + } + } + .onAppear { + textHeight = getNewHeight(geometry: geometry) + } + + if text.isEmpty { + Text(placeholder) + .typographyStyle(.body1Medium, with: .neutral400) + .padding(.vertical, TTextEditor.verticalPadding + 8) + .padding(.horizontal, TTextEditor.horizontalPadding + 4) + } + } + if let footer { + footer + } + } + } + .frame(height: TTextEditor.defaultHeight) + } + + private func getNewHeight(geometry: GeometryProxy) -> CGFloat { + let newHeight: CGFloat = TextUtility.calculateTextHeight( + boxWidth: geometry.size.width - TTextEditor.horizontalPadding * 2, + text: text, + style: .body1Medium + ) + TTextEditor.verticalPadding * 2 + return max(newHeight, TTextEditor.defaultHeight) + } +} + +public extension TTextEditor { + /// TTextEditor의 Footer입니다 + struct Footer: View { + /// 최대 입력 가능 글자 수 + private let textLimit: Int + + /// 텍스트 필드 상태 + @Binding private var status: Status + /// 입력된 텍스트 + @Binding private var text: String + + /// Footer 생성자 + /// - Parameters: + /// - textLimit: 최대 입력 가능 글자 수. + /// - status: 텍스트 에디터의 상태를 관리하는 바인딩. + /// - text: 입력된 텍스트를 관리하는 바인딩. + public init( + textLimit: Int, + status: Binding, + text: Binding + ) { + self.textLimit = textLimit + self._status = status + self._text = text + } + + public var body: some View { + HStack { + Spacer() + Text("\(text.count)/\(textLimit)") + .typographyStyle(.label2Medium, with: status.footerColor) + } + } + } +} + +public extension TTextEditor { + /// TextEditor에 표시되는 상태입니다 + enum Status { + case empty + case filled + case invalid + + /// 테두리 두께 설정 + func borderWidth(isFocused: Bool) -> CGFloat { + switch self { + case .invalid: + return 2 // Focus와 상관없이 2 + default: + return isFocused ? 2 : 1 + } + } + + /// 테두리 색상 설정 + func borderColor(isFocused: Bool) -> Color { + switch self { + case .invalid: + return .red500 // Focus와 상관없이 .red500 + case .empty: + return isFocused ? .neutral900 : .neutral200 + case .filled: + return isFocused ? .neutral900 : .neutral600 + } + } + + /// 텍스트 색상 설정 + var textColor: Color { + switch self { + case .empty: + return .neutral400 + case .filled, .invalid: + return .neutral600 + } + } + + /// 푸터 색상 설정 + var footerColor: Color { + switch self { + case .empty, .filled: + return .neutral300 + case .invalid: + return .red500 + } + } + } +} diff --git a/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextField.swift b/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextField.swift new file mode 100644 index 0000000..473bee0 --- /dev/null +++ b/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextField.swift @@ -0,0 +1,258 @@ +// +// TTextField.swift +// DesignSystem +// +// Created by 박민서 on 1/14/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI + +/// TnT 앱 내에서 전반적으로 사용되는 커스텀 텍스트 필드 컴포넌트입니다. +public struct TTextField: View { + + /// 텍스트 필드 우측에 표시될 RightView + private let rightView: RightView? + /// Placeholder 텍스트 + private let placeholder: String + + /// 입력 텍스트 + @Binding private var text: String + /// 텍스트 필드 상태 + @Binding private var status: Status + /// 텍스트 필드 포커스 상태 + @FocusState var isFocused: Bool + + /// - Parameters: + /// - placeholder: Placeholder 텍스트 (기본값: "내용을 입력해주세요") + /// - text: 입력 텍스트 (Binding) + /// - textFieldStatus: 텍스트 필드 상태 (Binding) + /// - rightView: 텍스트 필드 우측에 표시될 `TTextField.RightView`를 정의하는 클로저. + public init( + placeholder: String = "내용을 입력해주세요", + text: Binding, + textFieldStatus: Binding, + @ViewBuilder rightView: () -> RightView? = { nil } + ) { + self.placeholder = placeholder + self._text = text + self._status = textFieldStatus + self.rightView = rightView() + } + + public var body: some View { + // Text Field + VStack(spacing: 0) { + HStack(spacing: 0) { + TextField(placeholder, text: $text) + .autocorrectionDisabled() + .focused($isFocused) + .font(Typography.FontStyle.body1Medium.font) + .lineSpacing(Typography.FontStyle.body1Medium.lineSpacing) + .kerning(Typography.FontStyle.body1Medium.letterSpacing) + .tint(Color.neutral800) + .foregroundStyle(status.textColor) + .padding(8) + .frame(height: 42) + + if let rightView { + rightView + } + } + + TDivider(color: status.underlineColor(isFocused: isFocused)) + } + } +} + +public extension TTextField.RightView { + /// + enum Style { + case unit(text: String, status: TTextField.Status) + case button(title: String, tapAction: () -> Void) + } +} + +public extension TTextField { + /// TextField 우측 컨텐츠 뷰입니다 + struct RightView: View { + /// 컨텐츠 스타일 + private let style: RightView.Style + + public init(style: RightView.Style) { + self.style = style + } + + public var body: some View { + + switch style { + case let .unit(text, status): + Text(text) + .typographyStyle(.body1Medium, with: status.textColor) + .padding(.horizontal, 12) + .padding(.vertical, 3) + + case let .button(title, tapAction): + // TODO: 추후 버튼 컴포넌트 나오면 대체 + Button(action: tapAction) { + Text(title) + .typographyStyle(.label2Medium, with: .neutral50) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.neutral900) + .clipShape(.rect(cornerRadius: 8)) + } + .padding(.vertical, 5) + } + } + } + + /// TextField 상단 헤더입니다 + struct Header: View { + /// 필수 여부를 표시 + private let isRequired: Bool + /// 헤더의 제목 + private let title: String + /// 입력 가능한 글자 수 제한 + private let limitCount: Int? + /// 입력된 텍스트 + @Binding private var text: String + + public init( + isRequired: Bool, + title: String, + limitCount: Int?, + text: Binding + ) { + self.isRequired = isRequired + self.title = title + self.limitCount = limitCount + self._text = text + } + + public var body: some View { + HStack(spacing: 0) { + Text(title) + .typographyStyle(.body1Bold, with: .neutral900) + if isRequired { + Text("*") + .typographyStyle(.body1Bold, with: .red500) + } + + Spacer() + + if let limitCount { + Text("\(text.count)/\(limitCount)자") + .typographyStyle(.label1Medium, with: .neutral400) + .padding(.horizontal, 4) + .padding(.vertical, 2) + } + } + } + } + + /// TextField 하단 푸터입니다 + struct Footer: View { + /// 푸터 텍스트 + private let footerText: String + /// 텍스트 필드 상태 + @Binding private var status: Status + + public init(footerText: String, status: Binding) { + self.footerText = footerText + self._status = status + } + + public var body: some View { + Text(footerText) + .typographyStyle(.body2Medium, with: status.footerColor) + } + } +} + +public extension TTextField { + /// TextField에 표시되는 상태입니다 + enum Status { + case empty + case filled + case invalid + case valid + + /// 밑선 색상 설정 + func underlineColor(isFocused: Bool) -> Color { + switch self { + case .empty: + return isFocused ? .neutral600 : .neutral200 + case .filled: + return isFocused ? .neutral600 : .neutral200 + case .invalid: + return .red500 + case .valid: + return .blue500 + } + } + + /// 텍스트 색상 설정 + var textColor: Color { + switch self { + case .empty: + return .neutral400 + case .filled, .invalid, .valid: + return .neutral600 + } + } + + /// 푸터 색상 설정 + var footerColor: Color { + switch self { + case .empty, .filled: + return .clear + case .invalid: + return .red500 + case .valid: + return .blue500 + } + } + } +} + +struct TTextFieldModifier: ViewModifier { + /// Textfield 상단에 표시될 헤더 + private let header: TTextField.Header? + /// Textfield 하단에 표시될 푸터 + private let footer: TTextField.Footer? + + public init(header: TTextField.Header?, footer: TTextField.Footer?) { + self.header = header + self.footer = footer + } + + func body(content: Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + // 헤더 추가 + if let header { + header + } + + // 본체(TextField) + content + + // 푸터 추가 + if let footer { + footer + } + } + } +} + +public extension TTextField { + /// 헤더와 푸터를 포함한 레이아웃을 텍스트 필드에 적용합니다. + /// + /// - Parameters: + /// - header: `TTextField.Header`로 정의된 상단 헤더. (옵션) + /// - footer: `TTextField.Footer`로 정의된 하단 푸터. (옵션) + /// - Returns: 헤더와 푸터가 포함된 새로운 View. + func withSectionLayout(header: TTextField.Header? = nil, footer: TTextField.Footer? = nil) -> some View { + self.modifier(TTextFieldModifier(header: header, footer: footer)) + } +} diff --git a/TnT/Projects/DesignSystem/Sources/DesignSystem/Font+DesignSystem.swift b/TnT/Projects/DesignSystem/Sources/DesignSystem/Font+DesignSystem.swift index b96115f..3718792 100644 --- a/TnT/Projects/DesignSystem/Sources/DesignSystem/Font+DesignSystem.swift +++ b/TnT/Projects/DesignSystem/Sources/DesignSystem/Font+DesignSystem.swift @@ -16,35 +16,29 @@ public struct Typography { /// Pretendard 폰트의 굵기(enum) 정의 public enum Weight { case thin, extraLight, light, regular, medium, semibold, bold, extrabold, black - - public var name: String { + + public var fontConvertible: DesignSystemFontConvertible { switch self { - case .thin: return "Pretendard-Thin" - case .extraLight: return "Pretendard-ExtraLight" - case .light: return "Pretendard-Light" - case .regular: return "Pretendard-Regular" - case .medium: return "Pretendard-Medium" - case .semibold: return "Pretendard-SemiBold" - case .bold: return "Pretendard-Bold" - case .extrabold: return "Pretendard-ExtraBold" - case .black: return "Pretendard-Black" + + case .thin: return DesignSystemFontFamily.Pretendard.thin + case .extraLight: return DesignSystemFontFamily.Pretendard.extraLight + case .light: return DesignSystemFontFamily.Pretendard.light + case .regular: return DesignSystemFontFamily.Pretendard.regular + case .medium: return DesignSystemFontFamily.Pretendard.medium + case .semibold: return DesignSystemFontFamily.Pretendard.semiBold + case .bold: return DesignSystemFontFamily.Pretendard.bold + case .extrabold: return DesignSystemFontFamily.Pretendard.extraBold + case .black: return DesignSystemFontFamily.Pretendard.black } } } - - /// 주어진 Weight와 크기로 커스텀 폰트를 생성합니다. - /// - Parameters: - /// - weight: Pretendard의 폰트 굵기 - /// - size: 폰트 크기 - /// - Returns: SwiftUI Font 객체 - public static func customFont(_ weight: Pretendard.Weight, size: CGFloat) -> Font { - return Font.custom(weight.name, size: size) - } } /// 폰트, 줄 높이, 줄 간격, 자간 등을 포함한 스타일 정의를 위한 구조체입니다. public struct FontStyle { public let font: Font + public let uiFont: UIFont + public let size: CGFloat public let lineHeight: CGFloat public let lineSpacing: CGFloat public let letterSpacing: CGFloat @@ -56,7 +50,9 @@ public struct Typography { /// - lineHeightMultiplier: 줄 높이 배율 (CGFloat) /// - letterSpacing: 자간 (CGFloat) init(_ weight: Pretendard.Weight, size: CGFloat, lineHeightMultiplier: CGFloat, letterSpacing: CGFloat) { - self.font = Pretendard.customFont(weight, size: size) + self.font = weight.fontConvertible.swiftUIFont(size: size) + self.uiFont = weight.fontConvertible.font(size: size) + self.size = size self.lineHeight = size * lineHeightMultiplier self.lineSpacing = (size * lineHeightMultiplier) - size self.letterSpacing = letterSpacing diff --git a/TnT/Projects/DesignSystem/Sources/Utility/TextUtility.swift b/TnT/Projects/DesignSystem/Sources/Utility/TextUtility.swift new file mode 100644 index 0000000..dafe094 --- /dev/null +++ b/TnT/Projects/DesignSystem/Sources/Utility/TextUtility.swift @@ -0,0 +1,50 @@ +// +// TextUtility.swift +// DesignSystem +// +// Created by 박민서 on 1/15/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import UIKit + +public struct TextUtility { + /// 주어진 텍스트와 스타일을 기준으로 텍스트 높이를 계산합니다. + /// + /// 이 함수는 `NSTextStorage`, `NSLayoutManager`, `NSTextContainer`를 사용하여 텍스트 렌더링의 실제 높이를 계산합니다. + /// 텍스트가 주어진 너비를 초과할 경우 줄바꿈과 스타일을 고려하여 계산된 높이를 반환합니다. + /// + /// - Parameters: + /// - boxWidth: 텍스트가 렌더링될 컨테이너의 가로 길이. (최대 너비) + /// - text: 높이를 계산할 텍스트 문자열. + /// - style: `Typography.FontStyle`로 정의된 폰트, 줄 간격, 자간 등의 스타일. + /// - Returns: 주어진 스타일로 렌더링된 텍스트의 높이 (CGFloat). + static func calculateTextHeight( + boxWidth: CGFloat, + text: String, + style: Typography.FontStyle + ) -> CGFloat { + // 1. 텍스트 렌더링을 위한 핵심 클래스 설정 + let textStorage: NSTextStorage = NSTextStorage(string: text) + let textContainer: NSTextContainer = NSTextContainer(size: CGSize(width: boxWidth, height: .greatestFiniteMagnitude)) + let layoutManager: NSLayoutManager = NSLayoutManager() + + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + + // 2. 텍스트 스타일 지정 + let paragraphStyle: NSMutableParagraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = style.lineSpacing + paragraphStyle.alignment = .left + + textStorage.addAttributes([ + .font: style.uiFont, + .paragraphStyle: paragraphStyle, + .kern: style.letterSpacing + ], range: NSRange(location: 0, length: text.count)) + + // 3. 텍스트 높이 계산 + let estimatedHeight: CGFloat = layoutManager.usedRect(for: textContainer).height + return estimatedHeight + } +} diff --git a/TnT/Tuist/Config/Info.plist b/TnT/Tuist/Config/Info.plist index 39592c4..db58c8f 100644 --- a/TnT/Tuist/Config/Info.plist +++ b/TnT/Tuist/Config/Info.plist @@ -62,5 +62,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIUserInterfaceStyle + Light