Skip to content

[Fix] #249 -필터링 재설정화면 3차 스프린트 1차 QA 반영했습니다.#250

Merged
wjdalswl merged 8 commits intomainfrom
Fix/#249
Jan 11, 2025
Merged

[Fix] #249 -필터링 재설정화면 3차 스프린트 1차 QA 반영했습니다.#250
wjdalswl merged 8 commits intomainfrom
Fix/#249

Conversation

@wjdalswl
Copy link
Copy Markdown
Member

@wjdalswl wjdalswl commented Jan 10, 2025

🩵 Issue

close #249


💙 변경된 내용

  • 상단에 필터라는 텍스트가 없음
  • 처음 체크박스를 누른 후 데이트피커만 움직이면 저장이 활성화 되는 오류
  • 건너뛰기 직후 계획 필터링에 진입했을 때 체크박스가 체크되어 있지 않음
  • 직무 필터링 반영이 안됨
  • [직무 카테고리] 섹션과 [계획] 섹션 간의 간격 수정
  • 직무 선택 안해도 저장하기 버튼이 활성화되어 있음 -> 항상 하나는 선택되게 수정
  • 2가지 메뉴 슬라이드로 왔다갔다 할때, 위에 직무카테고리와 계획아래에 있는 초록색 바가 늦게 따라옴

🅿️ PR Point

1️⃣번 커밋: 직무 선택 안해도 저장하기 버튼이 활성화되어 있음 -> 항상 하나는 선택되게 수정

    private let selectedJobTypeRelay = BehaviorRelay<JobType?>(value: UserFilteringData.shared.jobType ?? JobType.allCases.last)

저장된 직무 필터값 없을때 마지막 값인 전체가 뜨게했습니다.

 func transform(input: Input, disposeBag: DisposeBag) -> Output {
        input.jobSelected
            .subscribe(onNext: { [weak self] indexPath in
                guard let self = self else { return }
                let selectedJob = JobType.allCases[indexPath.item]
                self.selectedJobTypeRelay.accept(selectedJob)
            })
            .disposed(by: disposeBag)
output.selectedJobType
            .drive(onNext: { [weak self] selectedJob in
                guard let self = self, let selectedJob = selectedJob,
                      let index = JobType.allCases.firstIndex(of: selectedJob) else { return }
                let indexPath = IndexPath(item: index, section: 0)
                self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
            })
            .disposed(by: disposeBag)

뷰모델과 뷰컨에서 같은 값 한번 더 누르면 값이 nil이 되는 로직을 제거했습니다.

2️⃣번 커밋: 상단에 필터라는 텍스트 추가

요건 그냥 필터 적혀있는 라벨 FilteringViewController 상단에 추가했습니다.

3️⃣번 커밋: 계획필터링뷰 UILabel ->LabelFactory로 변경

PlanFilteringViewController에서 LabelFactory말고 UILabel 썼길래 수정했습니다.

4️⃣번 커밋: 계획 필터링 관련 QA 반영

  • 처음 체크박스를 누른 후 데이트피커만 움직이면 저장이 활성화 되는 오류 해결
  • 건너뛰기 직후 계획 필터링에 진입했을 때 체크박스가 체크되어 있지 않은 오류 해결
final class UserFilteringData {
    static let shared = UserFilteringData()
    
    var grade: Grade? 
    var workingPeriod: WorkingPeriod?
    var startYear: Int?
    var startMonth: Int?
    var jobType: JobType?
    
    private init() {}
}

final class TemporaryFilteringData {
    static let shared = TemporaryFilteringData()
    
    var grade: Grade?
    var workingPeriod: WorkingPeriod?
    var startYear: Int?
    var startMonth: Int?
    var jobType: JobType?
    
    private init() {}
}

UserFilteringData와 TemporaryFilteringData의 startYear, startMonth에 현재 날짜값 넣어주는 코드 삭제했습니다. -> 초기값이 nil 이 될 수 있도록

기존의 PlanFilteringViewController에 있던 아래 코드를 삭제했습니다.

    private var selectedButtons: [CustomOnboardingButton?] = [nil, nil]

선택된 버튼 값을 뷰모델만 관리하도록 옮겨주었고, 따라서
체크 박스와 선택된 버튼값을 설정해주던 코드들을 함께 삭제했습니다. (아래 코드 삭제)

private func updateCheckBoxState() {
        let hasSelectedButton = selectedButtons.contains { $0 != nil }
        let pickerHasValidSelection = {
            let selectedYearRow = customPickerView.selectedRow(inComponent: 0)
            let selectedMonthRow = customPickerView.selectedRow(inComponent: 1)
            
            let yearIsValid = selectedYearRow >= 0 && selectedYearRow < customPickerView.years.count && customPickerView.years[selectedYearRow] != "-"
            let monthIsValid = selectedMonthRow >= 0 && selectedMonthRow < customPickerView.months.count && customPickerView.months[selectedMonthRow] != "-"
            return yearIsValid || monthIsValid
        }()
        if hasSelectedButton || pickerHasValidSelection {
            checkBox.setChecked(false)
        }
    }
    
    private func updateSelectedButton(section: Int, sender: CustomOnboardingButton) {
        if selectedButtons[section] == sender {
            selectedButtons[section]?.deselectButton()
            selectedButtons[section] = nil
        } else {
            selectedButtons[section]?.deselectButton()
            sender.selectButton()
            selectedButtons[section] = sender
        }
        updateCheckBoxState()
    }
}

