포스트

단어장 프로젝트 (8)

예외처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func gameStart () {
        failCount = 0
        score = 0
        isGameEnd = false
        
        if !labelList.isEmpty {
            resetLabel()
        }
        
        hangManBottomView = HangManBottomView()
        
        vStackView.addSubview(hangManBottomView!)
        
        hangManBottomView!.snp.makeConstraints {
            $0.top.equalTo(hangManBodyView.snp.bottom)
            $0.leading.equalToSuperview()
            $0.trailing.equalToSuperview()
            $0.bottom.equalToSuperview().offset(-60)
        }
        if currentCount < quizArray.count {
            answer = quizArray[currentCount].word!
            makeWordLabel(word: answer)
            
            updateUI()
        } else {
            let alert = alertController.makeNormalAlert(title: "게임종료", message: "게임이 끝났습니다.")
            self.present(alert, animated: true)
        }
    }

지금은 심플하게 이렇게 구현해두었다.

게임 기록 페이지 구현

게임 기록 페이지를 만들어본다.

우선 기능구현이 우선이기에 디자인은 심플하게 해둘 예정이며.

UISegmentController를 사용해볼 예정.

해당부분을 코드로 구현해본적은 없는것같아. 기록해두려 한다.

기록하기엔 생각보다 property가 없어서

1
2
3
4
5
lazy var segControl: UISegmentedControl = {
        let control = UISegmentedControl(items: ["Quiz", "HangMan"])
        control.selectedSegmentIndex = 0
        return control
    }()

우선은 이렇게 기본세팅만 한다.

Simulator Screenshot - iPhone 15 Pro - 2024-05-18 at 15 24 36

위에서 언급한대로 특이점은 바로

Segcontrol이 있다는 것.

이전에는 StoryBoard를 통해 IBAction으로 바로 따와서 sender를 통해 값의 변화를 감지 할 수 있었다.

하지만 지금은 sender를 가져올수가 없다.

왜냐 현재 구성한 VC는 Header, BodyView를 가져오고

각각의 View들 안에 UIComponent가 있기 때문.

Segcontrol만 seg의 변화에 따라 해당 index를 출력하게 끔 테스트를 해서 작동이 되면 그다음에 다른 로직을 구현 하면 될것같다.

우선 다음과 같은 함수를 만들어 주었다.

1
2
3
4
5
6
7
8
9
10
11
func changeSegment() {
        let selectedIndex = recordBodyView.segControl.selectedSegmentIndex
        switch selectedIndex {
        case 0:
            print("select 0")
        case 1:
            print("select 1")
        default:
            return
        }
    }

하지만 VC에서는 작동이 되질않았다.

그냥 처음에 ViewDidload나 ViewDidapper 같은 VC의 생명주기를 고려하여 해당 함수를 트리거 해서 작동을 확인 해보려 했으나 되지 않았다.

이게 VC에서 해야하는걸까? 라는 생각이 들었다.

view에서 addAction 통해 클로저로 구현 하면 어떨까라는 생각이 들었고 그걸 해보려한다.

1
2
3
4
5
6
7
8
9
lazy var segControl: UISegmentedControl = {
        let control = UISegmentedControl(items: ["Quiz", "HangMan"])
        control.selectedSegmentIndex = 0
        control.addAction(UIAction(handler: { [weak self] _ in
            let index = control.selectedSegmentIndex
            self?.changeIndex(index: index)
        }), for: .valueChanged)
        return control
    }()

다음과 같이 action을 만들어 준다,

이젠 작동을 한다.

물론 해당 방식을 응용해서

1
2
3
4
5
func addSegAction () {
        recordBodyView.segControl.addAction(UIAction(handler: { [weak self] _ in
            print(self?.recordBodyView.segControl.selectedSegmentIndex)
        }), for: .valueChanged)
    }

VC에 이렇게 적어도 작동은 한다.

혹시나 메모리 누수가 있을까 싶어 확인했지만 그건 없는듯 하다.

cell을 만들어주고,

이번 TableView는 Diffable Datasource를 적용한다.

섹션을 만들어준다.

1
2
3
4
5
6
enum DiffableSection {
    
    case quiz
    case hangMan
    
}

그리고 모델이 필요할거 같아서 별도로 레코드 모델을 만들어 준다.

데이터 결정

단순히 게임횟수보다는 게임을 했을때 틀린단어를 보여주는게 좋겠다고 팀회의로 결정.

