[Project 일지] 단어장 앱 만들기 (6)

 

2024.05.13 - [◽️ Programming/T I L] - [Project 일지] 단어장 앱 만들기 (1)

2024.05.14 - [◽️ Programming/T I L] - [Project 일지] 단어장 앱 만들기 (2)

2024.05.15 - [◽️ Programming/T I L] - [Project 일지] 단어장 앱 만들기 (3)

2024.05.16 - [◽️ Programming/T I L] - [Project 일지] 단어장 앱 만들기 (4)

2024.05.19 - [◽️ Programming/T I L] - [Project 일지] 단어장 앱 만들기 (5)

 

 

오늘은 필수 구현하려고 했던 내용을 모두 구현 완료 된 것 같다 🙂 이제 출시를 위해서 추가 기능을 조금 넣고 완성도를 잡는 과정을 거쳐 꼭 출시까지 한번 가보자!!

 

단어 정렬 필터 값 UserDefults 저장 및 단어 정렬 구현

이전 내용에서 코어데이터에 저장된 단어를 날짜별로 나눠 각각의 캘린더 날짜에 단어를 나눴다면 이제 그 나눈 단어를 가지고 정렬을 설정할 수 있도록 정렬 값을 앱이 꺼져도 가질 수 있도록 저장하고 정렬 값 마다 데이터의 정렬을 나눠 원하는 정렬을 가질 수 있도록 구현했다!

 

정렬 값을 저장하는 건 코어데이터에 저장까지 할 필요는 없는 것 같아 간단한 세팅 값에 사용되는 UserDefults에 값을 저장하기로 결정했다!

var selectedButtonIndex: Int?

먼저 필터 뷰는 테이블 뷰로 지정했기 때문에 인덱스로 해당 값을 지정해두고 관리하기 위해 변수에 담아둔다.

func saveFilterSettings() {
    guard let selectedButtonIndex = selectedButtonIndex else { return }
    UserDefaults.standard.set(selectedButtonIndex, forKey: "SelectedFilterIndex")
    UserDefaults.standard.synchronize()
    print("Saved selected filter index: \\(selectedButtonIndex)")
}

버튼 값을 UserDefults에 저장하기 위해 해당 메서드를 만들어 주고 해당 하는 셀을 누르면 그 셀의 인덱스 값이 저장되도록 구현한다.

func loadFilterSettings() {
    let savedIndex = UserDefaults.standard.integer(forKey: "SelectedFilterIndex")
    if savedIndex < labels.count {
        selectedButtonIndex = savedIndex
    } else {
        selectedButtonIndex = 0
    }
    print("Loaded selected filter index: \\(savedIndex)")
    tableView.reloadData()
}

처음 앱에 접속해서 필터 값을 지정해주지 않으면 어떤 방식으로 정렬을 해야할지 애매하므로, 처음 기본 값은 0의 인덱스를 가지도록 구현하는 메서드를 만들어준다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: FilterTableViewCell.identifier, for: indexPath) as? FilterTableViewCell else { fatalError("테이블 뷰 에러") }
    
    cell.label.text = labels[indexPath.row]
    cell.selectionStyle = .none
    
    cell.button.isSelected = (indexPath.row == selectedButtonIndex)
    
    cell.buttonAction = { [weak self] in
        guard let self = self else { return }
        self.updateSelectedButton(at: indexPath.row)
    }
    
    return cell
}

저장되어 있는 값을 토대로 버튼이 눌려있어야 하므로 해당 내용의 값을 넣어 줄 수 있도록 button의 isSelected 값을 현제 저장되어있는 유저디폴츠의 값으로 넣어준다.

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard tableView.cellForRow(at: indexPath) is FilterTableViewCell else { fatalError("테이블 뷰 셀 선택 에러") }
    updateSelectedButton(at: indexPath.row)
    
}

func updateSelectedButton(at index: Int) {
    if let selectedButtonIndex = selectedButtonIndex {
        let previousIndexPath = IndexPath(row: selectedButtonIndex, section: 0)
        if let previousCell = tableView.cellForRow(at: previousIndexPath) as? FilterTableViewCell {
            previousCell.button.isSelected = false
        }
    }
    
    selectedButtonIndex = index
    let currentIndexPath = IndexPath(row: index, section: 0)
    if let currentCell = tableView.cellForRow(at: currentIndexPath) as? FilterTableViewCell {
        currentCell.button.isSelected = true
    }
    
    saveFilterSettings()
}