bindViewModel() 에서 각각의 버튼이나 데이트 피커, 체크 박스가 동작할때 input으로 해당 값을 전달해주었고

 private func bindViewModel() {
        let gradeSelected = Observable<Grade?>.merge(
            gradeButtons.arrangedSubviews.compactMap { $0 as? CustomOnboardingButton }.map { button in
                button.rx.tap.map { Grade.allCases[button.tag % 10] }
            }
        )
        
        let periodSelected = Observable<WorkingPeriod?>.merge(
            periodButtons.arrangedSubviews.compactMap { $0 as? CustomOnboardingButton }.map { button in
                button.rx.tap.map { WorkingPeriod.allCases[button.tag % 10] }
            }
        )
        
        let dateSelected = Observable<(Int?, Int?)>.create { [weak self] observer -> Disposable in
            self?.customPickerView.onDateSelected = { year, month in
                observer.onNext((year, month))
            }
            return Disposables.create()
        }
        .observe(on: MainScheduler.asyncInstance)
        .share()
        
        let checkBoxToggled = Observable<Bool>.create { [weak self] observer -> Disposable in
            self?.checkBox.action = { isChecked in
                observer.onNext(isChecked)
                if isChecked {
                    self?.customPickerView.addPlaceholder()
                    self?.customPickerView.removePlaceholder()
                }
            }
            return Disposables.create()
        }
        .observe(on: MainScheduler.asyncInstance)

        let input = PlanFilteringViewModel.Input(
            gradeSelected: gradeSelected,
            periodSelected: periodSelected,
            yearSelected: dateSelected.map { $0.0 },
            monthSelected: dateSelected.map { $0.1 },
            checkBoxToggled: checkBoxToggled
        )

output에 따라 UI를 업데이트 해주는 함수를 호출 시켰습니다.
년도와 요일의 경우, 뷰컨트롤러에서 '-'Placeholder를 넣어주는 작업이 필요했어서, .take(1)사용해서 초기값이 설정되는 단 한번만 초기값 년도와 달이 숫자이면 해당 숫자로 세팅 setInitialDate() 아니면 '-'가 추가되도록 하였습니다.

이전 코드와 달리 isCheckBoxChecked와 isFilterApplied(저장하기 버튼 활성화 관련)를 분리하여 관리하였습니다.

  
        let output = viewModel.transform(input: input, disposeBag: disposeBag)
        
        Observable.combineLatest(output.selectedYear.asObservable(), output.selectedMonth.asObservable())
            .take(1)
            .subscribe(onNext: { [weak self] year, month in
                guard let self = self else { return }
                
                if let year = year, let month = month, year > 0, month > 0 {
                    self.customPickerView.setInitialDate(year: year, month: month)
                } else {
                    self.customPickerView.addPlaceholder()
                }
            })
            .disposed(by: disposeBag)

        output.selectedGrade
            .drive(onNext: { [weak self] grade in
                guard let self = self else { return }
                if let grade = grade {
                    self.updateButtonState(for: self.gradeButtons, selectedValue: grade.displayName)
                } else {
                    self.updateButtonState(for: self.gradeButtons, selectedValue: nil)
                }
            })
            .disposed(by: disposeBag)
        
        output.selectedPeriod
            .drive(onNext: { [weak self] period in
                guard let self = self else { return }
                if let period = period {
                    self.updateButtonState(for: self.periodButtons, selectedValue: period.displayName)
                } else {
                    self.updateButtonState(for: self.periodButtons, selectedValue: nil)
                }
            })
            .disposed(by: disposeBag)
        
        output.isCheckBoxChecked
            .drive(onNext: { [weak self] isChecked in
                self?.checkBox.setChecked(isChecked)
            })
            .disposed(by: disposeBag)
        
        output.isFilterApplied
            .drive(filteringState)
            .disposed(by: disposeBag)
    }