Api 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class NetworkManager {
    
    
    func fetchRequest (query: String, complete: @escaping (Result<TranslatedModel,Error>) -> Void) {
        
        let url = "https://api-free.deepl.com/v2/translate"
        let header = ["Authorization" : "DeepL-Auth-Key \(Secret.apiKey)"]
        let component = [URLQueryItem(name: "text", value: query), URLQueryItem(name: "target_lang", value: "KO")]
        var urlComponent = URLComponents(string: url)
        urlComponent?.queryItems = component
        
        if let urlRequest = urlComponent?.url {
            var request = URLRequest(url: urlRequest)
            request.httpMethod = "POST"
            request.allHTTPHeaderFields = header
            
            let session = URLSession(configuration: .default)
            let task = session.dataTask(with: request) { data, response, error in
                if let e = error {
                    complete(.failure(e))
                }
                
                if let safeData = data {
                    if let decodedData = try? JSONDecoder().decode(TranslatedModel.self, from: safeData) {
                        complete(.success(decodedData))
                    }
                }
            }
            task.resume()
        }
        
    }
}

딱히 할말이 없다.

Combine style로 변경

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class NetworkManager {
    
    private var cancellables = Set<AnyCancellable>()
    
    func fetchRequest (query: String) -> Future<[Translation], Error> {
        
        return Future<[Translation], Error> { [weak self] promise in
            
            let url = "https://api-free.deepl.com/v2/translate"
            let header = ["Authorization" : "DeepL-Auth-Key \(Secret.apiKey)"]
            let component = [URLQueryItem(name: "text", value: query), URLQueryItem(name: "target_lang", value: "KO")]
            var urlComponent = URLComponents(string: url)
            urlComponent?.queryItems = component
            
            guard let urlRequest = urlComponent?.url else {
                return
            }

            var request = URLRequest(url: urlRequest)
            request.httpMethod = "POST"
            request.allHTTPHeaderFields = header
            let session = URLSession(configuration: .default)
            
            session.dataTaskPublisher(for: request)
                .map(\.data)
                .decode(type: [Translation].self, decoder: JSONDecoder())
                .eraseToAnyPublisher()
                .replaceError(with: [])
                .sink { data in
                    promise(.success(data))
                }.store(in: &self!.cancellables)
        }
    }
}

이번에는 MVVM이 아니라서 VC에 직접 구현해야할듯싶다.

textfield 값 가져오기

textfield에 대한 값을 가져오기 위해 extension을 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension UITextField {
    var textPublisher: AnyPublisher<String, Never> {
        NotificationCenter.default
            .publisher(for: UITextField.textDidChangeNotification, object: self)
            .map { ($0.object as? UITextField)?.text  ?? "" }
            .eraseToAnyPublisher()
    }
}

func observe() {
        wordTextField.textPublisher.sink { value in
            print(value)
        }.store(in: &cancellables)
    }

이렇게하면 출력이 된다.

그냥 간단하게 작동만 되게 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func observe() {
        wordTextField.textPublisher
            .debounce(for: 1, scheduler: DispatchQueue.main)
            .sink { [weak self] value in
                self?.networkManager.fetchRequest(query: value)
                    .sink(receiveCompletion: { completion in
                        switch completion {
                        case .finished:
                            return
                        case .failure(_):
                            return
                        }
                    }, receiveValue: { documents in
                        self!.result = documents.first!
                    }).store(in: &self!.cancellables)
        }.store(in: &cancellables)
    }

Diffable Datasourece 사용.

1
2
var tableDatasource: UITableViewDiffableDataSource<DiffableSectionModel, Translation>?
var tableSnapshot: NSDiffableDataSourceSnapshot<DiffableSectionModel, Translation>?

이렇게 만들어주고.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
extension InsertVocaViewController {
    func configureDiffableDataSource () {
        tableDatasource = UITableViewDiffableDataSource(tableView: resultTable, cellProvider: { tableView, indexPath, model in
            
            let cell = tableView.dequeueReusableCell(withIdentifier: Constants.resultCell, for: indexPath) as! ResultTableViewCell
            
            cell.wordLabel.text = model.text
            cell.selectionStyle = .none
            
            return cell
        })
    }
    
    func configureSnapshot() {
        tableSnapshot = NSDiffableDataSourceSnapshot<DiffableSectionModel, Translation>()
        tableSnapshot?.deleteAllItems()
        tableSnapshot?.appendSections([.requestResult])
        tableSnapshot?.appendItems(result)

        tableDatasource?.apply(tableSnapshot!,animatingDifferences: true)
    }
    
}

이렇게 적용하게 했다.

그리고 셀을 선택했을때 textField에 들어가게 해야하므로

