- ์นด๋ฉ๋ผ๊ฐ ๋น์ถ ๊ธ์๋ฅผ ์ค์๊ฐ์ผ๋ก ๋ฒ์ญํด์ฃผ๋ ์ฑ
- iOS 16 ์ด์์ ํ๊ฒฝ์์ ์ฌ์ฉ ๊ฐ๋ฅํฉ๋๋ค
- ํ๋ก์ ํธ ๊ธฐ๊ฐ : 23/10/09~23/10/17
โ ๏ธ ์ฃผ์์ฌํญAPI ํ์ฉ์ ์ํด์ SecretKey.plist ํ์ผ ์์ฑ์ด ํ์ํฉ๋๋ค. plist์ ๋ค์ด๊ฐ๋ ๋ด์ฉ์ ์๋ ๋ ๊ฐ์ง์ ๋๋ค.
- PapagoClientSecret : Naver Developer์ Secret Key(String)
- PapagoClientId : Naver Developer์ Client ID(String)
- ๐ฉโ๐ป ํ์ ์๊ฐ
- ๐ ํ์ ๋ผ์ธ
- ๐ ๏ธ ํ์ฉ ๊ธฐ์
- ๐ ์๊ฐํ ๊ตฌ์กฐ
- ๐ฑ ์คํ ํ๋ฉด
- ๐ ํต์ฌ ๊ฒฝํ
- ๐งจ ํธ๋ฌ๋ธ ์ํ
- ๐ ์ฐธ๊ณ ์๋ฃ
maxhyunm ([email protected]) |
๋ ์ง | ๋ด์ฉ |
---|---|
2023.10.09. | ํ์ฉํ ๊ธฐ์ ์คํ ๊ฒฐ์ ํ๋ก์ ํธ ๊ตฌ์กฐ ๊ตฌ์ |
2023.10.10. | Network, Model, Error ๊ด๋ จ ๊ธฐ๋ณธ ํ์
์์ฑ DataScanner ํ๋ฉด์ธ์ ๊ธฐ๋ฅ ๊ตฌํ |
2023.10.11. | API ํต์ ์ ์ํ ๋คํธ์ํฌ ๊ธฐ๋ฅ ๊ตฌํ Repository ํ์ ์์ฑ ViewModel ์์ฑ |
2023.10.12. | DataScannerViewController๋ฅผ MainViewController ๋ด๋ถ๋ก ์ด๋ ์ธ์ด ์ ํ์ ์ํ PickerView ์ถ๊ฐ Item ํ์ ์์ฑ RxSwift ์ถ๊ฐํ์ฌ ViewModel ๋ณ๊ฒฝ |
2023.10.13. | TextPickerField ํ์
์์ฑ ๋คํธ์ํฌ ์ค๋ฅ ํฝ์ค |
2023.10.14. | NetworkManager ๋ด๋ถ ๋ฉ์๋ ๋ถ๋ฆฌ ColorNamespace ์ถ๊ฐ Localization ์ถ๊ฐ |
2023.10.15. | ๋คํธ์ํฌ ํ
์คํธ ์ถ๊ฐ ResultViewModel ๋ถ๋ฆฌ |
2023.10.16. | ์์ด์ฝ ๋ฐ ๋ฐ์น์คํฌ๋ฆฐ ์์ฑ Copy ์์ ์ถ๊ฐ |
2023.10.17. | Toast ๋ฉ์์ง ๊ธฐ๋ฅ ์ถ๊ฐ ResultViewController -> TextLabel๋ก ๋ณ๊ฒฝ README ์์ฑ |
Framework | Architecture | Concurrency | API | Dependency Manager |
---|---|---|---|---|
UIKit | MVVM | RxSwift | Papago ๋ฒ์ญ, ์ธ์ด๊ฐ์ง | SPM |
.
โโโ README.md
โโโ TranslateApp
โโโ TranslateApp
โย ย โโโ en.lproj
โย ย โย ย โโโ Localizable.strings
โย ย โโโ ko.lproj
โย ย โ โโโ Localizable.strings
โย ย โโโ App
โย ย โย ย โโโ AppDelegate.swift
โย ย โย ย โโโ SceneDelegate.swift
โย ย โโโ Network
โย ย โย ย โโโ NetworkConfiguration.swift
โย ย โย ย โโโ NetworkManager.swift
โย ย โย ย โโโ URLSessionDataTaskProtocol.swift
โย ย โย ย โโโ URLSessionProtocol.swift
โย ย โโโ Model
โย ย โย ย โโโ DTO
โย ย โย ย โย ย โโโ LanguageDetectorDTO.swift
โย ย โย ย โย ย โโโ TranslatorDTO.swift
โย ย โย ย โโโ TranslatorRepository.swift
โย ย โโโ Utility
โย ย โย ย โโโ AlertBuilder.swift
โย ย โย ย โโโ CustomColors.swift
โย ย โย ย โโโ DecodingManager.swift
โย ย โย ย โโโ KeywordArgument.swift
โย ย โย ย โโโ Languages.swift
โย ย โโโ Error
โย ย โย ย โโโ APIError.swift
โย ย โย ย โโโ DecodingError.swift
โย ย โย ย โโโ TranslateError.swift
โย ย โโโ Presentation
โย ย โย ย โโโ View
โย ย โย ย โโโ Protocol
โย ย โย ย โย ย โโโ ToastShowable.swift
โย ย โย ย โย ย โโโ ViewModelType.swift
โย ย โย ย โโโ Main
โย ย โย ย ย ย โโโ LanguagePickerField.swift
โย ย โย ย ย ย โโโ TextLabel.swift
โย ย โย ย ย ย โโโ MainViewController.swift
โย ย โย ย ย ย โโโ MainViewModel.swift
โย ย โโโ Resource
โย ย โย ย โโโ Assets.xcassets
โย ย โโโ Info.plist
โย ย โโโ SecretKey.plist
โโโ TranslateApp.xcodeproj
โโโ TranslateAppTests
โโโ TranslateApp.xctestplan
โโโ TestDouble.swift
โโโ NetworkTexts.swift
โโโ ViewModelTest.swift
์ธ์ด ๊ฐ์ง ๋ฒ์ญ | ์ธ์ด ์ ํ | ๊ฒฐ๊ณผ ๋ณต์ฌ |
---|---|---|
input
ํ์
๊ณผ output
ํ์
์ ๋ถ๋ฆฌํ View Model
์ ์ ์ฉํ MVVM
ํจํด์ ํ์ฉํ์์ต๋๋ค. ๋ํ ๋คํธ์ํฌ ์ฒ๋ฆฌ์ ์ฐ๊ฒฐ๋๋ ๋ก์ง์ Repository
ํ์
์ผ๋ก ๋ถ๋ฆฌํ์์ต๋๋ค.
์์ธ์ฝ๋
protocol MainViewModelType {
var inputs: MainViewModelInputsType { get }
var outputs: MainViewModelOutputsType { get }
}
protocol MainViewModelInputsType {
func scanText(_ input: String)
func touchUpTranslate(source: String, target: String)
}
protocol MainViewModelOutputsType {
var outputItem: PublishRelay<String> { get }
var errorMessage: PublishRelay<String> { get }
}
protocol ViewModelWithError {
var errorMessage: PublishRelay<String> { get }
func handle(error: Error)
}
final class TranslatorRepository {
let networkManager: NetworkManager
init(networkManager: NetworkManager) {
self.networkManager = networkManager
}
func detectLanguage(_ text: String, completion: @escaping(Result<Languages, Error>) -> Void) {
...
}
func translate(source: Languages, target: Languages, text: String, completion: @escaping(Result<String, Error>) -> Void) {
...
}
}
DataScannerViewController
๋ฅผ ์ฌ์ฉํ๋ ค๋ฉด iOS 16 ์ด์์ด์ด์ผ ํ๋ค๋ ์ ์ด ๊ณ ๋ฏผ๋์ด, ์ฒ์์๋ VNDocumentCameraViewController
๋ฅผ ์ฌ์ฉํ๋ ค ํ์์ต๋๋ค. ํ์ง๋ง ๊ทธ๋ ๊ฒ ํ๋ฉด ์ค์๊ฐ์ด๋ผ๊ธฐ๋ณด๋ค๋ ํ ์ฐจ๋ก ์ด๋ฏธ์ง๋ฅผ ์ค์บํ ๋ค ๊ทธ ์ค์์ ์ ํํ์ฌ ํ
์คํธ ์ธ์์ ํ๊ณ , ๊ทธ ํ์ ๋ฒ์ญ์ ํ๋ ๋ฑ ๋จ๊ณ๊ฐ ์ถ๊ฐ๋์ด ์ค์๊ฐ ๋ฒ์ญ์ด๋ผ๋ ๋๋์ด ๋ค์ง ์๋ ๊ฒ ๊ฐ์์ต๋๋ค. ๊ฒฐ๊ตญ DataScannerViewController
์ DataScannerViewControllerDelegate
๋ฅผ ํ์ฉํ์ฌ ์ค์๊ฐ์ผ๋ก ์นด๋ฉ๋ผ์ ๋น์น ํ
์คํธ๋ฅผ ๊ฐ์งํ ์ ์๋๋ก ํ์์ต๋๋ค.
์์ธ์ฝ๋
private let dataScanner: DataScannerViewController = {
let scanner = DataScannerViewController(recognizedDataTypes: [.text()],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: true,
isHighlightingEnabled: true)
scanner.view.translatesAutoresizingMaskIntoConstraints = false
return scanner
}()
...
extension MainViewController: DataScannerViewControllerDelegate {
func dataScanner(_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]) {
translateLabel.removeFromSuperview()
guard let item = addedItems.first,
case .text(let text) = item else { return }
let frame = CGRect(x: text.bounds.topLeft.x,
y: dataScanner.view.frame.origin.y + text.bounds.topLeft.y,
width: text.bounds.topRight.x - text.bounds.topLeft.x,
height: text.bounds.bottomRight.y - text.bounds.topRight.y)
translateLabel.resetLabel(frame: frame)
viewModel.inputs.scanText(text.transcript)
}
}
View
์ View Model
๊ฐ์ ๋ฐ์ดํฐ ๋ฐ์ธ๋ฉ์ ๋ฌผ๋ก , View
๋ด๋ถ์ ๋ฒํผ์ด๋ PickerView ์ก์
์๋ RxSwift
๋ฅผ ํ์ฉํ์์ต๋๋ค.
์์ธ์ฝ๋
final class MainViewModel: MainViewModelType, MainViewModelOutputsType, ViewModelWithError {
...
var outputItem = PublishRelay<String>()
var errorMessage = PublishRelay<String>()
...
}
private func bindOutput() {
viewModel.outputs.outputItem
.subscribe(on: MainScheduler.instance)
.bind { [weak self] output in
guard let self else { return }
self.translateLabel.text = output
self.view.addSubview(self.translateLabel)
}
.disposed(by: disposeBag)
}
func bindPickerView(disposeBag: DisposeBag) {
Observable.just(category.menu).bind(to: pickerView.rx.itemTitles) { _, item in
return item
}.disposed(by: disposeBag)
pickerView.selectRow(0, inComponent: 0, animated: false)
self.text = category.menu.first
}
Builder
ํจํด์ ํ์ฉํด Alert
์ฒ๋ฆฌ๋ฅผ ์กฐ๊ธ ๋ ๊น๋ํ ํ ์ ์๋๋ก ํ์์ต๋๋ค.
์์ธ์ฝ๋
struct AlertBuilder {
let configuration: AlertConfiguration
init(prefferedStyle: UIAlertController.Style) {
...
}
@discardableResult
func setTitle(_ title: String) -> Self {
...
}
@discardableResult
func setMessage(_ message: String) -> Self {
...
}
@discardableResult
func addAction(_ actionType: AlertActionType, action: ((UIAlertAction) -> Void)? = nil) -> Self {
...
}
func build() -> UIAlertController {
...
}
}
let alertBuilder = AlertBuilder(prefferedStyle: .alert)
.setMessage(errorMessage)
.addAction(.confirm) { action in
self.dismiss(animated: true)
}
.build()
๋ค์ํ ํ์
์ Entity๋ฅผ ๋ฐํํด์ผ ํ๋ DecodingManager
์ ๋ฉ์๋๋ฅผ Generic
์ผ๋ก ๊ตฌํํ์์ต๋๋ค.
์์ธ์ฝ๋
private init() {}
func decode<T: Decodable>(_ data: Data?) throws -> T {
guard let data = data,
let decodedData = try? decoder.decode(T.self, from: data) else {
throw DecodingError.decodingFailure
}
return decodedData
}
}
Text Field
์ Picker View
๋ฅผ ์ฐ๊ฒฐํ LanguagePickerField
ํ์
์ ์์ฑํ์ฌ ์ธ์ด ์ ํ์ด ๊ฐ๋ฅํ๋๋ก ๊ตฌํํ์์ต๋๋ค.
์์ธ์ฝ๋
final class LanguagePickerField: UITextField {
private let pickerView = UIPickerView()
...
inputView = pickerView
...
func bindPickerView(disposeBag: DisposeBag) {
Observable.just(category.menu).bind(to: pickerView.rx.itemTitles) { _, item in
return item
}.disposed(by: disposeBag)
pickerView.selectRow(0, inComponent: 0, animated: false)
self.text = category.menu.first
}
...
}
UIToolBar
์ flexibleSpace
๋ฅผ ํ์ฉํ์ฌ PickerView ์๋จ ๋ฒํผ์ ๊ตฌํํ์์ต๋๋ค.
์์ธ์ฝ๋
private let toolbar: UIToolbar = {
let toolBar = UIToolbar(frame: CGRect(origin: .zero, size: CGSize(width: UIScreen.main.bounds.width, height: 35)))
toolBar.translatesAutoresizingMaskIntoConstraints = false
toolBar.barStyle = .default
toolBar.isTranslucent = true
toolBar.tintColor = Colors.barButtonTitle
toolBar.sizeToFit()
return toolBar
}()
...
let cancelButton = UIBarButtonItem(primaryAction: cancel)
let selectButton = UIBarButtonItem(primaryAction: select)
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
toolbar.setItems([cancelButton, flexibleSpace, selectButton], animated: true)
toolbar.isUserInteractionEnabled = true
self.inputAccessoryView = toolbar
label
์ Long Press Guesture
๋ฅผ ์ฐ๊ฒฐํ์ฌ ์ค๋ ํฐ์นํ๋ฉด ํด๋ฆฝ๋ณด๋์ ๋ณต์ฌ๋ฅผ ํ ์ ์๋๋ก ๊ตฌํํ์์ต๋๋ค.
์์ธ์ฝ๋
private func addGestureRecognizer() {
let gestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
translateLabel.addGestureRecognizer(gestureRecognizer)
}
@objc private func handleLongPress() {
showToast(Constants.copyToastMessage, withDuration: 3.0, delay: 0.1)
UIPasteboard.general.string = translateLabel.text
}
์ง์ญํ๋ฅผ ์ํ ์ฒ๋ฆฌ๋ฅผ ์ถ๊ฐํ์ฌ ์์ด์ ํ๊ตญ์ด๋ก ํ์ฉํ ์ ์๋๋ก ํ์์ต๋๋ค.
์์ธ์ฝ๋
"languageAuto" = "Detect Language";
"languageKorean" = "Korean";
"languageEnglish" = "English";
"languageJapanese" = "Japanese";
...
"languageAuto" = "์ธ์ด ๊ฐ์ง";
"languageKorean" = "ํ๊ตญ์ด";
"languageEnglish" = "์์ด";
"languageJapanese" = "์ผ๋ณธ์ด";
...
...
switch self {
case .auto:
return String(format: NSLocalizedString("languageAuto", comment: "์ธ์ด ๊ฐ์ง"))
case .korean:
return String(format: NSLocalizedString("languageKorean", comment: "ํ๊ตญ์ด"))
case .english:
return String(format: NSLocalizedString("languageEnglish", comment: "์์ด"))
case .japanese:
return String(format: NSLocalizedString("languageJapanese", comment: "์ผ๋ณธ์ด"))
case .chineseSimple:
...
๐จ ๋ฌธ์ ์
์ฒ์์๋ DataScannerViewController
์์ ์ง๋๊ณ ์๋ overlayContainerView
์ Button
๊ณผ TextField
๋ฅผ ๋ฐฐ์นํ๋ ค ํ์ต๋๋ค. ํ์ง๋ง ๊ทธ๋ ๊ฒ ํ๋ฉด ํด๋น ์์๋ค๊ณผ ์ํธ์์ฉ์ ํ ์ ์๋ค๋ ๊ฒ์ ๊นจ๋ฌ์์ต๋๋ค. ๋ทฐ๋ฅผ splitํด์ผ ํ ์ง, ์ด๋ป๊ฒ ํ๋ฉด ๋ทฐ ์ปจํธ๋กค๋ฌ ๋ด๋ถ์ DataScannerViewController
๋ฅผ ๋ฃ์ ์ ์์์ง ๋ง์ ๊ณ ๋ฏผ์ ๊ฑฐ์ณค์ต๋๋ค.
๐ก ํด๊ฒฐ ๋ฐฉ๋ฒ
DataScannerViewController
์ View
๋ง์ ๋ทฐ์ปจํธ๋กค๋ฌ์ Subview
๋ก ์ถ๊ฐํด์ฃผ๋ ๋ฐฉ์์ผ๋ก ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ์์ต๋๋ค.
private func configureScanner() {
dataScanner.delegate = self
addChild(dataScanner)
view.addSubview(dataScanner.view)
NSLayoutConstraint.activate([
dataScanner.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
dataScanner.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
dataScanner.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 50),
dataScanner.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
dataScanner.didMove(toParent: self)
}
๐จ ๋ฌธ์ ์
DataScannerViewContoller
๋ฅผ ํ์ฉํ๋ฉด ์ค์บํ ํ
์คํธ์ ์์น์ ๋ณด๋ ํจ๊ป ์ป์ ์ ์์ต๋๋ค. ๋๋ฌธ์ ์นด๋ฉ๋ผ๊ฐ ์ธ์ํ ํ
์คํธ ์์น์ Label
์ ์ฌ๋ ค ์ค์๊ฐ์ผ๋ก ๋ฒ์ญ๋ ๋ด์ฉ์ ํ์ํ๋ฉด ์ข๊ฒ ๋ค๋ ์๊ฐ์ด ๋ค์์ต๋๋ค. ์ด๋ฅผ ์ํด UILabel
์ frame
์ ๋ณด๋ฅผ ๋๊ฒจ์ฃผ์์ง๋ง, ๊ณ์ํด์ ํฌ๊ธฐ์ ์์น๋ฅผ ์ก์ง ๋ชปํ๊ณ ์๋จ์ผ๋ก ์ฌ๋ผ๊ฐ๋ ์ค๋ฅ๊ฐ ์์์ต๋๋ค.
๐ก ํด๊ฒฐ ๋ฐฉ๋ฒ
label
์ translatesAutoresizingMaskIntoConstraints
์ค์ ์ด false
๋ก ๋์ด ์๊ธฐ ๋๋ฌธ์ ํด๋น ๋ด์ฉ์ ์ ์ฉํ์ง ๋ชปํ๋ ๊ฒ์ ํ์ธํ์์ต๋๋ค. ํด๋น ์ค์ ์ ์ญ์ ํ์ฌ ํด๊ฒฐํ์์ต๋๋ค.
๐จ ๋ฌธ์ ์
๋ฒํผ ์ฒ๋ฆฌ๋ฅผ ์ํด UIToolBar๋ฅผ ์ฐ๊ฒฐํ ํ PickerView๋ฅผ ํ์ฑํํ๋ฉด ์๋์ ๊ฐ์ Constraint ์ค๋ฅ๊ฐ ๊ณ์ํ์ฌ ๋ฐ์ํ์์ต๋๋ค.
๐ก ํด๊ฒฐ ๋ฐฉ๋ฒ
UIToolBar์ frame ์ค์ ์ด ์์ด ์ค๋ฅ๊ฐ ๋๋ ๊ฒ์ผ๋ก ํ์ธํ์ฌ, ์๋์ ๊ฐ์ด frame์ ์ค์ ํด ํด๊ฒฐํ์์ต๋๋ค.
let toolBar = UIToolbar(frame: CGRect(origin: .zero, size: CGSize(width: UIScreen.main.bounds.width, height: 35)))
๐ : Apple Developer Documentations ๐ข : Naver Developers โช๏ธ : ๊ธฐํ ์๋ฃ
๐ URLSession
๐ URLRequest
๐ URLComponents
๐ UIAlertController
๐ DataScannerViewController
๐ Scanning data with the camera
๐ UIPickerView
๐ UIToolbar
๐ Bounds
๐ Frame
๐ข ํํ๊ณ ๋ฒ์ญ API
๐ข ํํ๊ณ ์ธ์ด ๊ฐ์ง API
โช๏ธ RxSwift
โช๏ธ Kodeco : New Scanning and Text Capabilities with VisionKit