Output에서 isFilterApplied를 추가하여 체크 박스와 별개로 상태를 관리하였습니다. 이전 코드에서는 저장하기 상태만 관리하고, 체크 박스 상태를 뷰컨에서 관리를 하였습니다.
Properties에서는 hasNonNilValueRelay와 isCheckBoxRelay가 추가되었습니다.(아래 설명 계속)

final class PlanFilteringViewModel: ViewModelType {
    
    // MARK: - Properties
    
    private let hasNonNilValueRelay = BehaviorRelay<Bool>(value: false)
    private let isCheckBoxRelay = BehaviorRelay<Bool?>(value: nil)
    
    private let gradeRelay = BehaviorRelay<Grade?>(value: UserFilteringData.shared.grade)
    private let periodRelay = BehaviorRelay<WorkingPeriod?>(value: UserFilteringData.shared.workingPeriod)
    private let yearRelay = BehaviorRelay<Int?>(value: UserFilteringData.shared.startYear)
    private let monthRelay = BehaviorRelay<Int?>(value: UserFilteringData.shared.startMonth)
    private let isFilterAppliedRelay = BehaviorRelay<Bool>(value: false)
    
    // MARK: - Input
    
    struct Input {
        ...
    }
    
    // MARK: - Output
    
    struct Output {
        ...
        let isCheckBoxChecked: Driver<Bool>
        let isFilterApplied: Driver<Bool>
    }

체크박스에 input이 들어올 경우, 체크박스가 true 인경우 나머지 계획 필터링 값을 모두 nil을 해주었고,
hasNonNilValueRelay를 false로 해주었습니다.

 input.checkBoxToggled
            .subscribe(onNext: { [weak self] isChecked in
                guard let self = self else { return }
                self.isCheckBoxRelay.accept(isChecked)

                if isChecked {
                    self.gradeRelay.accept(nil)
                    self.periodRelay.accept(nil)
                    self.yearRelay.accept(nil)
                    self.monthRelay.accept(nil)
                    self.hasNonNilValueRelay.accept(false)
                }
            })
            .disposed(by: disposeBag)

hasNonNilValueRelay를 한 이유는,
체크 박스를 클릭한 후 계획 필터링 값이 하나라도 nil이 아니게 될때 체크 박스를 false로 바꾸기 위해 아래와 같은 리턴값을 체크박스 아웃풋 값으로 반환하도록 했었는데,

  let isAllNil = grade == nil && period == nil && year == nil && month == nil
    return isAllNil 

이럴 경우
ex) 체크 박스를 누른 . 학년 버튼을 1학년을 눌렀다가 다시 취소한 경우 다시 체크 박스가 자동 활성화 되는 문제가 있었습니다.

  let isAllNil = grade == nil && period == nil && year == nil && month == nil
  return isAllNil && !self.hasNonNilValueRelay.value

따라서 위와 같은 느낌으로 hasNonNilValueRelay를 통해 사용자가 체크 박스를 누르지 않았는데 모두 Nil값이라 자동 체크박스가 체크되는 것을 방지하였습니다.

2025-01-11.7.25.34.mov

combinedRelays의 경우 체크박스 상태 결정에도 쓰이고, 저장하기 버튼 상태 결정에도 쓰여서 하나로 빼서 사용했습니다.

isCheckBoxChecked의 경우

  1. 체크박스를 수동으로 해제한 경우 바로 우선으로 값이 false로 반환되게 함
  2. 위에 설명한 것 처럼 계획 필터링 값들과, hasNonNilValueRelay를 통해 관리