1
2
3
4
5
6
7
8
lazy var resultTable: UITableView = {
        let table = UITableView()
        table.register(ResultTableViewCell.self, forCellReuseIdentifier: Constants.resultCell)
        table.didSelectRowPublisher.sink { [weak self] indexPath in
            self?.definitionTextField.text = self?.result[0].text
        }.store(in: &cancellables)
        return table
    }()

combinecocoa를 사용했다.

May-21-2024 17-54-23

이전에도 느꼈지만 컴바인과 디퍼블은 최고의 조합이다.

기록 로직 구현

여기에도 디퍼블을 사용할 예정인데,

데이터를 저장을 해야한다.

현재 recordVC에

1
2
3
4
5
6
func addSegAction () {
        recordBodyView.segControl.addAction(UIAction(handler: { [unowned self] _ in
            let index = recordBodyView.segControl.selectedSegmentIndex
            print (index)
        }), for: .valueChanged)
    }

이녀석이 있는데 index값이 변화한다.

이 index값을 이용해서 소팅을 하면 될듯하다.

해당 데이터를 모델링을 먼저 하면 될듯한데.

index 번호와 단어, 그리고 뜻 이렇게 3개만 있으면 충분할것으로 보여서 모델링을 먼저 한다.

1
2
3
4
5
6
7
struct ReminderModel {
    
    var index: Int
    var word: String
    var meaning: String
    
}

1. QuizVC에 NotificationCenter 등록.

우선 다음과 같이 등록 static let inCorrect = Notification.Name("inCorrect")

이걸 받을 vc에 observer를 등록해준다.