또한 필터의 값은 바로바로 변해야하고 테이블 뷰 셀 내 하나만 선택되어야 하므로 새로운 값을 클릭해 저장되면 이전 값은 삭제 하고 현재 값이 저장되도록 구현한다.

 

이제 필터 뷰 컨트롤러에서 구현해야할 내용은 얼추 다 한 것 같고 이제 이 저장된 필터 값을 토대로 캘린더 VC에 있는 컬렉션 뷰를 업데이트 하는 과정이 있어야 한다.

func fetchFilterIndex() -> Int {
    if UserDefaults.standard.object(forKey: "SelectedFilterIndex") == nil {
        UserDefaults.standard.set(0, forKey: "SelectedFilterIndex")
        UserDefaults.standard.synchronize()
    }
    return UserDefaults.standard.integer(forKey: "SelectedFilterIndex")
}

저장되어 있는 유저 디폴츠 값을 가져오는 메서드를 만든다. 저장된 값이 없다면 0을 가져온다!

func sortWords(_ words: [WordEntity], by filterIndex: Int) -> [WordEntity] {
    switch filterIndex {
    case 0:
        return words.sorted { (word1: WordEntity, word2: WordEntity) -> Bool in
            return word1.date ?? Date() > word2.date ?? Date()
        }
    case 1:
        return words.sorted { (word1: WordEntity, word2: WordEntity) -> Bool in
            return word1.date ?? Date() < word2.date ?? Date()
        }
    case 2:
        return words.sorted { (word1: WordEntity, word2: WordEntity) -> Bool in
            return word1.memory && !word2.memory
        }
    case 3:
        return words.sorted { (word1: WordEntity, word2: WordEntity) -> Bool in
            return !word1.memory && word2.memory
        }
    case 4:
        return words.shuffled()
    default:
        return words
    }
}

유저 디폴츠 값을 기준으로 저장되어 있는 데이터를 정렬할 수 있도록 저장된 코어데이터 WordEntity 를 설정하는 로직을 구현한다!

func fetchWordListAndUpdateCollectionView() {
    let currentDate = Calendar.current.dateComponents([.year, .month, .day], from: Date())
    let dateToFetch = selectedDate?.date ?? Calendar.current.date(from: currentDate)!
    
    filteredWords = coreDataManager.getWordListFromCoreData(for: dateToFetch)
    
    let filterIndex = fetchFilterIndex()
    
    filteredWords = sortWords(filteredWords, by: filterIndex)
    
    dayCollectionView.reloadData()
}

위의 정렬 값을 토대로 컬렉션 뷰에 정렬된 데이터를 반영하는 메서드를 구현해 시점에 맞게 넣어 주면 구현 완료!

 

날짜별 전체 외우기 기능 , 전체 삭제 기능 구현

먼제 해당 내용을 구현하기 위해 MenuDetailModalViewController 에 있는 액션을 CalenderViewController 전달하는 방식은 델리게이트 패턴을 활용해 구현했다.

protocol MenuDetailModalDelegate: AnyObject {
    func markWordsAsLearned()
    func deleteAllWords()
}
weak var delegate: MenuDetailModalDelegate?

델리게이트 통신을 받기 위해 해당 내용을 적어준 뒤

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if indexPath.row == 0 {
        delegate?.markWordsAsLearned()
        dismissViewController()
    } else if indexPath.row == 1 {
        let alert = UIAlertController(title: "전체 삭제", message: "선택한 날짜의 모든 단어를 삭제하시겠습니까?", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "취소", style: .cancel, handler: nil))
        alert.addAction(UIAlertAction(title: "삭제", style: .destructive, handler: { _ in
            self.delegate?.deleteAllWords()
            self.dismissViewController()
        }))
        present(alert, animated: true, completion: nil)
    }
}

테이블 뷰 셀 즉 전체 외우기 , 전체 삭제 탭을 선택하면 해당하는 내용을 구현할 수 있도록 로직을 구성했다.

 

이제 캘린더 VC로 넘어가서

extension CalenderViewController: MenuDetailModalDelegate {
    func markWordsAsLearned() {
        let date: Date
        if let selectedDate = selectedDate {
            date = Calendar.current.date(from: selectedDate)!
        } else {
            date = Date()
        }
        
        let wordsForDate = coreDataManager.getWordListFromCoreData(for: date)
        
        for word in wordsForDate {
            coreDataManager.updateWordMemoryStatus(word: word, memory: true)
        }
        
        fetchWordListAndUpdateCollectionView()
    }
    
