diff --git a/TOASTER-iOS.xcodeproj/project.pbxproj b/TOASTER-iOS.xcodeproj/project.pbxproj index 1eeac121..aa909ca9 100644 --- a/TOASTER-iOS.xcodeproj/project.pbxproj +++ b/TOASTER-iOS.xcodeproj/project.pbxproj @@ -281,6 +281,8 @@ 8315CD8C2B54782F0061F377 /* SelectClipViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8315CD8B2B54782F0061F377 /* SelectClipViewController.swift */; }; 8315CD8E2B547EE30061F377 /* SelectClipHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8315CD8D2B547EE30061F377 /* SelectClipHeaderView.swift */; }; 8315CD912B5521F70061F377 /* SelectClipModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8315CD902B5521F70061F377 /* SelectClipModel.swift */; }; + 832F0ED72C9C07EA00E38571 /* AddLinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F0ED62C9C07EA00E38571 /* AddLinkViewModel.swift */; }; + 8334CFA02CA6E2D200319922 /* ViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */; }; 83474A6A2BED06EB009B9C48 /* ToasterTargetType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE6DA7B2B54571D008B06FA /* ToasterTargetType.swift */; }; 83474A6B2BED06F1009B9C48 /* PatchEditLinkTitleRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8388E98B2BC8FAB200858C5C /* PatchEditLinkTitleRequestDTO.swift */; }; 83474A6C2BED072A009B9C48 /* ToasterAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE6DA7D2B54572B008B06FA /* ToasterAPIService.swift */; }; @@ -502,6 +504,8 @@ 8315CD8B2B54782F0061F377 /* SelectClipViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectClipViewController.swift; sourceTree = ""; }; 8315CD8D2B547EE30061F377 /* SelectClipHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectClipHeaderView.swift; sourceTree = ""; }; 8315CD902B5521F70061F377 /* SelectClipModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectClipModel.swift; sourceTree = ""; }; + 832F0ED62C9C07EA00E38571 /* AddLinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddLinkViewModel.swift; sourceTree = ""; }; + 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelType.swift; sourceTree = ""; }; 8364220B2BE7BFB2005C4085 /* PatchEditLinkTitleResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchEditLinkTitleResponseDTO.swift; sourceTree = ""; }; 8388E98B2BC8FAB200858C5C /* PatchEditLinkTitleRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchEditLinkTitleRequestDTO.swift; sourceTree = ""; }; 8388E98D2BC8FC6700858C5C /* EditLinkBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLinkBottomSheetView.swift; sourceTree = ""; }; @@ -976,6 +980,7 @@ isa = PBXGroup; children = ( 3F2FA1762B45C3E000EDBF95 /* AuthenticationAdapterProtocol.swift */, + 8334CF9F2CA6E2D200319922 /* ViewModelType.swift */, ); path = Protocols; sourceTree = ""; @@ -1579,6 +1584,7 @@ children = ( 8309F5862B8DCE8100A1420A /* Model */, 8309F5852B8DCE7B00A1420A /* View */, + 832F0ED52C9C07C500E38571 /* ViewModel */, ); path = LinkEmbed; sourceTree = ""; @@ -1635,6 +1641,14 @@ path = ViewModel; sourceTree = ""; }; + 832F0ED52C9C07C500E38571 /* ViewModel */ = { + isa = PBXGroup; + children = ( + 832F0ED62C9C07EA00E38571 /* AddLinkViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1889,6 +1903,7 @@ 8305179D2B4D3701009FFB60 /* MainInfoModel.swift in Sources */, 6BE6DAA42B547579008B06FA /* GetDetailTimerResponseDTO.swift in Sources */, 6BE6D9E22B4E9B58008B06FA /* CompleteTimerCollectionViewCell.swift in Sources */, + 8334CFA02CA6E2D200319922 /* ViewModelType.swift in Sources */, 3F2FA1792B45C46F00EDBF95 /* KakaoAuthenticateAdapter.swift in Sources */, 6BE6DA342B50594B008B06FA /* MoyaPlugin.swift in Sources */, 396DCDFA2CA19F2000FEF7C8 /* PopupTargetType.swift in Sources */, @@ -1914,6 +1929,7 @@ 3F617CB82B4ECB6000956E69 /* MypageUserModel.swift in Sources */, 6BE6DA612B50B742008B06FA /* ClipAPIService.swift in Sources */, 830517AA2B4D95E9009FFB60 /* HomeFooterCollectionView.swift in Sources */, + 832F0ED72C9C07EA00E38571 /* AddLinkViewModel.swift in Sources */, 39049C8D2B43EEF400C9196E /* ToastStatus.swift in Sources */, 8305178E2B4D1EF8009FFB60 /* WeeklyRecommendCollectionViewCell.swift in Sources */, 830517902B4D1FC7009FFB60 /* HomeHeaderCollectionView.swift in Sources */, diff --git a/TOASTER-iOS/Global/Protocols/ViewModelType.swift b/TOASTER-iOS/Global/Protocols/ViewModelType.swift new file mode 100644 index 00000000..fd8a6b53 --- /dev/null +++ b/TOASTER-iOS/Global/Protocols/ViewModelType.swift @@ -0,0 +1,15 @@ +// +// ViewModelType.swift +// TOASTER-iOS +// +// Created by Gahyun Kim on 9/27/24. +// + +import Foundation + +protocol ViewModelType { + associatedtype Input + associatedtype Output + + func transform(_ input: Input) -> Output +} diff --git a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift index 0d72bd4e..782b95c2 100644 --- a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift +++ b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkView.swift @@ -12,16 +12,15 @@ import Then final class AddLinkView: UIView { - // MARK: - Properties - - private var timer: Timer? + // MARK: - Property + private var keyboardHeight: CGFloat = 100 // MARK: - UI Components private let descriptLabel = UILabel() - var linkEmbedTextField = UITextField() - private let clearButton = UIButton() + private(set) var linkEmbedTextField = UITextField() + let clearButton = UIButton() let nextBottomButton = UIButton() let nextTopButton = UIButton() @@ -36,7 +35,7 @@ final class AddLinkView: UIView { super.init(frame: frame) setLinkEmbedTextField() - setView() + setupView() } @available(*, unavailable) @@ -46,14 +45,13 @@ final class AddLinkView: UIView { // MARK: - Make View - func setView() { + func setupView() { setupStyle() setupHierarchy() setupLayout() } func setLinkEmbedTextField() { - linkEmbedTextField.delegate = self linkEmbedTextField.resignFirstResponder() } @@ -89,6 +87,7 @@ private extension AddLinkView { clearButton.do { $0.setImage(.icCancle24, for: .normal) $0.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + $0.isHidden = true } nextBottomButton.do { @@ -112,7 +111,6 @@ private extension AddLinkView { func setupHierarchy() { addSubviews(descriptLabel, linkEmbedTextField, nextBottomButton, clearButton) - clearButton.isHidden = true accessoryView.addSubview(nextTopButton) } @@ -155,7 +153,8 @@ private extension AddLinkView { } } - @objc func cancelButtonTapped() { + @objc + func cancelButtonTapped() { linkEmbedTextField.text = "" linkEmbedTextField.becomeFirstResponder() } @@ -163,110 +162,18 @@ private extension AddLinkView { // MARK: - Extension -extension AddLinkView: UITextFieldDelegate { - - // MARK: - Timer Check - - func textFieldDidBeginEditing(_ textField: UITextField) { - // 텍스트 필드에 입력이 시작될 때 호출되는 메서드 - clearButton.isHidden = false - nextTopButton.backgroundColor = .black850 - linkEmbedTextField.placeholder = nil - - // 여기서 타이머를 시작하고, 0.5초 후에 텍스트를 확인 후 텍스트필드 에러 처리 - if textField.text?.count ?? 0 > 1 { - startTimer() - } - } - - func textField(_ textField: UITextField, - shouldChangeCharactersIn range: NSRange, - replacementString string: String) -> Bool { - - // 입력이 발생할 때마다 호출되는 메서드 - // 여기서 타이머를 재시작 - restartTimer() - return true - } - - func startTimer() { - // 0.5초 후에 checkTextField 메서드 호출 - timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in - // URL 유효 여부 판단 후 처리 - if let urlText = self?.linkEmbedTextField.text { - if (urlText.prefix(8) == "https://") || (urlText.prefix(7) == "http://") { - self?.resetError() - } else { - self?.isValidLinkError() - } - } - } - } - - func restartTimer() { - // 타이머 재시작 - stopTimer() - startTimer() - } - - func stopTimer() { - // 타이머를 정지, 테두리 초기화 - timer?.invalidate() - linkEmbedTextField.layer.borderColor = UIColor.clear.cgColor - } - - // MARK: - Text Field Error - - // 링크를 입력하는 텍스트필드가 비어 있을 경우 error 처리 - func emptyError() { - linkEmbedTextField.layer.borderColor = UIColor.toasterError.cgColor - linkEmbedTextField.layer.borderWidth = 1 - - // Button 비활성화 - nextTopButton.backgroundColor = .gray200 - nextBottomButton.backgroundColor = .gray200 - nextTopButton.isEnabled = false - nextBottomButton.isEnabled = false - - errorLabel.text = "링크를 입력해주세요" - addSubview(errorLabel) - errorLabel.snp.makeConstraints { - $0.top.equalTo(linkEmbedTextField.snp.bottom).offset(6) - $0.leading.equalTo(linkEmbedTextField.snp.leading) - } +extension AddLinkView { + func isValidLinkError(_ message: String) { + errorLabel.text = message errorLabel.isHidden = false - } - - // 링크가 유효하지 않을 경우 error 처리 - func isValidLinkError() { - linkEmbedTextField.layer.borderColor = UIColor.toasterError.cgColor - linkEmbedTextField.layer.borderWidth = 1 - - // Button 비활성화 - nextTopButton.backgroundColor = .gray200 - nextBottomButton.backgroundColor = .gray200 - nextTopButton.isEnabled = false - nextBottomButton.isEnabled = false - - errorLabel.text = "유효하지 않은 형식의 링크입니다" addSubview(errorLabel) errorLabel.snp.makeConstraints { $0.top.equalTo(linkEmbedTextField.snp.bottom).offset(6) $0.leading.equalTo(linkEmbedTextField.snp.leading) } - errorLabel.isHidden = false } - // 링크가 유효할 경우, error reset func resetError() { - linkEmbedTextField.layer.borderColor = UIColor.clear.cgColor - - // Button 활성화 - nextTopButton.backgroundColor = .black850 - nextBottomButton.backgroundColor = .black850 - nextTopButton.isEnabled = true - nextBottomButton.isEnabled = true - errorLabel.isHidden = true } } diff --git a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift index 74fd5620..910115c8 100644 --- a/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift +++ b/TOASTER-iOS/Present/AddLink/LinkEmbed/View/AddLinkViewController.swift @@ -30,9 +30,10 @@ final class AddLinkViewController: UIViewController { private weak var delegate: AddLinkViewControllerPopDelegate? private weak var urldelegate: SelectClipViewControllerDelegate? - // MARK: - UI Properties + // MARK: - UI Components private var addLinkView = AddLinkView() + private var viewModel = AddLinkViewModel() // MARK: - Life Cycle @@ -40,8 +41,11 @@ final class AddLinkViewController: UIViewController { super.viewDidLoad() setupStyle() - setAddLinkVew() + setupAddLinkVew() hideKeyboard() + + setupBinding() + updateUI() } override func viewWillAppear(_ animated: Bool) { @@ -56,19 +60,6 @@ final class AddLinkViewController: UIViewController { navigationBarHidden(forHidden: false) } - - // MARK: - set up Add Link View - - private func setAddLinkVew() { - view.addSubview(addLinkView) - - addLinkView.snp.makeConstraints { - $0.edges.equalTo(view.safeAreaLayoutGuide) - } - - addLinkView.nextBottomButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) - addLinkView.nextTopButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) - } } // MARK: - extension @@ -92,6 +83,17 @@ private extension AddLinkViewController { view.backgroundColor = .toasterBackground } + func setupAddLinkVew() { + view.addSubview(addLinkView) + + addLinkView.snp.makeConstraints { + $0.edges.equalTo(view.safeAreaLayoutGuide) + } + + addLinkView.nextBottomButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) + addLinkView.nextTopButton.addTarget(self, action: #selector(tappedNextBottomButton), for: .touchUpInside) + } + func setupNavigationBar() { let type: ToasterNavigationType = ToasterNavigationType(hasBackButton: false, hasRightButton: true, @@ -123,16 +125,40 @@ private extension AddLinkViewController { } @objc func tappedNextBottomButton() { - if (addLinkView.linkEmbedTextField.text?.count ?? 0) < 1 { - addLinkView.emptyError() + let selectClipViewController = SelectClipViewController() + selectClipViewController.linkURL = addLinkView.linkEmbedTextField.text ?? "" + selectClipViewController.delegate = self + self.navigationController?.pushViewController(selectClipViewController, animated: true) + } + +} + +// ViewModel +extension AddLinkViewController { + private func setupBinding() { + addLinkView.linkEmbedTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) + } + + @objc private func textFieldDidChange(_ textField: UITextField) { + viewModel.inputs.embedLinkText(textField.text ?? "") + updateUI() + } + + private func updateUI() { + addLinkView.clearButton.isHidden = viewModel.outputs.isClearButtonHidden + addLinkView.nextTopButton.isEnabled = viewModel.outputs.isNextButtonEnabled + addLinkView.nextTopButton.backgroundColor = viewModel.outputs.nextButtonBackgroundColor + addLinkView.nextBottomButton.isEnabled = viewModel.outputs.isNextButtonEnabled + addLinkView.nextBottomButton.backgroundColor = viewModel.outputs.nextButtonBackgroundColor + addLinkView.linkEmbedTextField.layer.borderColor = viewModel.outputs.textFieldBorderColor.cgColor + addLinkView.linkEmbedTextField.layer.borderWidth = 1 + + if let errorMessage = viewModel.outputs.linkEffectivenessMessage { + addLinkView.isValidLinkError(errorMessage) } else { - let selectClipViewController = SelectClipViewController() - selectClipViewController.linkURL = addLinkView.linkEmbedTextField.text ?? "" - selectClipViewController.delegate = self - self.navigationController?.pushViewController(selectClipViewController, animated: true) + addLinkView.resetError() } } - } extension AddLinkViewController: SaveLinkButtonDelegate { diff --git a/TOASTER-iOS/Present/AddLink/LinkEmbed/ViewModel/AddLinkViewModel.swift b/TOASTER-iOS/Present/AddLink/LinkEmbed/ViewModel/AddLinkViewModel.swift new file mode 100644 index 00000000..dfd7c4b6 --- /dev/null +++ b/TOASTER-iOS/Present/AddLink/LinkEmbed/ViewModel/AddLinkViewModel.swift @@ -0,0 +1,76 @@ +// +// AddLinkViewModel.swift +// TOASTER-iOS +// +// Created by Gahyun Kim on 9/19/24. +// + +import UIKit + +protocol AddLinkViewModelInputs { + func embedLinkText(_ text: String) +} + +protocol AddLinkViewModelOutputs { + var isClearButtonHidden: Bool { get } + var isNextButtonEnabled: Bool { get } + var nextButtonBackgroundColor: UIColor { get } + var textFieldBorderColor: UIColor { get } + var linkEffectivenessMessage: String? { get } +} + +protocol AddLinkViewModelType { + var inputs: AddLinkViewModelInputs { get } + var outputs: AddLinkViewModelOutputs { get } +} + +final class AddLinkViewModel: AddLinkViewModelType, AddLinkViewModelInputs, AddLinkViewModelOutputs { + + // Input + private var embedLink: String = "" { + didSet { + updateOutputs() + } + } + + // Output + var isClearButtonHidden: Bool + var isNextButtonEnabled: Bool + var nextButtonBackgroundColor: UIColor + var textFieldBorderColor: UIColor + var linkEffectivenessMessage: String? + + init() { + self.isClearButtonHidden = true + self.isNextButtonEnabled = false + self.nextButtonBackgroundColor = .gray200 + self.textFieldBorderColor = .clear + self.linkEffectivenessMessage = nil + } + + func embedLinkText(_ text: String) { + embedLink = text + } + + var inputs: AddLinkViewModelInputs { return self } + var outputs: AddLinkViewModelOutputs { return self } +} + +private extension AddLinkViewModel { + func updateOutputs() { + let isValid = isValidURL(embedLink) + isClearButtonHidden = embedLink.isEmpty + isNextButtonEnabled = !embedLink.isEmpty && isValid + nextButtonBackgroundColor = isNextButtonEnabled ? .black850 : .gray200 + textFieldBorderColor = isValid ? .clear : UIColor.toasterError + linkEffectivenessMessage = isValid ? nil : (embedLink.isEmpty ? "링크를 입력해주세요" : "유효하지 않은 형식의 링크입니다.") + } + + func isValidURL(_ urlString: String) -> Bool { + if (urlString.prefix(8) == "https://") || (urlString.prefix(7) == "http://") { + return true + } else { + return false + } + } +}