본문 바로가기

iOS 개발/번역기 앱(RxSwift)

번역기 앱 - 11. 번역 화면 개발하기

반응형

안녕하세요. Skillist입니다

 

추후 수정사항이 없다면 아마도 이번 글이 마지막이 되지 않을까 생각해요.

번역 화면만 구현하면 됩니다!

지금까지 구현한 View 3개를 몽땅 사용할거에요.

그러다 보니 bind가 생각보다 더 복잡합니다. 각오 하고 따라오세요.

 

———————————————————————————————————————————————————

 

ViewModel부터 보겠습니다.

12라인 : 언어 선택 View에 대한 ViewModel입니다. ViewModel이 ViewModel을 가지고 있습니다.

13라인 : 번역할 text 입력 View에 대한 ViewModel입니다.

14라인 : 번역 결과 View에 대한 ViewModel입니다. 

17라인 : 번역 API를 Request하고 Response가 도착할때 까지, 보여줄  indicatorView에서 활용할 Relay입니다.

 

간단하죠? 이미 다른 ViewModel에서 정의했기 때문에, bind에서 흐름만 연결해주면 됩니다.

 

———————————————————————————————————————————————————

 

View를 보겠습니다. 역시나 bind는 마지막에 볼게요.

지난번에 작성한 "번역 화면의 구성" 글부터 보고 오세요.

https://skillist.tistory.com/314

 

번역기 앱 - 7. 번역 화면의 구성

안녕하세요. Skillist입니다 이번엔, 번역 화면을 구성 하기전에! 어떻게 구현돼있는지 볼거에요. 왜냐면, 번역 화면은 크게 3개의 뷰로 구현돼있거든요. ——————————————————

skillist.tistory.com

 

View : 하위에 ScrollView가 존재합니다.

    ㄴ ScrollView : 하위에 StackView가 존재합니다.

        ㄴ StackView : 하위에는 "언어 선택 View", "text 입력 View", "번역 결과 View" 가 존재합니다.

            ㄴ 언어 선택 View

            ㄴ text 입력 View

            ㄴ 번역 결과 View

    ㄴ indicatorView : 또 View 하위에는, api 요청 시 보여줄 indicatorView도 존재합니다.

 

16~60라인 : UI컴포넌트입니다.

62라인 : viewDidLoad에서 기본 레이아웃 설정을 합니다.

 

202라인 : view 기본 설정을 합니다.

207라인 : 스냅킷을 통해 레이아웃 구성합니다.

 

언어를 선택하면 보여줄 ActionSheet입니다. 

PublishSubject<Language>를 리턴합니다.

버튼을 구성하고, 버튼 handler에서 Language를 onNext로 전달합니다.

아직 익숙하지 않은 부분인데, 자주 활용하여 친숙해져야겠습니다.

 

275라인 : Reactive를 extension합니다.

276라인 : Error 발생 시 보여주는 Alert입니다. api error 발생, 한국어에서 한국어로 번역 시도할 때 보여줄거에요.

Reactive extension을 몇번 구현해봤는데, 아직은 익숙하지가 않네요. 몇번을 해도 익숙하지가 않아요.

 

71라인 : 언어 선택 View(selectLanguageView)를 바인딩 합니다.

73라인 : source 버튼을 터치하면, 언어 선택 action sheet을 보여줍니다. 버튼을 터치하면, language를 받아와, changedSourceLanguage에 바인드 합니다.

81라인 : target 버튼을 터치하면, 언어 선택 action sheet을 보여줍니다. 버튼을 터치하면, language를 받아와, changedTargetLanguage에 바인드 합니다.

 

89, 100라인 : source, target 언어가 변경되면, 각 뷰의 언어 label 타이틀을 변경하고, 번역 결과 View를 hide합니다.

 

111라인 : source, target 언어와 번역할 Text의 최신 값을 묶어버립니다. api request할때 사용할거에요.

119라인 : 번역 버튼(엔터키, done키)을 터치하면, 111라인의 데이터를 받아와, requestModel을 생성합니다.

125라인 : model을 구독합니다.

127라인 : source와 target 언어가 같다면, Error Alert을 보여줍니다.

133라인 : 그게 아니라면, indicatorView를 보여줍니다.