let combinedRelays = Observable
            .combineLatest(gradeRelay, periodRelay, yearRelay, monthRelay)
        
        let isCheckBoxChecked = Observable
            .combineLatest(combinedRelays, isCheckBoxRelay.asObservable())
            .map { [weak self] relays, isCheckBoxValue -> Bool in
                guard let self = self else { return false }
                let (grade, period, year, month) = relays
                let isAllNil = grade == nil && period == nil && year == nil && month == nil

                if let isCheckBoxValue = isCheckBoxValue {
                    if !isCheckBoxValue {
                        return false
                    }
                }

                if !isAllNil {
                    self.hasNonNilValueRelay.accept(true)
                }
                return isAllNil && !self.hasNonNilValueRelay.value
            }
            .distinctUntilChanged()
            .share(replay: 1, scope: .forever)
        
        let isFilterApplied = Observable
            .combineLatest(combinedRelays, isCheckBoxChecked)
            .map { relays, isCheckBoxChecked in
                let (grade, period, year, month) = relays
                if isCheckBoxChecked { return true }
                return grade != nil && period != nil && year != nil && month != nil
            }
            .distinctUntilChanged()

isFilterApplied의 경우 isCheckBoxChecked와 combinedRelays(=계획 필터링 값들)을 통해 체크 박스가 true 상태이면 항상 저장하기 버튼 활성화, 체크 박스가 false일 경우, 모든 계획 필터링 값이 nil이 아닐때만 저장하기 버튼이 활성화 되도록 하였습니다.

5️⃣번 커밋: [직무 카테고리] 섹션과 [계획] 섹션 간의 간격 수정

기본 UISegmentedControl은 기본적으로 모든 세그먼트의 너비를 균등하게 나누기 때문에,
텍스트의 위치 및 세그먼트 너비가 동일하게 고정되어 세부적인 조정이 불가능했습니다.

텍스트 색상은 UIColor.clear로 설정해 기본 텍스트는 보이지 않게 하고 사용자 정의한 배경 뷰에 텍스트를 추가했습니다.
보이지 않는데 아래 코드가 남아있는 이유는 CustomSegmentedControl의 크기를 기본 텍스트와 스타일 기반으로 계산하고, 이후 커스터마이징된 UI (ex 아래 추가한 backgroundView 등)에서 이를 활용하기 위해 setTitleTextAttributes를 설정했습니다.

private func setUI() {
        removeBorders()
        let normalTextAttributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: UIColor.clear,
            .font: UIFont.title4
        ]
        setTitleTextAttributes(normalTextAttributes, for: .normal)
        selectedSegmentTintColor = .clear
        selectedSegmentIndex = 0
    }

세그먼트의 너비안에서 테스트와의 여백도 조절할 수 없었습니다.
setContentPositionAdjustment(_:forSegmentType:barMetrics:)를 사용해서 억지로 조절할 순 있었으나,
많은 시도를 해보았으나 CustomSegmentedControl로 만들기에는 조절해주는 간격을 규격화 시킬 수가 없었습니다!
따라서 아래와 같은 방법을 사용했습니다.

frameForSegment 함수를 통해 각 세그먼트의 텍스트 크기와 간격을 기준으로 정확한 CGRect를 만들어주었습니다.
addBackgroundView를 통해 기존의 세그먼트 텍스트의 경우 기존의 세그먼트 텍스트 대신 배경 뷰와 레이블을 추가하여 텍스트를 렌더링되도록 하였습니다.

private func frameForSegment(at index: Int) -> CGRect {
        let xOffset = (0..<index).reduce(0) { result, idx in
            result + calculateTextWidth(for: idx) + itemSpacing
        }
        let itemWidth = calculateTextWidth(for: index)
        return CGRect(x: xOffset, y: 0, width: itemWidth, height: bounds.height)
    }

    private func addBackgroundView(for frame: CGRect, at index: Int) {
        subviews.filter { $0.tag == 999 + index }.forEach { $0.removeFromSuperview() }
        
        let textWidth = calculateTextWidth(for: index)
        let segmentFrame = frameForSegment(at: index)
        
        let backgroundFrame = CGRect(
            x: segmentFrame.origin.x + (segmentFrame.width - textWidth) / 2,
            y: 0,
            width: textWidth,
            height: bounds.height
        )
        let backgroundView = UIView(frame: backgroundFrame)
        backgroundView.tag = 999 + index
        
        let label = UILabel(frame: backgroundView.bounds)
        label.text = titleForSegment(at: index)
        label.textColor = (index == selectedSegmentIndex) ? .terningMain : .grey300
        label.font = UIFont.title4
        label.textAlignment = .center
        label.tag = 1000 + index

        backgroundView.addSubview(label)
        insertSubview(backgroundView, at: 0)
    }

