From fb0df990c566ba56412f6ea34271289b2e38b0ab Mon Sep 17 00:00:00 2001 From: wjdalswl <109158284+wjdalswl@users.noreply.github.com> Date: Thu, 2 Jan 2025 13:44:02 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#21]=20HandyTextField=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Atom/TextFieldViewController.swift | 85 +++++++ Handy/Handy.xcodeproj/project.pbxproj | 24 ++ .../HandyTextField/HandyBaseTextField.swift | 226 ++++++++++++++++++ .../HandyTextFieldConstants.swift | 48 ++++ .../HandyTextField/HandyTextFieldView.swift | 170 +++++++++++++ 5 files changed, 553 insertions(+) create mode 100644 Handy/Handy-Storybook/Atom/TextFieldViewController.swift create mode 100644 Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift create mode 100644 Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldConstants.swift create mode 100644 Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift 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.xcodeproj/project.pbxproj b/Handy/Handy.xcodeproj/project.pbxproj index fd42298..a210df7 100644 --- a/Handy/Handy.xcodeproj/project.pbxproj +++ b/Handy/Handy.xcodeproj/project.pbxproj @@ -38,6 +38,10 @@ 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 */; }; 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 +119,10 @@ 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 = ""; }; 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 +181,7 @@ 2D41E8132C5A21930043161D /* FabViewController.swift */, 02ED764B2C57BD09001569F1 /* HandyBoxButtonViewController.swift */, A5A12A7C2C57A6C200996916 /* ChipViewController.swift */, + 2D88118E2D2642F900B0B517 /* TextFieldViewController.swift */, A5F6D36C2C97099C00FB961F /* DividerViewController.swift */, E51FBF9A2C5399A00097B0DA /* CheckBoxViewController.swift */, E51FBFA12C54CD350097B0DA /* RadioButtonViewController.swift */, @@ -228,6 +237,7 @@ 029E47FE2C49FD2E00D2F3B7 /* Atom */ = { isa = PBXGroup; children = ( + 2D8811872D26428500B0B517 /* HandyTextField */, 02ED762F2C52849A001569F1 /* HandyButton */, 029E47FC2C49FD1A00D2F3B7 /* HandyLabel.swift */, 2D41E8152C5A21B50043161D /* HandyFab.swift */, @@ -313,6 +323,16 @@ path = Extension; sourceTree = ""; }; + 2D8811872D26428500B0B517 /* HandyTextField */ = { + isa = PBXGroup; + children = ( + 2D8811882D2642A800B0B517 /* HandyTextFieldView.swift */, + 2D88118C2D2642CE00B0B517 /* HandyBaseTextField.swift */, + 2D88118A2D2642BC00B0B517 /* HandyTextFieldConstants.swift */, + ); + path = HandyTextField; + sourceTree = ""; + }; E5650D412C4D30B9002790CC /* Asset */ = { isa = PBXGroup; children = ( @@ -455,6 +475,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 */, @@ -486,6 +507,7 @@ E5D02AFD2C46C5A70056CE7B /* HandySematic.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 +516,12 @@ 02ED764A2C5779C3001569F1 /* UIImage+.swift in Sources */, 029E48002C49FD4000D2F3B7 /* HandyTypography.swift in Sources */, E5650D432C4D326D002790CC /* HandyCheckBox.swift in Sources */, + 2D88118D2D2642CE00B0B517 /* HandyBaseTextField.swift in Sources */, 029E47FD2C49FD1A00D2F3B7 /* HandyLabel.swift in Sources */, A56B3DE22C4E51D300C3610A /* HandyChip.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..644a66a --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift @@ -0,0 +1,226 @@ +// +// HandyBaseTextField.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit + +public class HandyBaseTextField: UITextField { + + // MARK: - 외부에서 지정할 수 있는 속성 + + /** + 텍스트 필드를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(.layout) public var isDisabled: Bool = false { + didSet { + updateState() + } + } + + /** + 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. + */ + @Invalidating(.layout) public var isNegative: Bool = false { + didSet { + updateState() + } + } + + // 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: - 초기화 + + /** + 초기화 메소드입니다. 기본적인 텍스트 필드 속성과 Clear 버튼을 설정합니다. + */ + 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.cornerRadius = HandySemantic.radiusM + self.layer.borderWidth = 1 + self.layer.borderColor = HandySemantic.bgBasicLight.cgColor + self.backgroundColor = HandySemantic.bgBasicLight + self.clipsToBounds = true + 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 + )) + } +} + +// 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..5ee4cb6 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift @@ -0,0 +1,170 @@ +// +// HandyTextField.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + +public class HandyTextFieldView: UIView { + + // MARK: - 외부에서 지정할 수 있는 속성 + + /** + 텍스트 필드를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(.layout) public var isDisabled: Bool = false { + didSet { + updateState() + } + } + + /** + 텍스트 필드의 오류 상태를 표현할 때 사용합니다. + */ + @Invalidating(.layout) public var isNegative: Bool = false { + didSet { + updateState() + } + } + + /** + 텍스트 필드의 텍스트를 설정하거나 가져올 때 사용합니다. + */ + 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 + } + } +} + From 9a57a6eeabf6b7c3573202e281c7edc95b90f405 Mon Sep 17 00:00:00 2001 From: wjdalswl <109158284+wjdalswl@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:10:10 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[#21]=20HandyTextView=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Atom/TextViewController.swift | 79 ++++++++ Handy/Handy.xcodeproj/project.pbxproj | 24 +++ .../HandyTextView/HandyBaseTextView.swift | 179 ++++++++++++++++++ .../Atom/HandyTextView/HandyTextView.swift | 141 ++++++++++++++ .../HandyTextViewConstants.swift | 37 ++++ 5 files changed, 460 insertions(+) create mode 100644 Handy/Handy-Storybook/Atom/TextViewController.swift create mode 100644 Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift create mode 100644 Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift create mode 100644 Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift diff --git a/Handy/Handy-Storybook/Atom/TextViewController.swift b/Handy/Handy-Storybook/Atom/TextViewController.swift new file mode 100644 index 0000000..edf23e0 --- /dev/null +++ b/Handy/Handy-Storybook/Atom/TextViewController.swift @@ -0,0 +1,79 @@ +// +// 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 = "Helper text" + textView.placeholderColor = .lightGray + textView.minHeight = 187 + textView.maxHeight = 187 + + return textView + }() + + + private let errorTextView: HandyTextView = { + let textView = HandyTextView() + textView.placeholder = "Input text" + textView.helperLabelText = "Helper text" + textView.placeholderColor = .lightGray + textView.isNegative = true + textView.maxHeight = 80 + + return textView + }() + + private let disabledTextView: HandyTextView = { + let textView = HandyTextView() + textView.placeholder = "Input text" + textView.helperLabelText = "Helper text" + textView.placeholderColor = .lightGray + textView.isDisabled = true + textView.maxHeight = 187 + + return textView + }() + + + override func viewDidLoad() { + super.viewDidLoad() + setViewLayouts() + } + + override func setViewHierarchies() { + [ + defaultTextView, errorTextView, disabledTextView + ].forEach { + view.addSubview($0) + } + } + + override func setViewLayouts() { + defaultTextView.snp.makeConstraints { + $0.top.equalToSuperview().offset(100) + $0.horizontalEdges.equalToSuperview().inset(20) + } + errorTextView.snp.makeConstraints { + $0.top.equalTo(defaultTextView.snp.bottom).offset(20) + $0.horizontalEdges.equalToSuperview().inset(20) + } + disabledTextView.snp.makeConstraints { + $0.top.equalTo(errorTextView.snp.bottom).offset(20) + $0.horizontalEdges.equalToSuperview().inset(20) + } + } +} + + diff --git a/Handy/Handy.xcodeproj/project.pbxproj b/Handy/Handy.xcodeproj/project.pbxproj index a210df7..d172163 100644 --- a/Handy/Handy.xcodeproj/project.pbxproj +++ b/Handy/Handy.xcodeproj/project.pbxproj @@ -42,6 +42,10 @@ 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 */; }; @@ -123,6 +127,10 @@ 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 = ""; }; @@ -181,6 +189,7 @@ 2D41E8132C5A21930043161D /* FabViewController.swift */, 02ED764B2C57BD09001569F1 /* HandyBoxButtonViewController.swift */, A5A12A7C2C57A6C200996916 /* ChipViewController.swift */, + 2D8811902D26512500B0B517 /* TextViewController.swift */, 2D88118E2D2642F900B0B517 /* TextFieldViewController.swift */, A5F6D36C2C97099C00FB961F /* DividerViewController.swift */, E51FBF9A2C5399A00097B0DA /* CheckBoxViewController.swift */, @@ -237,6 +246,7 @@ 029E47FE2C49FD2E00D2F3B7 /* Atom */ = { isa = PBXGroup; children = ( + 2D8811922D26514B00B0B517 /* HandyTextView */, 2D8811872D26428500B0B517 /* HandyTextField */, 02ED762F2C52849A001569F1 /* HandyButton */, 029E47FC2C49FD1A00D2F3B7 /* HandyLabel.swift */, @@ -333,6 +343,16 @@ 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 = ( @@ -489,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 */, ); @@ -505,6 +526,7 @@ 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 */, @@ -516,9 +538,11 @@ 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 */, diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift new file mode 100644 index 0000000..ef0ec9f --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift @@ -0,0 +1,179 @@ +// +// HandyBaseTextView.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + +public class HandyBaseTextView: UITextView { + // MARK: - 외부에서 지정할 수 있는 속성 + + /** + 텍스트 뷰를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(.layout) public var isDisabled: Bool = false { + didSet { updateState() } + } + + /** + 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. + */ + @Invalidating(.layout) public var isNegative: Bool = false { + didSet { updateState() } + } + + /** + 텍스트 뷰의 최소 높이를 설정할 때 사용합니다. + */ + @Invalidating(.layout) public var minHeight: CGFloat? = 48 + + /** + 텍스트 뷰의 최대 높이를 설정할 때 사용합니다. + */ + @Invalidating(.layout) public var maxHeight: CGFloat? = nil + + /** + 텍스트 뷰의 플레이스홀더를 설정할 때 사용합니다. + */ + public var placeholder: String? { + didSet { setupPlaceholder() } + } + + /** + 플레이스홀더 텍스트 색상을 설정할 때 사용합니다. + */ + public var placeholderColor: UIColor = HandySemantic.textBasicTertiary { + didSet { + placeholderLabel?.textColor = placeholderColor + } + } + + /** + 플레이스홀더 텍스트를 설정할 때 사용합니다. + */ + private var placeholderLabel: UILabel? + + // MARK: - 메소드 + + public init() { + super.init(frame: .zero, textContainer: nil) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func setupView() { + self.delegate = self + self.font = HandyFont.B3Rg14 + self.backgroundColor = HandySemantic.bgBasicLight + self.isScrollEnabled = false + self.layer.cornerRadius = HandySemantic.radiusM + self.layer.borderWidth = 1 + 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) + } + + NotificationCenter.default.addObserver(self, + selector: #selector(textDidChange), + name: UITextView.textDidChangeNotification, + object: nil) + } else { + placeholderLabel?.text = placeholder + } + } + + private func updateState() { + 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 + } + } + + + public override func layoutSubviews() { + super.layoutSubviews() + + if let minHeight = minHeight, let maxHeight = maxHeight { + isScrollEnabled = contentSize.height > maxHeight || contentSize.height < minHeight + } else if let maxHeight = maxHeight { + isScrollEnabled = contentSize.height > maxHeight + } + + else if let minHeight = minHeight, bounds.height < minHeight { + invalidateIntrinsicContentSize() + frame.size.height = minHeight + } + + else if let maxHeight = maxHeight, bounds.height > maxHeight { + invalidateIntrinsicContentSize() + frame.size.height = maxHeight + } + + scrollIndicatorInsets = UIEdgeInsets( + top: 0, + left: 0, + bottom: 0, + right: HandyTextViewConstants.Dimension.scrollIndicatorInsets + ) + } + + @objc private func textDidChange() { + placeholderLabel?.isHidden = !text.isEmpty + } +} + +// MARK: - UITextViewDelegate + +extension HandyBaseTextView: UITextViewDelegate { + public func textViewDidBeginEditing(_ textView: UITextView) { + if !isNegative { + self.layer.borderColor = HandySemantic.lineStatusPositive.cgColor + } + } + + public func textViewDidEndEditing(_ textView: UITextView) { + updateState() + } + + public func textViewDidChange(_ textView: UITextView) { + textDidChange() + } +} diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift new file mode 100644 index 0000000..5a90b71 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift @@ -0,0 +1,141 @@ +// +// HandyTextView.swift +// Handy +// +// Created by 정민지 on 11/18/24. +// + +import UIKit +import SnapKit + +public class HandyTextView: UIView { + + // MARK: - 외부에서 지정할 수 있는 속성 + + /** + 텍스트 필드를 비활성화 시킬 때 사용합니다. + */ + @Invalidating(.layout) public var isDisabled: Bool = false { + didSet { updateState() } + } + + /** + 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. + */ + @Invalidating(.layout) public var isNegative: Bool = false { + didSet { updateState() } + } + + /** + 텍스트 뷰의 최소 높이를 설정합니다. + */ + public var minHeight: CGFloat? { + didSet { + guard let minHeight = minHeight else { return } + textView.snp.updateConstraints { + $0.height.greaterThanOrEqualTo(minHeight) + } + } + } + + /** + 텍스트 뷰의 최대 높이를 설정합니다. + */ + public var maxHeight: CGFloat? { + get { return textView.maxHeight } + set { textView.maxHeight = newValue } + } + + /** + 텍스트 필드의 텍스트를 설정하거나 가져올때 사용합니다. + */ + public var text: String? { + get { return textView.text } + set { textView.text = newValue } + } + + /** + 텍스트 필드의 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) + setupView() + updateState() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + addSubview(stackView) + + stackView.snp.makeConstraints { $0.edges.equalToSuperview()} + + stackView.addArrangedSubview(textView) + stackView.addArrangedSubview(helperLabelContainer) + + textView.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(minHeight ?? HandyTextViewConstants.Dimension.textViewHeight) + if let maxHeight = maxHeight { + $0.height.lessThanOrEqualTo(maxHeight) + } + } + helperLabelContainer.addSubview(helperLabel) + helperLabel.snp.makeConstraints { + $0.leading.equalToSuperview().inset(HandyTextViewConstants.Dimension.labelInsetWidth) + $0.top.verticalEdges.equalToSuperview() + } + + self.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(minHeight ?? HandyTextViewConstants.Dimension.textViewHeight) + } + } + + private func updateState() { + textView.isDisabled = isDisabled + textView.isNegative = isNegative + helperLabel.textColor = isNegative ? HandySemantic.lineStatusNegative : HandySemantic.textBasicTertiary + } +} diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift new file mode 100644 index 0000000..fd0fe21 --- /dev/null +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift @@ -0,0 +1,37 @@ +// +// 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 + + /** + 텍스트뷰의 스크롤바 여백 값입니다. + */ + static let scrollIndicatorInsets: CGFloat = 8 + + /** + Label, TextView 내부 요소 간 간격입니다. + */ + static let subviewSpacing: CGFloat = 4 + + /** + Helper text가 TextView보다 왼쪽으로 더 들어가있는 Inset 값 입니다. + */ + static let labelInsetWidth: CGFloat = 4 + } +} From 580d4ec1b4be44a016ca78b155c5abe45cb53111 Mon Sep 17 00:00:00 2001 From: wjdalswl <109158284+wjdalswl@users.noreply.github.com> Date: Mon, 13 Jan 2025 20:06:06 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[#21]=20NotificationCenter=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Source/Atom/HandyTextView/HandyBaseTextView.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift index ef0ec9f..cba78b0 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift @@ -67,10 +67,6 @@ public class HandyBaseTextView: UITextView { fatalError("init(coder:) has not been implemented") } - deinit { - NotificationCenter.default.removeObserver(self) - } - private func setupView() { self.delegate = self self.font = HandyFont.B3Rg14 @@ -102,10 +98,6 @@ public class HandyBaseTextView: UITextView { $0.edges.equalToSuperview().inset(textContainerInset) } - NotificationCenter.default.addObserver(self, - selector: #selector(textDidChange), - name: UITextView.textDidChangeNotification, - object: nil) } else { placeholderLabel?.text = placeholder } @@ -155,7 +147,7 @@ public class HandyBaseTextView: UITextView { ) } - @objc private func textDidChange() { + private func textDidChange() { placeholderLabel?.isHidden = !text.isEmpty } } From c7f04c3e447c4598de707b0ff5cf61b465d242d8 Mon Sep 17 00:00:00 2001 From: wjdalswl <109158284+wjdalswl@users.noreply.github.com> Date: Mon, 13 Jan 2025 21:04:22 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[#21]=20@Invalidating=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HandyTextField/HandyBaseTextField.swift | 72 +++++++++---------- .../HandyTextField/HandyTextFieldView.swift | 63 ++++++++-------- .../HandyTextView/HandyBaseTextView.swift | 35 ++++----- .../Atom/HandyTextView/HandyTextView.swift | 48 ++++++------- 4 files changed, 106 insertions(+), 112 deletions(-) diff --git a/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift b/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift index 644a66a..5a6ca58 100644 --- a/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift @@ -8,29 +8,20 @@ import UIKit public class HandyBaseTextField: UITextField { - + // MARK: - 외부에서 지정할 수 있는 속성 - + /** 텍스트 필드를 비활성화 시킬 때 사용합니다. */ - @Invalidating(.layout) public var isDisabled: Bool = false { - didSet { - updateState() - } - } - + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool /** 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. */ - @Invalidating(.layout) public var isNegative: Bool = false { - didSet { - updateState() - } - } - + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool + // MARK: - 내부에서 사용되는 뷰 - + /** 텍스트 필드 내의 입력을 초기화할 때 사용하는 Clear 버튼입니다. */ @@ -41,9 +32,9 @@ public class HandyBaseTextField: UITextField { button.isHidden = true return button }() - + // MARK: - 초기화 - + /** 초기화 메소드입니다. 기본적인 텍스트 필드 속성과 Clear 버튼을 설정합니다. */ @@ -54,30 +45,30 @@ public class HandyBaseTextField: UITextField { 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.cornerRadius = HandySemantic.radiusM self.layer.borderWidth = 1 + self.layer.cornerRadius = HandySemantic.radiusM + self.layer.masksToBounds = true self.layer.borderColor = HandySemantic.bgBasicLight.cgColor self.backgroundColor = HandySemantic.bgBasicLight - self.clipsToBounds = true 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 @@ -96,12 +87,12 @@ public class HandyBaseTextField: UITextField { .foregroundColor: color, .font: HandyFont.B1Rg16 ] - + if let placeholder = self.placeholder { self.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes) } } - + /** Clear 버튼을 설정합니다. - Clear 버튼은 텍스트 필드 오른쪽에 위치하며, 텍스트 입력 상태에 따라 표시됩니다. @@ -109,18 +100,18 @@ public class HandyBaseTextField: UITextField { 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`: 비활성화 상태를 나타냅니다. @@ -136,7 +127,7 @@ public class HandyBaseTextField: UITextField { clearButton.isHidden = true return } - + if isNegative { self.isUserInteractionEnabled = true self.layer.borderColor = HandySemantic.lineStatusNegative.cgColor @@ -145,16 +136,16 @@ public class HandyBaseTextField: UITextField { 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 버튼이 눌렸을 때 호출됩니다. @@ -163,7 +154,7 @@ public class HandyBaseTextField: UITextField { self.text = "" clearButton.isHidden = true } - + /** 텍스트 필드의 텍스트 변경 시 호출됩니다. - 텍스트가 입력되거나 삭제될 때 Clear 버튼의 표시 상태를 업데이트합니다. @@ -171,9 +162,9 @@ public class HandyBaseTextField: UITextField { @objc private func textDidChange() { clearButton.isHidden = self.text?.isEmpty ?? true } - + // MARK: - Overridden Methods - + /** Placeholder 및 텍스트 레이아웃을 설정합니다. */ @@ -185,7 +176,7 @@ public class HandyBaseTextField: UITextField { right: HandyTextFieldConstants.Dimension.rightMargin )) } - + /** 텍스트 입력 시 레이아웃을 설정합니다. */ @@ -197,6 +188,11 @@ public class HandyBaseTextField: UITextField { right: HandyTextFieldConstants.Dimension.rightMargin )) } + + public override func draw(_ rect: CGRect) { + super.draw(rect) + updateState() + } } // MARK: - UITextFieldDelegate diff --git a/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift index 5ee4cb6..0a83ea2 100644 --- a/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift @@ -9,27 +9,19 @@ import UIKit import SnapKit public class HandyTextFieldView: UIView { - + // MARK: - 외부에서 지정할 수 있는 속성 - + /** 텍스트 필드를 비활성화 시킬 때 사용합니다. */ - @Invalidating(.layout) public var isDisabled: Bool = false { - didSet { - updateState() - } - } - + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool + /** 텍스트 필드의 오류 상태를 표현할 때 사용합니다. */ - @Invalidating(.layout) public var isNegative: Bool = false { - didSet { - updateState() - } - } - + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool + /** 텍스트 필드의 텍스트를 설정하거나 가져올 때 사용합니다. */ @@ -37,7 +29,7 @@ public class HandyTextFieldView: UIView { get { return textField.text } set { textField.text = newValue } } - + /** 텍스트 필드의 Placeholder를 설정할 때 사용합니다. */ @@ -45,7 +37,7 @@ public class HandyTextFieldView: UIView { get { return textField.placeholder } set { textField.placeholder = newValue } } - + /** 상단 라벨 텍스트를 설정하거나 가져올 때 사용합니다. - 값이 `nil`일 경우 라벨이 숨겨집니다. @@ -57,7 +49,7 @@ public class HandyTextFieldView: UIView { fieldLabel.isHidden = newValue == nil } } - + /** 하단 헬퍼 라벨 텍스트를 설정하거나 가져올 때 사용합니다. - 값이 `nil`일 경우 라벨이 숨겨집니다. @@ -69,9 +61,9 @@ public class HandyTextFieldView: UIView { helperLabel.isHidden = newValue == nil } } - + // MARK: - UI 구성 요소 - + /** 텍스트 필드와 라벨들을 담고 있는 스택 뷰입니다. */ @@ -82,29 +74,29 @@ public class HandyTextFieldView: UIView { 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 구성 요소를 설정합니다. */ @@ -113,13 +105,13 @@ public class HandyTextFieldView: UIView { setupView() updateState() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: - 뷰 구성 - + /** 뷰의 기본 UI 요소를 설정하고 제약 조건을 추가합니다. */ @@ -128,7 +120,7 @@ public class HandyTextFieldView: UIView { stackView.snp.makeConstraints { $0.edges.equalToSuperview() } - + stackView.addArrangedSubview(fieldLabelContainer) stackView.addArrangedSubview(textField) stackView.addArrangedSubview(helperLabelContainer) @@ -138,7 +130,7 @@ public class HandyTextFieldView: UIView { $0.leading.equalToSuperview().inset(HandyTextFieldConstants.Dimension.labelInsetWidth) $0.trailing.verticalEdges.equalToSuperview() } - + helperLabelContainer.addSubview(helperLabel) helperLabel.snp.makeConstraints { $0.leading.equalToSuperview().inset(HandyTextFieldConstants.Dimension.labelInsetWidth) @@ -149,16 +141,16 @@ public class HandyTextFieldView: UIView { $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 @@ -166,5 +158,12 @@ public class HandyTextFieldView: UIView { 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 index cba78b0..049f968 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift @@ -14,26 +14,22 @@ public class HandyBaseTextView: UITextView { /** 텍스트 뷰를 비활성화 시킬 때 사용합니다. */ - @Invalidating(.layout) public var isDisabled: Bool = false { - didSet { updateState() } - } + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool /** 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. */ - @Invalidating(.layout) public var isNegative: Bool = false { - didSet { updateState() } - } + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool /** 텍스트 뷰의 최소 높이를 설정할 때 사용합니다. */ - @Invalidating(.layout) public var minHeight: CGFloat? = 48 + @Invalidating(wrappedValue: HandyTextViewConstants.Dimension.textViewHeight, .layout) public var minHeight: CGFloat? /** 텍스트 뷰의 최대 높이를 설정할 때 사용합니다. */ - @Invalidating(.layout) public var maxHeight: CGFloat? = nil + @Invalidating(wrappedValue: nil, .layout) public var maxHeight: CGFloat? /** 텍스트 뷰의 플레이스홀더를 설정할 때 사용합니다. @@ -72,8 +68,9 @@ public class HandyBaseTextView: UITextView { self.font = HandyFont.B3Rg14 self.backgroundColor = HandySemantic.bgBasicLight self.isScrollEnabled = false - self.layer.cornerRadius = HandySemantic.radiusM 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( @@ -119,21 +116,26 @@ public class HandyBaseTextView: UITextView { } } - + private func textDidChange() { + placeholderLabel?.isHidden = !text.isEmpty + } + + // MARK: - Overridden Methods + public override func layoutSubviews() { super.layoutSubviews() - + if let minHeight = minHeight, let maxHeight = maxHeight { isScrollEnabled = contentSize.height > maxHeight || contentSize.height < minHeight } else if let maxHeight = maxHeight { isScrollEnabled = contentSize.height > maxHeight } - - else if let minHeight = minHeight, bounds.height < minHeight { + + else if let minHeight = minHeight, bounds.height < minHeight { invalidateIntrinsicContentSize() frame.size.height = minHeight } - + else if let maxHeight = maxHeight, bounds.height > maxHeight { invalidateIntrinsicContentSize() frame.size.height = maxHeight @@ -147,8 +149,9 @@ public class HandyBaseTextView: UITextView { ) } - private func textDidChange() { - placeholderLabel?.isHidden = !text.isEmpty + public override func draw(_ rect: CGRect) { + super.draw(rect) + updateState() } } diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift index 5a90b71..5b17876 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift @@ -9,34 +9,23 @@ import UIKit import SnapKit public class HandyTextView: UIView { - + // MARK: - 외부에서 지정할 수 있는 속성 - + /** 텍스트 필드를 비활성화 시킬 때 사용합니다. */ - @Invalidating(.layout) public var isDisabled: Bool = false { - didSet { updateState() } - } - + @Invalidating(wrappedValue: false, .display) public var isDisabled: Bool + /** 텍스트 필드의 오류 상태를 나타낼 때 사용합니다. */ - @Invalidating(.layout) public var isNegative: Bool = false { - didSet { updateState() } - } + @Invalidating(wrappedValue: false, .display) public var isNegative: Bool /** 텍스트 뷰의 최소 높이를 설정합니다. */ - public var minHeight: CGFloat? { - didSet { - guard let minHeight = minHeight else { return } - textView.snp.updateConstraints { - $0.height.greaterThanOrEqualTo(minHeight) - } - } - } + @Invalidating(wrappedValue: HandyTextViewConstants.Dimension.textViewHeight, .layout) public var minHeight: CGFloat? /** 텍스트 뷰의 최대 높이를 설정합니다. @@ -45,7 +34,7 @@ public class HandyTextView: UIView { get { return textView.maxHeight } set { textView.maxHeight = newValue } } - + /** 텍스트 필드의 텍스트를 설정하거나 가져올때 사용합니다. */ @@ -53,7 +42,7 @@ public class HandyTextView: UIView { get { return textView.text } set { textView.text = newValue } } - + /** 텍스트 필드의 Placeholder를 설정합니다. */ @@ -81,9 +70,9 @@ public class HandyTextView: UIView { helperLabel.isHidden = newValue == nil } } - + // MARK: - 내부에서 사용되는 컴포넌트 - + private let stackView: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical @@ -91,23 +80,23 @@ public class HandyTextView: UIView { 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) setupView() updateState() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func setupView() { addSubview(stackView) @@ -138,4 +127,11 @@ public class HandyTextView: UIView { textView.isNegative = isNegative helperLabel.textColor = isNegative ? HandySemantic.lineStatusNegative : HandySemantic.textBasicTertiary } + + // MARK: - Overridden Methods + + public override func draw(_ rect: CGRect) { + super.draw(rect) + updateState() + } } From 326a8d16a52ee7a281a7e28cfd1086bf511d5721 Mon Sep 17 00:00:00 2001 From: wjdalswl <109158284+wjdalswl@users.noreply.github.com> Date: Wed, 15 Jan 2025 03:23:02 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[#21]=20minHeight=EC=99=80=20maxHeight=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20constraints=20=EC=84=A4=EC=A0=95=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Atom/TextViewController.swift | 36 +++++---- .../HandyTextField/HandyBaseTextField.swift | 4 - .../HandyTextField/HandyTextFieldView.swift | 1 - .../HandyTextView/HandyBaseTextView.swift | 63 +++++++--------- .../Atom/HandyTextView/HandyTextView.swift | 75 +++++++++++-------- .../HandyTextViewConstants.swift | 5 ++ 6 files changed, 96 insertions(+), 88 deletions(-) diff --git a/Handy/Handy-Storybook/Atom/TextViewController.swift b/Handy/Handy-Storybook/Atom/TextViewController.swift index edf23e0..b71c7a9 100644 --- a/Handy/Handy-Storybook/Atom/TextViewController.swift +++ b/Handy/Handy-Storybook/Atom/TextViewController.swift @@ -11,26 +11,29 @@ import SnapKit import Handy final class TextViewController: BaseViewController { - private let defaultTextView: HandyTextView = { let textView = HandyTextView() textView.placeholder = "Input text" textView.helperLabelText = "Helper text" textView.placeholderColor = .lightGray - textView.minHeight = 187 - textView.maxHeight = 187 - - return textView - }() - + + 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 - textView.maxHeight = 80 return textView }() @@ -41,12 +44,10 @@ final class TextViewController: BaseViewController { textView.helperLabelText = "Helper text" textView.placeholderColor = .lightGray textView.isDisabled = true - textView.maxHeight = 187 return textView }() - override func viewDidLoad() { super.viewDidLoad() setViewLayouts() @@ -54,7 +55,7 @@ final class TextViewController: BaseViewController { override func setViewHierarchies() { [ - defaultTextView, errorTextView, disabledTextView + defaultTextView, noHelperLabelTextView, errorTextView, disabledTextView ].forEach { view.addSubview($0) } @@ -64,10 +65,19 @@ final class TextViewController: BaseViewController { defaultTextView.snp.makeConstraints { $0.top.equalToSuperview().offset(100) $0.horizontalEdges.equalToSuperview().inset(20) + $0.height.lessThanOrEqualTo(100) + $0.height.greaterThanOrEqualTo(100) } - errorTextView.snp.makeConstraints { + 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) @@ -75,5 +85,3 @@ final class TextViewController: BaseViewController { } } } - - diff --git a/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift b/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift index 5a6ca58..57a7654 100644 --- a/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyBaseTextField.swift @@ -35,9 +35,6 @@ public class HandyBaseTextField: UITextField { // MARK: - 초기화 - /** - 초기화 메소드입니다. 기본적인 텍스트 필드 속성과 Clear 버튼을 설정합니다. - */ public init() { super.init(frame: .zero) setupTextField() @@ -219,4 +216,3 @@ extension HandyBaseTextField: UITextFieldDelegate { updateState() } } - diff --git a/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift index 0a83ea2..cc2268a 100644 --- a/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift +++ b/Handy/Handy/Source/Atom/HandyTextField/HandyTextFieldView.swift @@ -166,4 +166,3 @@ public class HandyTextFieldView: UIView { updateState() } } - diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift index 049f968..2453c89 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift @@ -21,16 +21,6 @@ public class HandyBaseTextView: UITextView { */ @Invalidating(wrappedValue: false, .display) public var isNegative: Bool - /** - 텍스트 뷰의 최소 높이를 설정할 때 사용합니다. - */ - @Invalidating(wrappedValue: HandyTextViewConstants.Dimension.textViewHeight, .layout) public var minHeight: CGFloat? - - /** - 텍스트 뷰의 최대 높이를 설정할 때 사용합니다. - */ - @Invalidating(wrappedValue: nil, .layout) public var maxHeight: CGFloat? - /** 텍스트 뷰의 플레이스홀더를 설정할 때 사용합니다. */ @@ -47,6 +37,12 @@ public class HandyBaseTextView: UITextView { } } + public var maxHeight: CGFloat? { + didSet { + invalidateIntrinsicContentSize() + } + } + /** 플레이스홀더 텍스트를 설정할 때 사용합니다. */ @@ -67,7 +63,7 @@ public class HandyBaseTextView: UITextView { self.delegate = self self.font = HandyFont.B3Rg14 self.backgroundColor = HandySemantic.bgBasicLight - self.isScrollEnabled = false + self.isScrollEnabled = true self.layer.borderWidth = 1 self.layer.cornerRadius = HandySemantic.radiusM self.layer.masksToBounds = true @@ -94,13 +90,15 @@ public class HandyBaseTextView: UITextView { 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 @@ -122,36 +120,27 @@ public class HandyBaseTextView: UITextView { // MARK: - Overridden Methods + public override func draw(_ rect: CGRect) { + super.draw(rect) + + updateState() + } + public override func layoutSubviews() { super.layoutSubviews() - if let minHeight = minHeight, let maxHeight = maxHeight { - isScrollEnabled = contentSize.height > maxHeight || contentSize.height < minHeight - } else if let maxHeight = maxHeight { + if let maxHeight = maxHeight { isScrollEnabled = contentSize.height > maxHeight + + scrollIndicatorInsets = UIEdgeInsets( + top: 0, + left: 0, + bottom: 0, + right: HandyTextViewConstants.Dimension.scrollIndicatorInsets + ) + } else { + isScrollEnabled = false } - - else if let minHeight = minHeight, bounds.height < minHeight { - invalidateIntrinsicContentSize() - frame.size.height = minHeight - } - - else if let maxHeight = maxHeight, bounds.height > maxHeight { - invalidateIntrinsicContentSize() - frame.size.height = maxHeight - } - - scrollIndicatorInsets = UIEdgeInsets( - top: 0, - left: 0, - bottom: 0, - right: HandyTextViewConstants.Dimension.scrollIndicatorInsets - ) - } - - public override func draw(_ rect: CGRect) { - super.draw(rect) - updateState() } } diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift index 5b17876..76b2c2e 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift @@ -9,7 +9,6 @@ import UIKit import SnapKit public class HandyTextView: UIView { - // MARK: - 외부에서 지정할 수 있는 속성 /** @@ -22,19 +21,6 @@ public class HandyTextView: UIView { */ @Invalidating(wrappedValue: false, .display) public var isNegative: Bool - /** - 텍스트 뷰의 최소 높이를 설정합니다. - */ - @Invalidating(wrappedValue: HandyTextViewConstants.Dimension.textViewHeight, .layout) public var minHeight: CGFloat? - - /** - 텍스트 뷰의 최대 높이를 설정합니다. - */ - public var maxHeight: CGFloat? { - get { return textView.maxHeight } - set { textView.maxHeight = newValue } - } - /** 텍스트 필드의 텍스트를 설정하거나 가져올때 사용합니다. */ @@ -85,7 +71,7 @@ public class HandyTextView: UIView { private let helperLabelContainer = UIView() private let helperLabel = HandyLabel(style: .B5Rg12) - // MARK: - 메소드 + // MARK: - 초기화 public init() { super.init(frame: .zero) @@ -97,28 +83,35 @@ public class HandyTextView: UIView { fatalError("init(coder:) has not been implemented") } + // MARK: - 메소드 + private func setupView() { addSubview(stackView) - stackView.snp.makeConstraints { $0.edges.equalToSuperview()} - + stackView.snp.makeConstraints { $0.edges.equalToSuperview() } + stackView.addArrangedSubview(textView) - stackView.addArrangedSubview(helperLabelContainer) - textView.snp.makeConstraints { - $0.height.greaterThanOrEqualTo(minHeight ?? HandyTextViewConstants.Dimension.textViewHeight) - if let maxHeight = maxHeight { - $0.height.lessThanOrEqualTo(maxHeight) + 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) } - } - helperLabelContainer.addSubview(helperLabel) - helperLabel.snp.makeConstraints { - $0.leading.equalToSuperview().inset(HandyTextViewConstants.Dimension.labelInsetWidth) - $0.top.verticalEdges.equalToSuperview() - } - - self.snp.makeConstraints { - $0.height.greaterThanOrEqualTo(minHeight ?? HandyTextViewConstants.Dimension.textViewHeight) } } @@ -126,10 +119,28 @@ public class HandyTextView: UIView { textView.isDisabled = isDisabled textView.isNegative = isNegative helperLabel.textColor = isNegative ? HandySemantic.lineStatusNegative : HandySemantic.textBasicTertiary + } // 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() diff --git a/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift index fd0fe21..6e48581 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextViewConstants.swift @@ -19,6 +19,11 @@ internal struct HandyTextViewConstants { */ static let textViewHeight: CGFloat = 48 + /** + placeholder 높이입니다. + */ + static let helperLabelHeight: CGFloat = 18 + /** 텍스트뷰의 스크롤바 여백 값입니다. */ From 6a9531f07fa4b9d9663f4f2cebe10eb96e1839b8 Mon Sep 17 00:00:00 2001 From: wjdalswl <109158284+wjdalswl@users.noreply.github.com> Date: Wed, 15 Jan 2025 04:21:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[#21]=20Delegate=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A7=88=EC=9D=B4=EC=A7=95=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Atom/TextViewController.swift | 37 +++++++++- .../HandyTextView/HandyBaseTextView.swift | 45 ++++++++++-- .../Atom/HandyTextView/HandyTextView.swift | 70 ++++++++++++++++++- 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/Handy/Handy-Storybook/Atom/TextViewController.swift b/Handy/Handy-Storybook/Atom/TextViewController.swift index b71c7a9..a8211a6 100644 --- a/Handy/Handy-Storybook/Atom/TextViewController.swift +++ b/Handy/Handy-Storybook/Atom/TextViewController.swift @@ -14,7 +14,7 @@ final class TextViewController: BaseViewController { private let defaultTextView: HandyTextView = { let textView = HandyTextView() textView.placeholder = "Input text" - textView.helperLabelText = "Helper text" + textView.helperLabelText = "알파벳과 숫자만 허용합니다." textView.placeholderColor = .lightGray return textView @@ -53,6 +53,13 @@ final class TextViewController: BaseViewController { setViewLayouts() } + override func setViewProperties() { + self.view.backgroundColor = .white + defaultTextView.editingDelegate = self + defaultTextView.validationDelegate = self + defaultTextView.textChangeDelegate = self + } + override func setViewHierarchies() { [ defaultTextView, noHelperLabelTextView, errorTextView, disabledTextView @@ -85,3 +92,31 @@ final class TextViewController: BaseViewController { } } } + +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/Source/Atom/HandyTextView/HandyBaseTextView.swift b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift index 2453c89..b88bad9 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyBaseTextView.swift @@ -11,6 +11,25 @@ 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 + } + } + } + /** 텍스트 뷰를 비활성화 시킬 때 사용합니다. */ @@ -48,10 +67,11 @@ public class HandyBaseTextView: UITextView { */ private var placeholderLabel: UILabel? - // MARK: - 메소드 + // MARK: - 초기화 public init() { super.init(frame: .zero, textContainer: nil) + setDelegate() setupView() } @@ -59,6 +79,12 @@ public class HandyBaseTextView: UITextView { fatalError("init(coder:) has not been implemented") } + // MARK: - 메소드 + + private func setDelegate() { + super.delegate = self + } + private func setupView() { self.delegate = self self.font = HandyFont.B3Rg14 @@ -148,16 +174,27 @@ public class HandyBaseTextView: UITextView { extension HandyBaseTextView: UITextViewDelegate { public func textViewDidBeginEditing(_ textView: UITextView) { - if !isNegative { - self.layer.borderColor = HandySemantic.lineStatusPositive.cgColor - } + 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 index 76b2c2e..0533870 100644 --- a/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift +++ b/Handy/Handy/Source/Atom/HandyTextView/HandyTextView.swift @@ -8,9 +8,42 @@ 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? + /** 텍스트 필드를 비활성화 시킬 때 사용합니다. */ @@ -26,7 +59,8 @@ public class HandyTextView: UIView { */ public var text: String? { get { return textView.text } - set { textView.text = newValue } + set { textView.text = newValue + validateText() } } /** @@ -75,6 +109,7 @@ public class HandyTextView: UIView { public init() { super.init(frame: .zero) + setDelegate() setupView() updateState() } @@ -85,6 +120,10 @@ public class HandyTextView: UIView { // MARK: - 메소드 + private func setDelegate() { + textView.delegate = self + } + private func setupView() { addSubview(stackView) @@ -119,7 +158,17 @@ public class HandyTextView: UIView { 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 @@ -146,3 +195,20 @@ public class HandyTextView: UIView { 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) + } +}