134라인 : API를 request하고 response를 받아 핸들러로 처리합니다.

137라인 : 성공 시 번역 text를 방출합니다.

141라인 : 실패 시 Error Alert을 보여줍니다.

 

149라인 : 번역된 Text가 전달되면, HistoryModel을 만들고, UserDefault에 addHistory합니다.

 

160라인 : text 입력 View(sourceTextInputView)를 바인딩 합니다.

161라인 : Observable.merge()를 통해서 입력한 text가 변경되거나 clear 버튼을 터치하면, 번역 결과 View를 hide하도록 바인드합니다.

 

172라인 : 번역 결과 View(translatedTextOutputView) 바인딩 합니다.

183라인 : 보관(북마크) 버튼을 터치하면, HistoryModel을 생성하여, UserDefault의 addBookmark를 바인딩 합니다.

193라인 : viewModel의 isAPIRequesting을 indicatorView.isAnimating에 바인딩 합니다.

 

———————————————————————————————————————————————————

 

이번엔 API를 볼게요

 

11라인 : result를 enum으로 구현합니다.

17라인 : api 함수입니다. urlcomponent없이 바로 string으로 url을 가져오도록 했어요.

24라인 : 헤더 값을 추가합니다. 네이버 개발자 사이트에 가이드가 있습니다. 제 코드를 사용한다면, 본인의 키를 발행하여 하드코딩하여 사용하세요.

29라인 : Alamofire를 사용하여 request합니다. URLSession보다 간단하네요.

또, RxAlamofire를 사용하려고 시도해봤는데, 아직은 잘 모르겠어서, Alamofire를 사용했습니다. ㅠㅜㅜㅠ

 

———————————————————————————————————————————————————

 

이렇게 번역 앱을 함께 개발해봤어요!

어떠세요???????

RxSwift 느낌이 좀 왔나요??????

전 완전 느낌 왔어요!

여러 프로젝트를 진행하며, 성장이 느껴지는데,

이번 프로젝트는 아주아주 매우 대만족 입니다.

연휴는 날라갔지만, 저에게는 뼈와 살이되는 기간이었습니다!

모두들 고생하셨어요

 

잘못되거나 부족한 내용 등, 피드백 감사합니다!

 

https://github.com/DeveloperSkillist/TranstorKing

 

GitHub - DeveloperSkillist/TranstorKing

Contribute to DeveloperSkillist/TranstorKing development by creating an account on GitHub.

github.com

 

 

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  전체 코드  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

import RxCocoa
import RxSwift

struct TranslatorViewModel {
    let selectLanguageViewModel = SelectLanguageViewModel()
    let translatedTextOutputViewModel = TranslatedTextOutputViewModel()
    let sourceTextInputViewModel = SourceTextInputViewModel()
    
    //view -> viewModel
    let isAPIRequesting = BehaviorRelay<Bool>(value: false)
    
    //viewModel -> view
}
import UIKit
import RxSwift
import RxCocoa
import SnapKit

class TranslatorView: UIViewController {
    let disposeBag = DisposeBag()
    
    private lazy var scrollView: UIScrollView = {
        let scrollView = UIScrollView()
        return scrollView
    }()
    
    private lazy var selectLanguageView: SelectLanguageView = {
        let selectLanguageView = SelectLanguageView()
        return selectLanguageView
    }()
    
    private lazy var sourceTextInputView: SourceTextInputView = {
        let sourceTextInputView = SourceTextInputView()
        return sourceTextInputView
    }()
    
    private lazy var translatedTextOutputView: TranslatedTextOutputView = {
        let translatedTextOutputView = TranslatedTextOutputView()
        return translatedTextOutputView
    }()
    
    private lazy var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.distribution = .equalSpacing
        stackView.spacing = 16
        stackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
        stackView.isLayoutMarginsRelativeArrangement = true
        