1
2
3
4
5
6
7
NotificationCenter.default.addObserver(self, selector: #selector(getData), name: .inCorrect, object: nil)

@objc func getData(_ notification: Notification) {
        if let data = notification.object as? ReminderModel {
            dataList.append(data)
        }
    }

2. 관련 view에 등록

그리고 문제 정답과 오답을 판별하는 함수가 있는 quizbottomview에 notificationcenter 등록

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func checkAnswer(title: String) -> Bool {
        var flag = false
        guard let currentVC = currentViewController as? QuizViewController else { return flag }
        let currentQuestion = currentVC.quizBodyView.gameTitle.text
        let gameArray = currentVC.quizData
        let answer = gameArray.filter{ $0.question == currentQuestion }.map{ $0.answer }.joined()
        
        if title == answer {
            currentVC.currentNumber += 1
            currentVC.score += 1
            currentVC.gameStart()
            flag = true
        } else {
            // added
            let currentData = currentVC.quizData[currentVC.currentNumber]
            let data = ReminderModel(index: 0, word: currentData.question, meaning: currentData.answer)
            NotificationCenter.default.post(name: .inCorrect, object: data)
            currentVC.currentNumber += 1
            currentVC.gameStart()
        }
        return flag
    }

그리고 또 hangman에도 있기에 심어둔다.

이건 게임이 끝났을때 이므로

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func updateUI () {
        
        if failCount >= 7 {
            hangManBodyView.hangManImageView.image = UIImage(named: imageList[failCount])
            let alert = alertController.makeAlertWithCompletion(title: "게임종료", message: "게임이 끝났습니다.\n다시 시작하시겠습니까?") { [weak self] _ in
                self?.hangManBottomView?.removeFromSuperview()
                
                // added
                guard let word = self?.quizArray[self!.currentCount].word, let meaning = self?.quizArray[self!.currentCount].definition else {
                    return
                }
                
                let data = ReminderModel(index: 1, word: word, meaning: meaning)
                NotificationCenter.default.post(name: .inCorrect, object: data)
                
                self?.resetLabel()
                self?.currentCount += 1
                self?.gameStart()
                self?.isGameEnd = false
            }
            self.present(alert, animated: true)
            isGameEnd = true
            
        } else {
            hangManBodyView.hangManImageView.image = UIImage(named: imageList[failCount])
        }
    }

이렇게 등록.

하지만 recordvc가 호출이 되지않아 넘어가지않는 상황이 발생하여 우회를 하기로 결정

gaimmainvc에서 받아서 화면 전환시 넘기기로 했다.

1
2
3
4
5
6
7
8
9
NotificationCenter.default.addObserver(self, selector: #selector(getData), name: .quiz, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(getData), name: .hangman, object: nil)

@objc func getData(_ notification: Notification) {
        if let data = notification.object as? ReminderModel {
            //print(data)
            dataList.append(data)
        }
    }

DiffableDatasource 구현

고차함수인 filter와 map을 사용하여 각 인덱스에 해당하는 것만 적용하게 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
extension RecordViewController {
    func configureDiffableDataSource () {
        tableDiffableDatasoure = UITableViewDiffableDataSource(tableView: recordBodyView.tableView, cellProvider: { tableView, indexPath, model in
            
            let cell = tableView.dequeueReusableCell(withIdentifier: Constants.recordCell, for: indexPath) as! RecordTableViewCell
            
            cell.wordLabel.text = model.word
            cell.meaningLabel.text = model.meaning
            
            cell.selectionStyle = .none
            
            return cell
        })
    }
    
    func configureQuizSnapshot() {
        quizSnapshot = NSDiffableDataSourceSnapshot<DiffableSectionModel, ReminderModel>()
        quizSnapshot?.deleteAllItems()
        quizSnapshot?.appendSections([.quiz])
        quizSnapshot?.appendItems(dataList.filter{ $0.index == 0}.map { $0 } )

        tableDiffableDatasoure?.apply(quizSnapshot!,animatingDifferences: true)
    }
    
    func configureHangmanSnapshot() {
        hangmanSnapshot = NSDiffableDataSourceSnapshot<DiffableSectionModel, ReminderModel>()
        hangmanSnapshot?.deleteAllItems()
        hangmanSnapshot?.appendSections([.hangman])
        hangmanSnapshot?.appendItems(dataList.filter{ $0.index == 1}.map { $0 })

        tableDiffableDatasoure?.apply(hangmanSnapshot!,animatingDifferences: true)
    }
}

중복에러가 나서 어딘가 했는데

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func updateUI () {
        guard let word = quizArray[currentCount].word, let meaning = quizArray[currentCount].definition else {
            return
        }
        let data = ReminderModel(index: 1, word: word, meaning: meaning)
        if failCount >= 7 {
            hangManBodyView.hangManImageView.image = UIImage(named: imageList[failCount])
            let alert = alertController.makeAlertWithCompletion(title: "게임종료", message: "게임이 끝났습니다.\n다시 시작하시겠습니까?") { [weak self] _ in
                self?.hangManBottomView?.removeFromSuperview()
                self?.resetLabel()
                 NotificationCenter.default.post(name: .hangman, object: data) // wrong
                self?.currentCount += 1
                self?.gameStart()
                self?.isGameEnd = false
            }
            NotificationCenter.default.post(name: .hangman, object: data) // correct
            self.present(alert, animated: true)
            isGameEnd = true
            
        } else {
            hangManBodyView.hangManImageView.image = UIImage(named: imageList[failCount])
        }
    }

wrong이라고 쓴곳이 잘못되었다.

취소를 염두하고 아래에도 쓴게 화근이었다.

생각해보니 확인이나 취소를 눌러도 무조건 등록이 되어야하기에 correct 부분에 넣는게 맞다.

하지만 단점은 디퍼블은 Hashable이라서 중복값이 있어서는 안된다. 그것을 방지하기위해 set으로 한번 걸러낸다.

May-22-2024 00-36-23

1
2
3
 func removeDuplicate () {
        dataList = Array(Set(dataList)).sorted(by: { $0.word < $1.word })
    }

중복제거와 동시에 정렬을 해서 보여주게 했다.

하지만 카테고리가 없어서 좀 아쉽다.

카테고리도 모델에 넣으면 좋을듯하다.

모델 변경

1
2
3
4
5
6
7
8
struct ReminderModel: Hashable {
    
    var index: Int
    var word: String
    var meaning: String
    var category: String // added
}

카테고리 정보는 어차피 receivedData가 가지고 있다.

1
2
3
4
guard let word = quizArray[currentCount].word, let meaning = quizArray[currentCount].definition, let category = receivedData?.category else {
            return
        }
let data = ReminderModel(index: 1, word: word, meaning: meaning, category: category)

적용 완료.

하지만 하나 아쉬운건 보여줄때 레이블이 어떤걸 의미하는지 모를 수 있다.

tableview의 header에 uiview를 추가하여 보여주면 좋을듯하다.

Tableview Header View 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
lazy var tableView: UITableView = {
        let table = UITableView()
        table.register(RecordTableViewCell.self, forCellReuseIdentifier: Constants.recordCell)
        table.rowHeight = 60
        table.tableHeaderView = headerView
        return table
    }()
lazy var headerView: UIView = {
        let view = UIView()
        view.addSubview(hStackView)
        return view
    }()
    
lazy var categoryLabel = LabelFactory().makeLabel(title: "단어장", size: 20, isBold: true)
lazy var wordLabel = LabelFactory().makeLabel(title: "단어", size: 20, isBold: false)
lazy var defLabel = LabelFactory().makeLabel(title: "의미", size: 20, isBold: false)
    
lazy var hStackView: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [
            categoryLabel,
            wordLabel,
            defLabel
        ])
        stackView.axis = .horizontal
        stackView.distribution = .fillEqually
        return stackView
    }()    

