diff --git a/Handy/Handy-Storybook/Atom/TextFieldViewController.swift b/Handy/Handy-Storybook/Atom/TextFieldViewController.swift new file mode 100644 index 0000000..41c9f81 --- /dev/null +++ b/Handy/Handy-Storybook/Atom/TextFieldViewController.swift @@ -0,0 +1,85 @@ +// +// TextFieldViewController.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + +import Handy + +final class TextFieldViewController: BaseViewController { + + private let defaultField: HandyTextFieldView = { + let textField = HandyTextFieldView() + textField.placeholder = "Input text" + textField.fieldLabelText = "Label" + textField.helperLabelText = "Helper text" + return textField + }() + + private let filledField: HandyTextFieldView = { + let textField = HandyTextFieldView() + textField.text = "Text Inputting" + textField.placeholder = "Input text" + textField.fieldLabelText = "Label" + textField.helperLabelText = "Helper text" + return textField + }() + + private let errorField: HandyTextFieldView = { + let textField = HandyTextFieldView() + textField.placeholder = "Input text" + textField.fieldLabelText = "Label" + textField.helperLabelText = "Helper text" + textField.isNegative = true + return textField + }() + + private let disabledField: HandyTextFieldView = { + let textField = HandyTextFieldView() + textField.placeholder = "Input text" + textField.fieldLabelText = "Label" + textField.helperLabelText = "Helper text" + textField.isDisabled = true + return textField + }() + + override func viewDidLoad() { + super.viewDidLoad() + setViewLayouts() + } + + override func setViewHierarchies() { + [ + defaultField, + filledField, + errorField, + disabledField + ].forEach { + view.addSubview($0) + } + } + + override func setViewLayouts() { + defaultField.snp.makeConstraints { + $0.bottom.equalTo(filledField.snp.top).offset(-16) + $0.horizontalEdges.equalToSuperview().inset(20) + } + filledField.snp.makeConstraints { + $0.centerY.equalToSuperview().offset(-50) + $0.top.equalTo(defaultField.snp.bottom).offset(16) + $0.horizontalEdges.equalToSuperview().inset(20) + } + errorField.snp.makeConstraints { + $0.top.equalTo(filledField.snp.bottom).offset(16) + $0.horizontalEdges.equalToSuperview().inset(20) + } + disabledField.snp.makeConstraints { + $0.top.equalTo(errorField.snp.bottom).offset(16) + $0.horizontalEdges.equalToSuperview().inset(20) + } + } +} diff --git a/Handy/Handy-Storybook/Atom/TextViewController.swift b/Handy/Handy-Storybook/Atom/TextViewController.swift new file mode 100644 index 0000000..a8211a6 --- /dev/null +++ b/Handy/Handy-Storybook/Atom/TextViewController.swift @@ -0,0 +1,122 @@ +// +// TextViewController.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + +import Handy + +final class TextViewController: BaseViewController { + private let defaultTextView: HandyTextView = { + let textView = HandyTextView() + textView.placeholder = "Input text" + textView.helperLabelText = "알파벳과 숫자만 허용합니다." + textView.placeholderColor = .lightGray + + return textView + }() + + private let noHelperLabelTextView: HandyTextView = { + let textView = HandyTextView() + textView.placeholder = "Input text" + textView.placeholderColor = .lightGray + + return textView + }() + + private let errorTextView: HandyTextView = { + let textView = HandyTextView() + textView.placeholder = "Input text" + textView.helperLabelText = "Helper text" + textView.placeholderColor = .lightGray + textView.isNegative = true + + return textView + }() + + private let disabledTextView: HandyTextView = { + let textView = HandyTextView() + textView.placeholder = "Input text" + textView.helperLabelText = "Helper text" + textView.placeholderColor = .lightGray + textView.isDisabled = true + + return textView + }() + + override func viewDidLoad() { + super.viewDidLoad() + setViewLayouts() + } + + override func setViewProperties() { + self.view.backgroundColor = .white + defaultTextView.editingDelegate = self + defaultTextView.validationDelegate = self + defaultTextView.textChangeDelegate = self + } + + override func setViewHierarchies() { + [ + defaultTextView, noHelperLabelTextView, errorTextView, disabledTextView + ].forEach { + view.addSubview($0) + } + } + + override func setViewLayouts() { + defaultTextView.snp.makeConstraints { + $0.top.equalToSuperview().offset(100) + $0.horizontalEdges.equalToSuperview().inset(20) + $0.height.lessThanOrEqualTo(100) + $0.height.greaterThanOrEqualTo(100) + } + noHelperLabelTextView.snp.makeConstraints { + $0.top.equalTo(defaultTextView.snp.bottom).offset(20) + $0.horizontalEdges.equalToSuperview().inset(20) + $0.height.lessThanOrEqualTo(100) + $0.height.greaterThanOrEqualTo(100) + } + errorTextView.snp.makeConstraints { + $0.top.equalTo(noHelperLabelTextView.snp.bottom).offset(20) + $0.horizontalEdges.equalToSuperview().inset(20) + $0.height.greaterThanOrEqualTo(80) + } + disabledTextView.snp.makeConstraints { + $0.top.equalTo(errorTextView.snp.bottom).offset(20) + $0.horizontalEdges.equalToSuperview().inset(20) + } + } +} + +extension TextViewController: HandyTextViewEditingDelegate { + func handyTextViewDidBeginEditing(_ handyTextView: HandyTextView) { + print("입력 시작") + } + + func handyTextViewDidEndEditing(_ handyTextView: HandyTextView) { + print("입력 끝") + } +} + +extension TextViewController: HandyTextViewValidationDelegate { + func handyTextView(_ handyTextView: HandyTextView, isValidText text: String) -> Bool { + let regex = "^[a-zA-Z0-9]*$" + return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: text) + } + + func handyTextView(_ handyTextView: HandyTextView, didFailValidationWithError error: String) { + print("유효성 검사 에러: \(error)") + } +} + +extension TextViewController: HandyTextViewTextChangeDelegate { + func handyTextViewDidChange(_ handyTextView: HandyTextView, text: String) { + print("입력된 텍스트: \(text)") + } +} + diff --git a/Handy/Handy.xcodeproj/project.pbxproj b/Handy/Handy.xcodeproj/project.pbxproj index fd42298..d172163 100644 --- a/Handy/Handy.xcodeproj/project.pbxproj +++ b/Handy/Handy.xcodeproj/project.pbxproj @@ -38,6 +38,14 @@ 02ED764C2C57BD09001569F1 /* HandyBoxButtonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02ED764B2C57BD09001569F1 /* HandyBoxButtonViewController.swift */; }; 2D41E8142C5A21930043161D /* FabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D41E8132C5A21930043161D /* FabViewController.swift */; }; 2D41E8162C5A21B50043161D /* HandyFab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D41E8152C5A21B50043161D /* HandyFab.swift */; }; + 2D8811892D2642A900B0B517 /* HandyTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8811882D2642A800B0B517 /* HandyTextFieldView.swift */; }; + 2D88118B2D2642BD00B0B517 /* HandyTextFieldConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D88118A2D2642BC00B0B517 /* HandyTextFieldConstants.swift */; }; + 2D88118D2D2642CE00B0B517 /* HandyBaseTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D88118C2D2642CE00B0B517 /* HandyBaseTextField.swift */; }; + 2D88118F2D2642F900B0B517 /* TextFieldViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D88118E2D2642F900B0B517 /* TextFieldViewController.swift */; }; + 2D8811912D26512600B0B517 /* TextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8811902D26512500B0B517 /* TextViewController.swift */; }; + 2D8811942D26515100B0B517 /* HandyTextViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8811932D26515000B0B517 /* HandyTextViewConstants.swift */; }; + 2D8811962D26516100B0B517 /* HandyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8811952D26516000B0B517 /* HandyTextView.swift */; }; + 2D8811982D26517100B0B517 /* HandyBaseTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8811972D26517000B0B517 /* HandyBaseTextView.swift */; }; A56B3DE22C4E51D300C3610A /* HandyChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56B3DE12C4E51D300C3610A /* HandyChip.swift */; }; A5A12A7E2C57A6D900996916 /* ChipViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A12A7C2C57A6C200996916 /* ChipViewController.swift */; }; A5A12A7F2C57A92000996916 /* HandySematic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5D02AFC2C46C5A70056CE7B /* HandySematic.swift */; }; @@ -115,6 +123,14 @@ 02ED764B2C57BD09001569F1 /* HandyBoxButtonViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyBoxButtonViewController.swift; sourceTree = ""; }; 2D41E8132C5A21930043161D /* FabViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FabViewController.swift; sourceTree = ""; }; 2D41E8152C5A21B50043161D /* HandyFab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyFab.swift; sourceTree = ""; }; + 2D8811882D2642A800B0B517 /* HandyTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyTextFieldView.swift; sourceTree = ""; }; + 2D88118A2D2642BC00B0B517 /* HandyTextFieldConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyTextFieldConstants.swift; sourceTree = ""; }; + 2D88118C2D2642CE00B0B517 /* HandyBaseTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyBaseTextField.swift; sourceTree = ""; wrapsLines = 0; }; + 2D88118E2D2642F900B0B517 /* TextFieldViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldViewController.swift; sourceTree = ""; }; + 2D8811902D26512500B0B517 /* TextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewController.swift; sourceTree = ""; }; + 2D8811932D26515000B0B517 /* HandyTextViewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyTextViewConstants.swift; sourceTree = ""; }; + 2D8811952D26516000B0B517 /* HandyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyTextView.swift; sourceTree = ""; }; + 2D8811972D26517000B0B517 /* HandyBaseTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyBaseTextView.swift; sourceTree = ""; }; A56B3DE12C4E51D300C3610A /* HandyChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyChip.swift; sourceTree = ""; }; A5A12A7C2C57A6C200996916 /* ChipViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewController.swift; sourceTree = ""; }; A5F6D36A2C96F32D00FB961F /* HandyDivider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandyDivider.swift; sourceTree = ""; }; @@ -173,6 +189,8 @@ 2D41E8132C5A21930043161D /* FabViewController.swift */, 02ED764B2C57BD09001569F1 /* HandyBoxButtonViewController.swift */, A5A12A7C2C57A6C200996916 /* ChipViewController.swift */, + 2D8811902D26512500B0B517 /* TextViewController.swift */, + 2D88118E2D2642F900B0B517 /* TextFieldViewController.swift */, A5F6D36C2C97099C00FB961F /* DividerViewController.swift */, E51FBF9A2C5399A00097B0DA /* CheckBoxViewController.swift */, E51FBFA12C54CD350097B0DA /* RadioButtonViewController.swift */, @@ -228,6 +246,8 @@ 029E47FE2C49FD2E00D2F3B7 /* Atom */ = { isa = PBXGroup; children = ( + 2D8811922D26514B00B0B517 /* HandyTextView */, + 2D8811872D26428500B0B517 /* HandyTextField */, 02ED762F2C52849A001569F1 /* HandyButton */, 029E47FC2C49FD1A00D2F3B7 /* HandyLabel.swift */, 2D41E8152C5A21B50043161D /* HandyFab.swift */, @@ -313,6 +333,26 @@ path = Extension; sourceTree = ""; }; + 2D8811872D26428500B0B517 /* HandyTextField */ = { + isa = PBXGroup; + children = ( + 2D8811882D2642A800B0B517 /* HandyTextFieldView.swift */, + 2D88118C2D2642CE00B0B517 /* HandyBaseTextField.swift */, + 2D88118A2D2642BC00B0B517 /* HandyTextFieldConstants.swift */, + ); + path = HandyTextField; + sourceTree = ""; + }; + 2D8811922D26514B00B0B517 /* HandyTextView */ = { + isa = PBXGroup; + children = ( + 2D8811932D26515000B0B517 /* HandyTextViewConstants.swift */, + 2D8811972D26517000B0B517 /* HandyBaseTextView.swift */, + 2D8811952D26516000B0B517 /* HandyTextView.swift */, + ); + path = HandyTextView; + sourceTree = ""; + }; E5650D412C4D30B9002790CC /* Asset */ = { isa = PBXGroup; children = ( @@ -455,6 +495,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2D88118F2D2642F900B0B517 /* TextFieldViewController.swift in Sources */, 02150E4C2CCABAEB00EE690E /* SnackbarViewController.swift in Sources */, 2D41E8142C5A21930043161D /* FabViewController.swift in Sources */, A5A12A812C57A93C00996916 /* HandyPrimitive.swift in Sources */, @@ -468,6 +509,7 @@ E51FBFA22C54CD350097B0DA /* RadioButtonViewController.swift in Sources */, E51FBF9B2C5399A00097B0DA /* CheckBoxViewController.swift in Sources */, 025776352C4EA98C00272EC6 /* AppDelegate.swift in Sources */, + 2D8811912D26512600B0B517 /* TextViewController.swift in Sources */, 02697A262C99DDA30027A362 /* HansySwitchViewController.swift in Sources */, 025776372C4EA98C00272EC6 /* SceneDelegate.swift in Sources */, ); @@ -484,8 +526,10 @@ 02150E4A2CC8D7AB00EE690E /* HandySnackbar.swift in Sources */, E5D02B002C480A180056CE7B /* HandyPrimitive.swift in Sources */, E5D02AFD2C46C5A70056CE7B /* HandySematic.swift in Sources */, + 2D8811982D26517100B0B517 /* HandyBaseTextView.swift in Sources */, E5D02B002C480A180056CE7B /* HandyPrimitive.swift in Sources */, E51FBFA02C54CB260097B0DA /* HandyRadioButton.swift in Sources */, + 2D88118B2D2642BD00B0B517 /* HandyTextFieldConstants.swift in Sources */, E5669A3F2C443E7300DABC21 /* HandyBasicColor.swift in Sources */, 02ED76312C5284BB001569F1 /* HandyButtonProtocol.swift in Sources */, 02ED76352C5284F3001569F1 /* HandyTextButton.swift in Sources */, @@ -494,10 +538,14 @@ 02ED764A2C5779C3001569F1 /* UIImage+.swift in Sources */, 029E48002C49FD4000D2F3B7 /* HandyTypography.swift in Sources */, E5650D432C4D326D002790CC /* HandyCheckBox.swift in Sources */, + 2D8811962D26516100B0B517 /* HandyTextView.swift in Sources */, + 2D88118D2D2642CE00B0B517 /* HandyBaseTextField.swift in Sources */, 029E47FD2C49FD1A00D2F3B7 /* HandyLabel.swift in Sources */, A56B3DE22C4E51D300C3610A /* HandyChip.swift in Sources */, + 2D8811942D26515100B0B517 /* HandyTextViewConstants.swift in Sources */, E5650D472C512B07002790CC /* HandyIcon.swift in Sources */, E5650D472C512B07002790CC /* HandyIcon.swift in Sources */, + 2D8811892D2642A900B0B517 /* HandyTextFieldView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift b/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift new file mode 100644 index 0000000..57a7654 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift @@ -0,0 +1,218 @@ +// +// HandyBaseTextField.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit + +public class HandyBaseTextField: UITextField { + + // MARK: - 외부에서 지정할 수 있는 속성 + + /** + 텍스트 필드를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool + /** + 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool + + // MARK: - 내부에서 사용되는 뷰 + + /** + 텍스트 필드 내의 입력을 초기화할 때 사용하는 Clear 버튼입니다. + */ + private let clearButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(HandyIcon.cancelFilled, for: .normal) + button.tintColor = HandySemantic.iconBasicTertiary + button.isHidden = true + return button + }() + + // MARK: - 초기화 + + public init() { + super.init(frame: .zero) + setupTextField() + updatePlaceholderColorAndFont() + setupClearButton() + self.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - 설정 + + /** + 텍스트 필드의 기본 속성을 설정합니다. + - 테두리 색상, 패딩, 기본 배경색 등을 포함합니다. + */ + private func setupTextField() { + self.tintColor = HandySemantic.lineStatusPositive + self.layer.borderWidth = 1 + self.layer.cornerRadius = HandySemantic.radiusM + self.layer.masksToBounds = true + self.layer.borderColor = HandySemantic.bgBasicLight.cgColor + self.backgroundColor = HandySemantic.bgBasicLight + self.font = HandyFont.B1Rg16 + + let leftPaddingView = UIView(frame: CGRect(x: 0, y: 0, width: HandyTextFieldConstants.Dimension.leftMargin, height: 0)) + self.leftView = leftPaddingView + self.leftViewMode = .always + + let rightPaddingView = UIView(frame: CGRect(x: 0, y: 0, width: HandyTextFieldConstants.Dimension.rightMargin, height: 0)) + self.rightView = rightPaddingView + self.rightViewMode = .always + + self.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(HandyTextFieldConstants.Dimension.textFieldHeight) + } + } + + /** + 플레이스홀더의 색상과 폰트를 업데이트합니다. + - 기본적으로 `HandyFont.B1Rg16`를 사용하며, `color` 매개변수를 통해 색상을 지정할 수 있습니다. + */ + private func updatePlaceholderColorAndFont(color: UIColor = HandySemantic.textBasicTertiary) { + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: color, + .font: HandyFont.B1Rg16 + ] + + if let placeholder = self.placeholder { + self.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes) + } + } + + /** + Clear 버튼을 설정합니다. + - Clear 버튼은 텍스트 필드 오른쪽에 위치하며, 텍스트 입력 상태에 따라 표시됩니다. + */ + private func setupClearButton() { + addSubview(clearButton) + clearButton.addTarget(self, action: #selector(clearText), for: .touchUpInside) + + clearButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().inset(HandyTextFieldConstants.Dimension.clearButtonDefaultRightMargin) + $0.width.height.equalTo(HandyTextFieldConstants.Dimension.clearButtonSize) + } + + addTarget(self, action: #selector(textDidChange), for: .editingChanged) + } + + // MARK: - 상태 관리 + + /** + 텍스트 필드의 상태에 따라 UI를 업데이트합니다. + - `isDisabled`: 비활성화 상태를 나타냅니다. + - `isNegative`: 오류 상태를 나타냅니다. + */ + private func updateState() { + if isDisabled { + self.isUserInteractionEnabled = false + self.backgroundColor = HandySemantic.bgBasicLight + self.layer.borderColor = HandySemantic.bgBasicLight.cgColor + self.textColor = HandySemantic.textBasicDisabled + updatePlaceholderColorAndFont(color: HandySemantic.textBasicDisabled) + clearButton.isHidden = true + return + } + + if isNegative { + self.isUserInteractionEnabled = true + self.layer.borderColor = HandySemantic.lineStatusNegative.cgColor + self.textColor = HandySemantic.textBasicSecondary + updatePlaceholderColorAndFont(color: HandySemantic.textBasicTertiary) + clearButton.isHidden = false + return + } + + self.isUserInteractionEnabled = true + self.layer.borderColor = HandySemantic.bgBasicLight.cgColor + self.textColor = HandySemantic.textBasicPrimary + updatePlaceholderColorAndFont(color: HandySemantic.textBasicTertiary) + clearButton.isHidden = self.text?.isEmpty ?? true + } + + // MARK: - Clear 버튼 동작 + + /** + 텍스트 필드의 텍스트를 초기화합니다. + - Clear 버튼이 눌렸을 때 호출됩니다. + */ + @objc private func clearText() { + self.text = "" + clearButton.isHidden = true + } + + /** + 텍스트 필드의 텍스트 변경 시 호출됩니다. + - 텍스트가 입력되거나 삭제될 때 Clear 버튼의 표시 상태를 업데이트합니다. + */ + @objc private func textDidChange() { + clearButton.isHidden = self.text?.isEmpty ?? true + } + + // MARK: - Overridden Methods + + /** + Placeholder 및 텍스트 레이아웃을 설정합니다. + */ + public override func textRect(forBounds bounds: CGRect) -> CGRect { + return bounds.inset(by: UIEdgeInsets( + top: 0, + left: HandyTextFieldConstants.Dimension.leftMargin, + bottom: 0, + right: HandyTextFieldConstants.Dimension.rightMargin + )) + } + + /** + 텍스트 입력 시 레이아웃을 설정합니다. + */ + public override func editingRect(forBounds bounds: CGRect) -> CGRect { + return bounds.inset(by: UIEdgeInsets( + top: 0, + left: HandyTextFieldConstants.Dimension.leftMargin, + bottom: 0, + right: HandyTextFieldConstants.Dimension.rightMargin + )) + } + + public override func draw(_ rect: CGRect) { + super.draw(rect) + updateState() + } +} + +// MARK: - UITextFieldDelegate + +extension HandyBaseTextField: UITextFieldDelegate { + /** + 텍스트 필드가 편집을 시작할 때 호출됩니다. + - isNegative 상태가 아닐 경우, 테두리 색상을 긍정 상태 색상으로 변경합니다. + - 편집 중일 때 시각적 피드백을 제공합니다. + - 호출 시점: 사용자가 텍스트 필드에 포커스를 줄 때. + */ + public func textFieldDidBeginEditing(_ textField: UITextField) { + if !isNegative { + self.layer.borderColor = HandySemantic.lineStatusPositive.cgColor + } + } + + /** + 텍스트 필드의 편집이 종료될 때 호출됩니다. + - 상태를 다시 업데이트하여 현재 상태에 맞는 UI를 반영합니다. + - 호출 시점: 사용자가 텍스트 필드의 포커스를 해제할 때. + */ + public func textFieldDidEndEditing(_ textField: UITextField) { + updateState() + } +} diff --git a/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldConstants.swift b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldConstants.swift new file mode 100644 index 0000000..311ef79 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldConstants.swift @@ -0,0 +1,48 @@ +// +// HandyTextFieldConstants.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit + +internal struct HandyTextFieldConstants { + internal enum Dimension { + + /** + 텍스트 필드 좌측 마진값입니다. + */ + static let leftMargin: CGFloat = 16 + + /** + 텍스트 필드 우측 마진값입니다. + */ + static let rightMargin: CGFloat = (clearButtonDefaultRightMargin * 2) + clearButtonSize + + /** + 텍스트 필드 높이입니다. + */ + static let textFieldHeight: CGFloat = 48 + + /** + Label, TextField, Helper text 내부 요소 간 간격입니다. + */ + static let subviewSpacing: CGFloat = 4 + + /** + clearButton과 TextField 사이 값 (=clearButton의 우측 마진)입니다. + */ + static let clearButtonDefaultRightMargin: CGFloat = 12 + + /** + clearButton 크기입니다. + */ + static let clearButtonSize: CGFloat = 20 + + /** + Label, Helper text가 TextField보다 왼쪽으로 더 들어가있는 Inset 값 입니다. + */ + static let labelInsetWidth: CGFloat = 4 + } +} diff --git a/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift new file mode 100644 index 0000000..cc2268a --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift @@ -0,0 +1,168 @@ +// +// HandyTextField.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + +public class HandyTextFieldView: UIView { + + // MARK: - 외부에서 지정할 수 있는 속성 + + /** + 텍스트 필드를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool + + /** + 텍스트 필드의 오류 상태를 표현할 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool + + /** + 텍스트 필드의 텍스트를 설정하거나 가져올 때 사용합니다. + */ + public var text: String? { + get { return textField.text } + set { textField.text = newValue } + } + + /** + 텍스트 필드의 Placeholder를 설정할 때 사용합니다. + */ + public var placeholder: String? { + get { return textField.placeholder } + set { textField.placeholder = newValue } + } + + /** + 상단 라벨 텍스트를 설정하거나 가져올 때 사용합니다. + - 값이 `nil`일 경우 라벨이 숨겨집니다. + */ + public var fieldLabelText: String? { + get { return fieldLabel.text } + set { + fieldLabel.text = newValue + fieldLabel.isHidden = newValue == nil + } + } + + /** + 하단 헬퍼 라벨 텍스트를 설정하거나 가져올 때 사용합니다. + - 값이 `nil`일 경우 라벨이 숨겨집니다. + */ + public var helperLabelText: String? { + get { return helperLabel.text } + set { + helperLabel.text = newValue + helperLabel.isHidden = newValue == nil + } + } + + // MARK: - UI 구성 요소 + + /** + 텍스트 필드와 라벨들을 담고 있는 스택 뷰입니다. + */ + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = HandyTextFieldConstants.Dimension.subviewSpacing + stackView.alignment = .fill + return stackView + }() + + /** + 텍스트 필드 상단에 위치한 라벨입니다. + */ + private let fieldLabelContainer = UIView() + + private let fieldLabel = HandyLabel(style: .B5Rg12) + + /** + 사용자 입력을 위한 기본 텍스트 필드입니다. + - 내부적으로 `HandyBaseTextField`를 사용하여 Clear 버튼 및 상태 관리를 포함합니다. + */ + public let textField = HandyBaseTextField() + + /** + 텍스트 필드 하단에 위치한 헬퍼 라벨입니다. + */ + private let helperLabelContainer = UIView() + + private let helperLabel = HandyLabel(style: .B5Rg12) + + // MARK: - 초기화 + + /** + 초기화 메소드입니다. 기본적으로 뷰의 UI 구성 요소를 설정합니다. + */ + public init() { + super.init(frame: .zero) + setupView() + updateState() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - 뷰 구성 + + /** + 뷰의 기본 UI 요소를 설정하고 제약 조건을 추가합니다. + */ + private func setupView() { + addSubview(stackView) + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + stackView.addArrangedSubview(fieldLabelContainer) + stackView.addArrangedSubview(textField) + stackView.addArrangedSubview(helperLabelContainer) + + fieldLabelContainer.addSubview(fieldLabel) + fieldLabel.snp.makeConstraints { + $0.leading.equalToSuperview().inset(HandyTextFieldConstants.Dimension.labelInsetWidth) + $0.trailing.verticalEdges.equalToSuperview() + } + + helperLabelContainer.addSubview(helperLabel) + helperLabel.snp.makeConstraints { + $0.leading.equalToSuperview().inset(HandyTextFieldConstants.Dimension.labelInsetWidth) + $0.trailing.verticalEdges.equalToSuperview() + } + + self.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(HandyTextFieldConstants.Dimension.textFieldHeight) + } + } + + // MARK: - 상태 관리 + + /** + `isDisabled` 및 `isNegative` 속성에 따라 라벨과 텍스트 필드 상태를 업데이트합니다. + */ + private func updateState() { + textField.isDisabled = isDisabled + textField.isNegative = isNegative + + fieldLabel.textColor = HandySemantic.textBasicTertiary + helperLabel.textColor = HandySemantic.textBasicTertiary + + if isNegative { + helperLabel.textColor = HandySemantic.lineStatusNegative + } + } + + // MARK: - Overridden Methods + + public override func draw(_ rect: CGRect) { + super.draw(rect) + updateState() + } +} diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift new file mode 100644 index 0000000..b88bad9 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift @@ -0,0 +1,200 @@ +// +// HandyBaseTextView.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + +public class HandyBaseTextView: UITextView { + // MARK: - 외부에서 지정할 수 있는 속성 + + /** + 외부에서 지정한 UITextViewDelegate를 저장합니다. + */ + public weak var externalDelegate: UITextViewDelegate? + + /** + UITextView의 delegate속성을 오버라이드하여, 외부 Delegate와 내부 Delegate의 충돌을 방지합니다. + - 외부에서 Delegate를 설정할 경우, 내부적으로 externalDelegate에 저장하고, HandyBaseTextView가 실제 Delegate 역할을 수행합니다. + - HandyBaseTextView가 Delegate 메서드를 먼저 처리한 후, 외부 Delegate가 호출됩니다. + */ + public override var delegate: UITextViewDelegate? { + didSet { + if delegate !== self { + externalDelegate = delegate + super.delegate = self + } + } + } + + /** + 텍스트 뷰를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool + + /** + 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool + + /** + 텍스트 뷰의 플레이스홀더를 설정할 때 사용합니다. + */ + public var placeholder: String? { + didSet { setupPlaceholder() } + } + + /** + 플레이스홀더 텍스트 색상을 설정할 때 사용합니다. + */ + public var placeholderColor: UIColor = HandySemantic.textBasicTertiary { + didSet { + placeholderLabel?.textColor = placeholderColor + } + } + + public var maxHeight: CGFloat? { + didSet { + invalidateIntrinsicContentSize() + } + } + + /** + 플레이스홀더 텍스트를 설정할 때 사용합니다. + */ + private var placeholderLabel: UILabel? + + // MARK: - 초기화 + + public init() { + super.init(frame: .zero, textContainer: nil) + setDelegate() + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - 메소드 + + private func setDelegate() { + super.delegate = self + } + + private func setupView() { + self.delegate = self + self.font = HandyFont.B3Rg14 + self.backgroundColor = HandySemantic.bgBasicLight + self.isScrollEnabled = true + self.layer.borderWidth = 1 + self.layer.cornerRadius = HandySemantic.radiusM + self.layer.masksToBounds = true + self.layer.borderColor = HandySemantic.bgBasicLight.cgColor + self.textContainer.lineFragmentPadding = 0 + self.textContainerInset = UIEdgeInsets( + top: HandyTextViewConstants.Dimension.textContainerInset, + left: HandyTextViewConstants.Dimension.textContainerInset, + bottom: HandyTextViewConstants.Dimension.textContainerInset, + right: HandyTextViewConstants.Dimension.textContainerInset + ) + } + + private func setupPlaceholder() { + if placeholderLabel == nil { + placeholderLabel = UILabel() + placeholderLabel?.textColor = placeholderColor + placeholderLabel?.font = self.font + placeholderLabel?.numberOfLines = 0 + placeholderLabel?.text = placeholder + guard let label = placeholderLabel else { return } + self.addSubview(label) + + label.snp.makeConstraints { + $0.edges.equalToSuperview().inset(textContainerInset) + } + } else { + placeholderLabel?.text = placeholder + } + } + + private func updateState() { + self.layer.cornerRadius = HandySemantic.radiusM + self.layer.masksToBounds = true + + if isDisabled { + self.isEditable = false + self.layer.borderColor = HandySemantic.bgBasicLight.cgColor + placeholderLabel?.textColor = HandySemantic.textBasicDisabled + } else if isNegative { + self.isEditable = true + self.layer.borderColor = HandySemantic.lineStatusNegative.cgColor + placeholderLabel?.textColor = HandySemantic.textBasicTertiary + } else { + self.isEditable = true + self.layer.borderColor = HandySemantic.bgBasicLight.cgColor + placeholderLabel?.textColor = HandySemantic.textBasicTertiary + } + } + + private func textDidChange() { + placeholderLabel?.isHidden = !text.isEmpty + } + + // MARK: - Overridden Methods + + public override func draw(_ rect: CGRect) { + super.draw(rect) + + updateState() + } + + public override func layoutSubviews() { + super.layoutSubviews() + + if let maxHeight = maxHeight { + isScrollEnabled = contentSize.height > maxHeight + + scrollIndicatorInsets = UIEdgeInsets( + top: 0, + left: 0, + bottom: 0, + right: HandyTextViewConstants.Dimension.scrollIndicatorInsets + ) + } else { + isScrollEnabled = false + } + } +} + +// MARK: - UITextViewDelegate + +extension HandyBaseTextView: UITextViewDelegate { + public func textViewDidBeginEditing(_ textView: UITextView) { + self.layer.borderColor = HandySemantic.lineStatusPositive.cgColor + externalDelegate?.textViewDidBeginEditing?(textView) + } + + public func textViewDidEndEditing(_ textView: UITextView) { + updateState() + externalDelegate?.textViewDidEndEditing?(textView) + } + + public func textViewDidChange(_ textView: UITextView) { + textDidChange() + externalDelegate?.textViewDidChange?(textView) + + if let isValid = (self.superview as? HandyTextView)?.validationDelegate?.handyTextView(self.superview as! HandyTextView, isValidText: textView.text) { + isNegative = !isValid + } + + if isNegative { + self.layer.borderColor = HandySemantic.lineStatusNegative.cgColor + } else { + self.layer.borderColor = HandySemantic.lineStatusPositive.cgColor + } + } +} diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift new file mode 100644 index 0000000..0533870 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift @@ -0,0 +1,214 @@ +// +// HandyTextView.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + + +// MARK: - Delegate +/** + HandyTextView의 Delegate를 통해 텍스트 변경, 유효성 검사, 편집 시작/종료 등의 이벤트를 처리할 수 있습니다. + */ + +public protocol HandyTextViewEditingDelegate: AnyObject { + /** + 텍스트가 시작 또는 종료될 때 호출합니다. + */ + func handyTextViewDidBeginEditing(_ handyTextView: HandyTextView) + func handyTextViewDidEndEditing(_ handyTextView: HandyTextView) +} + +public protocol HandyTextViewValidationDelegate: AnyObject { + /** + 입력 텍스트가 특정 조건에 만족하지 않을 때 호출합니다. + */ + func handyTextView(_ handyTextView: HandyTextView, isValidText text: String) -> Bool + func handyTextView(_ handyTextView: HandyTextView, didFailValidationWithError error: String) +} + +public protocol HandyTextViewTextChangeDelegate: AnyObject { + /** + 텍스트가 변경될 때 호출합니다. + */ + func handyTextViewDidChange(_ handyTextView: HandyTextView, text: String) +} + +public class HandyTextView: UIView { + // MARK: - 외부에서 지정할 수 있는 속성 + + public weak var editingDelegate: HandyTextViewEditingDelegate? + public weak var validationDelegate: HandyTextViewValidationDelegate? + public weak var textChangeDelegate: HandyTextViewTextChangeDelegate? + + /** + 텍스트 필드를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool + + /** + 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. + */ + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool + + /** + 텍스트 필드의 텍스트를 설정하거나 가져올때 사용합니다. + */ + public var text: String? { + get { return textView.text } + set { textView.text = newValue + validateText() } + } + + /** + 텍스트 필드의 Placeholder를 설정합니다. + */ + public var placeholder: String? { + get { return textView.placeholder } + set { textView.placeholder = newValue } + } + + /** + 플레이스홀더 색상을 설정할때 사용합니다. + */ + public var placeholderColor: UIColor { + get { return textView.placeholderColor } + set { textView.placeholderColor = newValue } + } + + /** + 하단 헬퍼 라벨의 텍스트를 설정할때 사용합니다. + - 값이 `nil`일 경우 라벨이 숨겨집니다. + */ + public var helperLabelText: String? { + get { return helperLabel.text } + set { + helperLabel.text = newValue + helperLabel.isHidden = newValue == nil + } + } + + // MARK: - 내부에서 사용되는 컴포넌트 + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = HandyTextViewConstants.Dimension.subviewSpacing + stackView.alignment = .fill + return stackView + }() + + public let textView = HandyBaseTextView() + private let helperLabelContainer = UIView() + private let helperLabel = HandyLabel(style: .B5Rg12) + + // MARK: - 초기화 + + public init() { + super.init(frame: .zero) + setDelegate() + setupView() + updateState() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - 메소드 + + private func setDelegate() { + textView.delegate = self + } + + private func setupView() { + addSubview(stackView) + + stackView.snp.makeConstraints { $0.edges.equalToSuperview() } + + stackView.addArrangedSubview(textView) + + if !helperLabel.isHidden { + stackView.addArrangedSubview(helperLabelContainer) + + helperLabelContainer.addSubview(helperLabel) + helperLabel.snp.makeConstraints { + $0.leading.equalToSuperview().inset(HandyTextViewConstants.Dimension.labelInsetWidth) + $0.top.verticalEdges.equalToSuperview() + } + + let textViewHeight = HandyTextViewConstants.Dimension.textViewHeight + let helperLabelHeight = HandyTextViewConstants.Dimension.helperLabelHeight + let subviewSpacing = HandyTextViewConstants.Dimension.subviewSpacing + + self.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(textViewHeight + helperLabelHeight + subviewSpacing) + } + } else { + self.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(HandyTextViewConstants.Dimension.textViewHeight) + } + } + } + + private func updateState() { + textView.isDisabled = isDisabled + textView.isNegative = isNegative + helperLabel.textColor = isNegative ? HandySemantic.lineStatusNegative : HandySemantic.textBasicTertiary + } + + private func validateText() { + guard let text = textView.text else { return } + if let isValid = validationDelegate?.handyTextView(self, isValidText: text) { + isNegative = !isValid + + if !isValid { + validationDelegate?.handyTextView(self, didFailValidationWithError: "유효하지 않은 입력입니다.") + } + } + } + + // MARK: - Overridden Methods + + public override func layoutSubviews() { + super.updateConstraints() + + let maxConstraints = self.constraints.filter { + $0.firstAttribute == .height && $0.relation == .lessThanOrEqual + } + + if !maxConstraints.isEmpty { + maxConstraints.forEach { maxConstraint in + let helperHeight = helperLabel.isHidden ? 0 : ( + HandyTextViewConstants.Dimension.helperLabelHeight + HandyTextViewConstants.Dimension.subviewSpacing + ) + textView.maxHeight = maxConstraint.constant - helperHeight + } + } + } + + public override func draw(_ rect: CGRect) { + super.draw(rect) + updateState() + } +} + +// MARK: - UITextViewDelegate + +extension HandyTextView: UITextViewDelegate { + public func textViewDidChange(_ textView: UITextView) { + textChangeDelegate?.handyTextViewDidChange(self, text: textView.text ?? "") + validateText() + } + + public func textViewDidBeginEditing(_ textView: UITextView) { + editingDelegate?.handyTextViewDidBeginEditing(self) + } + + public func textViewDidEndEditing(_ textView: UITextView) { + editingDelegate?.handyTextViewDidEndEditing(self) + } +} diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift new file mode 100644 index 0000000..6e48581 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift @@ -0,0 +1,42 @@ +// +// HandyTextViewConstants.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit + +internal struct HandyTextViewConstants { + internal enum Dimension { + /** + 텍스트뷰의 텍스트 입력 영역 여백 값입니다. + */ + static let textContainerInset: CGFloat = 16 + + /** + 텍스트 뷰 최소 높이입니다. + */ + static let textViewHeight: CGFloat = 48 + + /** + placeholder 높이입니다. + */ + static let helperLabelHeight: CGFloat = 18 + + /** + 텍스트뷰의 스크롤바 여백 값입니다. + */ + static let scrollIndicatorInsets: CGFloat = 8 + + /** + Label, TextView 내부 요소 간 간격입니다. + */ + static let subviewSpacing: CGFloat = 4 + + /** + Helper text가 TextView보다 왼쪽으로 더 들어가있는 Inset 값 입니다. + */ + static let labelInsetWidth: CGFloat = 4 + } +}