        [
            selectLanguageView,
            sourceTextInputView,
            translatedTextOutputView
        ].forEach {
            stackView.addArrangedSubview($0)
        }
        return stackView
    }()
    
    private lazy var indicatorView: UIActivityIndicatorView = {
        var indicatorView = UIActivityIndicatorView()
        indicatorView.hidesWhenStopped = true
        indicatorView.isHidden = true
        indicatorView.style = .large
        return indicatorView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        attribute()
        layout()
    }
    
    func bind(_ viewModel: TranslatorViewModel) {
        //selectLanguageView
        selectLanguageView.bind(viewModel.selectLanguageViewModel)
        
        viewModel.selectLanguageViewModel.sourceLanguageButtonTap
            .flatMapFirst { _ in
                self.presentSelectLanguageActionSheet()
                    .map { $0 }
            }
            .bind(to: viewModel.selectLanguageViewModel.changedSourceLanguage)
            .disposed(by: disposeBag)
        
        viewModel.selectLanguageViewModel.targetLanguageButtonTap
            .flatMapFirst { _ in
                self.presentSelectLanguageActionSheet()
                    .map { $0 }
            }
            .bind(to: viewModel.selectLanguageViewModel.changedTargetLanguage)
            .disposed(by: disposeBag)
        
        viewModel.selectLanguageViewModel.sourceLanguage
            .asObservable()
            .map {
                $0
            }
            .bind(onNext: {
                viewModel.sourceTextInputViewModel.selectedLanguage.accept($0)
                viewModel.translatedTextOutputViewModel.isHiddenView.accept(true)
            })
            .disposed(by: disposeBag)
        
        viewModel.selectLanguageViewModel.targetLanguage
            .asObservable()
            .map {
                $0
            }
            .bind(onNext: {
                viewModel.translatedTextOutputViewModel.selectedLanguage.accept($0)
                viewModel.translatedTextOutputViewModel.isHiddenView.accept(true)
            })
            .disposed(by: disposeBag)
        
        let inputDatas = Observable.combineLatest(
            viewModel.selectLanguageViewModel.sourceLanguage
                .asObservable(),
            viewModel.selectLanguageViewModel.targetLanguage
                .asObservable(),
            viewModel.sourceTextInputViewModel.inputText
        )
        
        let settedTranslateRequestModel = viewModel.sourceTextInputViewModel.translateButtonTap
            .withLatestFrom(inputDatas) { ($1.0, $1.1, $1.2) }
            .map { sourceLan, targetLan, text -> TranslateRequestModel in
                return TranslateRequestModel(source: sourceLan.rawValue, target: targetLan.rawValue, text: text)
            }
        
        settedTranslateRequestModel
            .subscribe(onNext: {
                if $0.source == $0.target {
                    self.rx.showAlert
                        .onNext(Alert(title: "language_error_title".localize, message: "language_error_message".localize))
                    return
                }
                
                viewModel.isAPIRequesting.accept(true)
                TranslateAPI().requestTranslate(translateRequestModel: $0) { result in
                    viewModel.isAPIRequesting.accept(false)
                    switch result {
                    case .success(let result):
                        viewModel.translatedTextOutputViewModel.translatedText
                            .accept(result.translatedText)
    
                    case .failure(let error):
                        self.rx.showAlert
                            .onNext(Alert(title: "network_error_title".localize, message: error.localizedDescription))
                    }
                }
            })
            .disposed(by: disposeBag)
        
        viewModel.translatedTextOutputViewModel.translatedText
            .withLatestFrom(inputDatas) { ($0, $1.0, $1.1, $1.2) }
            .map { translatedText, sourceLan, targetLan, sourceText -> HistoryModel in
                return HistoryModel(sourceLanguage: sourceLan, targetLanguage: targetLan, sourceText: sourceText, targetText: translatedText)
            }
            .subscribe(onNext: {
                UserDefaults.standard.addHistory(historyModel: $0)
            })
            .disposed(by: disposeBag)
        
        //sourceTextInputView
        sourceTextInputView.bind(viewModel.sourceTextInputViewModel)
        Observable.merge(
            viewModel.sourceTextInputViewModel.changedInputText.asObservable(),
            viewModel.sourceTextInputViewModel.clearButtonTap.asObservable()
        )
            .map {
                true
            }
            .bind(to: viewModel.translatedTextOutputViewModel.isHiddenView)
            .disposed(by: disposeBag)
        
        //translatedTextOutputView
        translatedTextOutputView.bind(viewModel.translatedTextOutputViewModel)
        
        let completedTranstor = Observable.combineLatest(
            viewModel.selectLanguageViewModel.sourceLanguage
                .asObservable(),
            viewModel.selectLanguageViewModel.targetLanguage
                .asObservable(),
            viewModel.sourceTextInputViewModel.inputText,
            viewModel.translatedTextOutputViewModel.translatedText
        )
        
        viewModel.translatedTextOutputViewModel.bookmarkButtonTap
            .withLatestFrom(completedTranstor) { ($1.0, $1.1, $1.2, $1.3) }
            .map { sourceLan, targetLan, sourceText, translatedText -> HistoryModel in
                return HistoryModel(sourceLanguage: sourceLan, targetLanguage: targetLan, sourceText: sourceText, targetText: translatedText)
            }
            .subscribe(onNext: {
                UserDefaults.standard.addBookmark(historyModel: $0)
            })
            .disposed(by: disposeBag)
        
        viewModel.isAPIRequesting
            .bind(to: indicatorView.rx.isAnimating)
            .disposed(by: disposeBag)
    }
    
    private func attribute() {
        view.backgroundColor = .secondarySystemBackground
        navigationItem.title = "translator_title".localize
    }
    
    private func layout() {
        [
            scrollView,
            indicatorView
        ].forEach {
            view.addSubview($0)
        }
        
        scrollView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        indicatorView.snp.makeConstraints {
            $0.centerX.centerY.equalToSuperview()
        }

        [
            stackView
        ].forEach {
            scrollView.addSubview($0)
        }
        
        stackView.snp.makeConstraints {
            $0.edges.equalToSuperview()
            $0.width.equalToSuperview()
        }
    }
    
    private func presentSelectLanguageActionSheet() -> Observable<Language> {
        let selectedLanguage = PublishSubject<Language>()
        
        let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
        
        let koAction = UIAlertAction(title: "korean".localize, style: .default) { _ in
            selectedLanguage.onNext(.ko)
            selectedLanguage.onCompleted()
        }
        alertController.addAction(koAction)
        
        let enAction = UIAlertAction(title: "english".localize, style: .default) { _ in
            selectedLanguage.onNext(.en)
            selectedLanguage.onCompleted()
            }
        alertController.addAction(enAction)
        
        let jpAction = UIAlertAction(title: "japanese".localize, style: .default) { _ in
            selectedLanguage.onNext(.ja)
            selectedLanguage.onCompleted()
        }
        alertController.addAction(jpAction)
        
        let chAction = UIAlertAction(title: "chinese".localize, style: .default) { _ in
            selectedLanguage.onNext(.ch)
            selectedLanguage.onCompleted()
        }
        alertController.addAction(chAction)
        
        let cancelAction = UIAlertAction(title: "cancel".localize, style: .cancel) { _ in
            selectedLanguage.onCompleted()
        }
        alertController.addAction(cancelAction)
        
        present(alertController, animated: true)
        return selectedLanguage
    }
}