다음과 같이 잡아준다.

그리고 레이아웃을 또 잡아줘야 하는데 검색해보니

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private func layout () {
        self.addSubview(vStackView)
        
        vStackView.snp.makeConstraints {
            $0.top.leading.equalToSuperview().offset(20)
            $0.bottom.trailing.equalToSuperview().offset(-20)
        }
        
        headerView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 40) // important
        
        hStackView.snp.makeConstraints { // added
            $0.edges.equalToSuperview()
        }
        
    }

headerview는 레이아웃보다는 프레임으로 잡는다고 한다. 기억해두자.

실행하면 다음과 같다.

끄읏.

May-22-2024 02-18-45

예외처리

현재 문제를 하나 발견했는데 같은 문제가 두번 연속나오면 답을 못맞추는것같다.

확인해보니 정답인데 색을 잘못 인식하는듯하다.

추가로 확인해보니 오답으로 인식도 하는듯하다.

아무래도 버튼이 변하면서 cache가 남는 느낌인데 initialize가 필요할것으로 보인다.

우선 깜빡거리는 타이머를 0.2에서 0.1초로 바꾼다.

그리고

1
2
3
4
5
6
 @objc func updateBackground () {
        [firstButton, secondButton, thirdButton, forthButton].forEach { button in
            button.setTitle("", for: .normal) // added
            button.backgroundColor = ThemeColor.mainColor
        }
    }

빈값으로 한번 초기화를 해보기로했다.

문제가 사라진다. 아무래도 초기화를 해주는 시점을 다르게 잡아야할듯하다.

우선 확인해보니 현재 배열의 개수보다 더 많이 만들때 발생을 하는데

quiz에는 그 부분이 적용이 안되어있던걸로 보인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func checkException () {
        if receivedData!.quizCount > quizArray.count {
            receivedData?.quizCount = quizArray.count
        }
    }
    
    func getData () {
            quizArray = CoreDataManager.shared.getSpecificData(query: receivedData!.category, onError: { [unowned self] error in
            let alert = alertController.makeNormalAlert(title: "에러발생", message: "\(error.localizedDescription)가 발생했습니다.")
            self.present(alert, animated: true)
        })
        // added
        checkException()
        quizArray = Array(quizArray.prefix(receivedData!.quizCount))
    }

그리고 또 문제를 발견했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private func generate(count: Int) { // 문제배열이 생성

        for _ in 0..<count {
            let numberArray = (0...quizArray.count - 1).map{ $0 }.shuffled()
            let getFourNumberArray = numberArray.prefix(4).map { numberArray[$0] }
            let number1 = getFourNumberArray[0]
            let number2 = getFourNumberArray[1]
            let number3 = getFourNumberArray[2]
            let number4 = getFourNumberArray[3]
            
            
            let answerInfo = quizArray[number1]
            let question = answerInfo.word!
            let answer = answerInfo.definition!
            let first = quizArray[number2].definition!
            let second = quizArray[number3].definition!
            let third = quizArray[number4].definition!
            
            let dummy = VocaQuizModel(question: question, answer: answer, incorrectFirst: first, incorrectSecond: second, incorrectThird: third)
            quizData.append(dummy)
        }
        
    }

여기서 계속 랜덤으로 하다보니 문제가 발생한것.

중복을 피하기 위해 이미 사용한 문제를 따로 추가하는 배열을 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private func generate(count: Int) {
        
        var usedQuestions = Set<String>() // added
        
        for _ in 0..<count {
            var numberArray = (0...quizArray.count-1).map { $0 }.shuffled()
            
            var getFourNumberArray = numberArray.prefix(4).map { numberArray[$0] }
            var answerInfo = quizArray[getFourNumberArray[0]]
            
            while usedQuestions.contains(answerInfo.word!) {
                numberArray = (0...quizArray.count-1).map { $0 }.shuffled()
                getFourNumberArray = numberArray.prefix(4).map { numberArray[$0] }
                answerInfo = quizArray[getFourNumberArray[0]]
            }
            
            let question = answerInfo.word!
            let answer = answerInfo.definition!
            let first = quizArray[getFourNumberArray[1]].definition!
            let second = quizArray[getFourNumberArray[2]].definition!
            let third = quizArray[getFourNumberArray[3]].definition!
            
            let dummy = VocaQuizModel(question: question, answer: answer, incorrectFirst: first, incorrectSecond: second, incorrectThird: third)
            quizData.append(dummy)
            usedQuestions.insert(question)
        }
        
    }

완료.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.