    func deleteAllWords() {
        let date: Date
        if let selectedDate = selectedDate {
            date = Calendar.current.date(from: selectedDate)!
        } else {
            date = Date()
        }
        
        let wordsForDate = coreDataManager.getWordListFromCoreData(for: date)
        
        for word in wordsForDate {
            coreDataManager.deleteWord(word)
        }
        fetchWordListAndUpdateCollectionView()
    }
}

각각 전체 저장을 누르면 코어데이터의 값도 변하도록 설정하는 메서드와 전체 삭제를 해주는 메서드를 각각 구현한다.

@objc func menuButtonTapped() {
    let menuModelVC = MenuDetailModalViewController()
    menuModelVC.delegate = self
    menuModelVC.modalPresentationStyle = .custom
    menuModelVC.transitioningDelegate = self
    present(menuModelVC, animated: true, completion: nil)
}

델리게이트 패턴을 적용하기 위해 self 처리를 넣어준다.

이렇게 델리게이트 패턴을 사용해서 데이터를 전달하고 해당 내용을 구현하는 과정을 거쳐보았다 🙂

 

날짜 별 저장된 단어 있을 경우 날짜 활성화

이제 날짜 별로 데이터를 나눠 캘린더를 선택해 오늘 저장한 단어를 볼 수 있게 해뒀으니, 이 데이터가 존재한다면 이 날짜에 데이터가 저장됐음을 나타내는 이모지를 넣어주려고 한다.

extension CalenderViewController: UICalendarViewDelegate, UICalendarSelectionSingleDateDelegate {
    func dateSelection(_ selection: UICalendarSelectionSingleDate, didSelectDate dateComponents: DateComponents?) {
        selectedDate = dateComponents
        
        if let date = dateComponents?.date {
            filteredWords = coreDataManager.getWordListFromCoreData(for: date)
            let filterIndex = fetchFilterIndex()
            filteredWords = sortWords(filteredWords, by: filterIndex)
            dayCollectionView.reloadData()
        }
    }
    
    func calendarView(_ calendarView: UICalendarView, decorationFor dateComponents: DateComponents) -> UICalendarView.Decoration? {
        if let date = Calendar.current.date(from: dateComponents), coreDataManager.hasData(for: date) {
            let emojiLabel = UILabel()
            emojiLabel.text = "🐢"
            emojiLabel.textAlignment = .center
            
            let containerView = UIView()
            containerView.addSubview(emojiLabel)
            emojiLabel.snp.makeConstraints {
                $0.centerX.equalToSuperview()
                $0.centerY.equalToSuperview()
            }
            
            return .customView { containerView }
        }
        return nil
    }
    
    func calendarView(_ calendarView: UICalendarView, didSelect dateComponents: DateComponents?) {
        selectedDate = dateComponents
        fetchWordListAndUpdateCollectionView()
    }
}

먼저 UICalendar 내 메서드 decorationFor 를 사용하면 사진과 같이 날짜 밑에 원하는 내용을 넣을 수 있는 상태로 만들어 줄 수 있다.

final class CoreDataManager {
    // ... 생략된 코드 ...
    
    func hasData(for date: Date) -> Bool {
        let wordsForDate = getWordListFromCoreData(for: date)
        return !wordsForDate.isEmpty
    }
    
    func getWordListFromCoreData(for date: Date) -> [WordEntity] {
        var wordList: [WordEntity] = []
        
        guard let context = managedContext else {
            print("Error: managedContext is nil")
            return wordList
        }
        
        let calendar = Calendar.current
        let startOfDay = calendar.startOfDay(for: date)
        let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
        
        let predicate = NSPredicate(format: "(date >= %@) AND (date < %@)", argumentArray: [startOfDay, endOfDay])
        
        let request: NSFetchRequest<WordEntity> = WordEntity.fetchRequest()
        request.predicate = predicate
        
        do {
            wordList = try context.fetch(request)
        } catch {
            print("Failed to fetch word entities:", error)
        }
        return wordList
    }
}

그 다음 캘린더의 특정 날짜가 데이터를 가지고 있는지 없는지 판단하기 위해 코어데이터 메서드를 만들어주고 가져와서 사용하면 된다!

이렇게 구현이 완료되면 특정 날짜에 데이터가 있다면 이렇게 이모지가 확인된다!

 

여기까지 구현을 완료하니 기능적인 부분은 거의 다 구현이 완료 된 것 같다. 이제 소셜 로그인, 코어데이터 iCloud 저장 기능을 알아보고 디테일을 잡은 후 추가 기능을 구현해 보면서 완성도를 높혀가면 될 것 같다!