백그라운드를 추가해도, 터치 영역은 UISegmentedControl이 자동으로 계산해놓은 값 그대로 였기 때문에 터치 영역도 아래와 같이 텍스트 크기 기반으로 크기를 계산하여 설정해주었습니다.
터치 시 selectedSegmentIndex를 즉시 업데이트하고 valueChanged 이벤트를 트리거 해줍니다.

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        var xOffset: CGFloat = 0
        
        for index in 0..<numberOfSegments {
            let textWidth = calculateTextWidth(for: index)
            let segmentWidth = textWidth
            let segmentFrame = CGRect(x: xOffset, y: 0, width: segmentWidth, height: bounds.height)
            
            if segmentFrame.contains(point) {
                self.selectedSegmentIndex = index
                sendActions(for: .valueChanged)
                return self
            }
            xOffset += (segmentWidth + itemSpacing)
        }
        return nil
    }

추가해준 백그라운드 안의 라벨들은 해당 세그먼트가 선택될때 아래 방식으로 텍스트 색깔이 바뀔 수 있도록 하였습니다.

extension CustomSegmentedControl {
    @objc
    private func segmentValueChanged() {
        updateLabelColors()
    }
}

 private func updateLabelColors() {
        for index in 0..<numberOfSegments {
            if let backgroundView = subviews.first(where: { $0.tag == 999 + index }),
               let label = backgroundView.subviews.first(where: { $0 is UILabel }) as? UILabel {
                label.textColor = (selectedSegmentIndex == index) ? .terningMain : .grey300
            }
        }
    }

6️⃣번 커밋: 초록색 바가 따라오는 속도 수정

underbar.layer.removeAllAnimations()을 추가하여 이전에 실행중인 애니메이션이 있으면 강제로 중단하도록 하였습니다.
애니메이션 시간을 0.27 -> 0.17로 줄였는데,
안드가 어떻게 구현을 했는지 몰라서, 초록바 따라오는 속도가 느리다는게 애니메이션을 아예 없애라는 것일까요?? 바로 바로 밑에 뜨게? 일단 시간 줄여서 구현해놓았습니다!

private func updateUnderbarPosition() {
        let selectedSegmentFrame = frameForSegment(at: selectedSegmentIndex)
        let textWidth = calculateTextWidth(for: selectedSegmentIndex)
        
        underbar.layer.removeAllAnimations()
        
        UIView.animate(withDuration: 0.17, delay: 0, options: .curveLinear, animations: {
            self.underbar.frame = CGRect(
                x: selectedSegmentFrame.origin.x + (selectedSegmentFrame.width - textWidth) / 2,
                y: self.bounds.height - self.underbarInfo.height,
                width: selectedSegmentFrame.width,
                height: self.underbarInfo.height
            )
            self.layoutIfNeeded()
        })
    }

📘 ScreenShot

2025-01-11.4.03.03.mov

@wjdalswl wjdalswl added Fix 🐛 버그, 오류 해결, 코드 수정 민지 🌷 labels Jan 10, 2025
@wjdalswl wjdalswl self-assigned this Jan 10, 2025
@wjdalswl wjdalswl linked an issue Jan 10, 2025 that may be closed by this pull request
7 tasks
@wjdalswl wjdalswl requested a review from thingineeer January 10, 2025 23:01
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simulator Screen Recording - iPhone 16 - 2025-01-11 at 15 48 41

필터 박스를 클릭하고, 다시 돌아오면 날짜 빼고 학년, 기간의 데이터가 그대로 남아 있어서 이 부분 한번 확인 해주세요!

아래는 네모박스 누른 후 저장 하고, 다시 필터링 정보 불러오는 서버통신 결과 입니다. (기존 데이터가 남아있음)
image

Copy link
Copy Markdown
Member Author

@wjdalswl wjdalswl Jan 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정해서 올렸습니다!

2025-01-11.5.00.23.mov

Copy link
Copy Markdown
Member

@thingineeer thingineeer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

설명을 야물딱 지게 해서 코드 이해하기는 편했습니다.
코드가 조금 복잡하긴 한데 천천히 리팩하면 좋을 것 같아요!

@wjdalswl wjdalswl merged commit 158ea66 into main Jan 11, 2025
@wjdalswl wjdalswl deleted the Fix/#249 branch January 11, 2025 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Fix 🐛 버그, 오류 해결, 코드 수정 민지 🌷

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Fix] - 필터링 재설정화면 3차 스프린트 1차 QA 반영

2 participants