typealias Alert = (title: String, message: String)
extension Reactive where Base: TranslatorView {
    var showAlert: Binder<Alert> {
        return Binder(base) { viewController, alert in
            let alertController = UIAlertController(title: alert.title, message: alert.message, preferredStyle: .alert)
            let action = UIAlertAction(title: "ok_title".localize, style: .cancel)
            alertController.addAction(action)
            base.present(alertController, animated: true, completion: nil)
        }
    }
}
import RxSwift
import Alamofire

enum NetworkResult<T> {
    case success(T)
    case failure(Error)
}

struct TranslateAPI {
   func requestTranslate(
        translateRequestModel: TranslateRequestModel,
//        completionHandler: @escaping (String) -> Void
        completionHandler: @escaping (Result<TranslateResponseModel, AFError>) -> Void
    ) {
        let url = URL(string: "https://openapi.naver.com/v1/papago/n2mt")!
        
        let headers: HTTPHeaders = [
            "X-Naver-Client-Id": APIKeys.id,    //네이버 개발자 사이트에서 발행한 키를 입력하세요.
            "X-Naver-Client-Secret": APIKeys.Secret    //네이버 개발자 사이트에서 발행한 키를 입력하세요.
        ]
        
        AF
            .request(url, method: .post, parameters: translateRequestModel, headers: headers)
            .responseDecodable(of: TranslateResponseModel.self) { response in
                completionHandler(response.result)
            }
            .resume()
    }
}
반응형