안녕하세요. 딜리셔스 모바일 팀 iOS 파트 유현지입니다.
최근 파트 내에서 RxSwift 스터디를 진행했고, 저는 이를 이용해 평소 필요성을 느꼈던 공통 UITextField 리팩토링 작업을 진행하고 있습니다.
이 글에서는 기존 공통 UITextField에 대한 요구사항들과 이를 RxSwift를 이용해 어떤식으로 개선했는지 기술하고자 합니다.
(※ 리팩토링 중 RxSwift와 관련 없는 부분은 생략하며, 편의를 위해 기존 공통 UITextField
를 CMTextField
로 표기합니다.)
개요
RxSwift를 이용해 리팩토링을 진행한 목적은 다음과 같습니다.
- 스터디 학습 적용
- CMTextField를 사용하면서 느꼈던 불편사항 개선
- delegate, rx, addtarget 으로 파편화된 ViewController에서의 CMTextField 사용을 하나의 방식으로 통일
- 완성된 이후의 텍스트를 받아서 처리해야 하는 delegate 사용의 번거로움, 부정확함 개선
요구사항
리팩토링 시작 전 iOS파트에서 기존 CMTextField에 대한 요구사항들을 취합했고, 그 중 RxSwift를 이용해 개선할 수 있는것을 정리했습니다.
- 생성할 때 아래 값들을 세팅하면 CMTextField 내부에서 처리되었으면 좋겠다.
- maxLength
- TextField 상태에 따른 font, border, background 색상
- 입력제한 타입(ex. 이모지, 특수문자 등)
- 포맷 타입(ex. 휴대폰, 이메일 등)
- 포맷 설정 시 입력한 텍스트에 자동으로 포맷에 맞는 형식이 적용
- 파편화된 사용법이 하나로 통일됐으면 좋겠다.
- ViewController에서의 CMTextField 사용 방식 통합
- delegate, addTarget, rx로 파편화되어 있는 방식을 rx로 통합
- ViewController에서의 CMTextField 사용 방식 통합
RxSwift를 이용한 개선
요구사항을 개선하기 위해 CMTextField엔 3개의 Stream이 필요합니다.
- text가 변경될 때 그 String을 방출하는 ControlProperty
- editing 시작, 종료 시 ControlEvent를 방출하는 Observable
- CMTextField의 상태(normal, editing, error)를 방출하는 Observable
차례대로 살펴보겠습니다.
rx.changedText
정의
text가 변경될 때만 그 String을 방출하는 ControlProperty
기능
- 방출된 string을 요구사항 1번의 설정값을 적용한 Operator로 연산하여 최종 string 방출
- 최종 text를 방출하는 Driver를 구독해서 UI Binding 처리
→ 완성된 이후의 string을 delegate에서 받아 처리하는 번거로움 개선
설명
RxSwift에서 제공하는 UITextField.rx.text
는
controlPropertyWithDefaultEvents
이벤트가 발생할 때 방출되기 때문에
.editingChanged
이벤트 + 최초 초기화 1회 + 포커싱 + 언포커싱 될 때 모두 방출됩니다.
이는 우리의 CMTextField에 맞지 않기 때문에 controlPropertyWithDefaultEvents
중에서 값이 변경될 때 발생하는 이벤트인 .editingChanged
, .valueChanged
가 발생할 때만 방출되는 ControlProperty를 새로 정의했습니다.
public var changedText: ControlProperty<String?> {
return base.rx.controlProperty(editingEvents: [.editingChanged, .valueChanged], getter: { textField in
textField.text
}, setter: { textField, value in
if textField.text != value {
textField.text = value
}
})
}
사용은 rx.changedText에서 방출한 String → Operator 과정 → 최종 String 방출 Driver 구독 → UI Binding 순으로 이루어집니다.
Flow Chart
입력된 text가 최초 방출되고 초기화 시 설정한 값으로 만든 Operator들에 의해 연산되는 과정을 그린 플로우 차트 입니다.
각 Operator에서는 다음과 같은 기능을 수행합니다.
- orEmpty 연산
- 역할
- optional 해제를 위해 추가합니다.
- 역할
- scan 연산
- 역할
- scan 연산자는 이전에 방출된 값과 새로 방출된 값을 결합해 방출할 아이템을 생성할 수 있습니다.
- 주로 새로 방출된 값을 검사하는 로직을 넣어 통과하지 못하면 이전에 방출된 값을 방출시킵니다.
- 기능
- maxLength 검사
- 입력 제한 타입 검사
- 사용
let test: Driver<String?> = self.rx.changedText .orEmpty .scan(self.text) { [weak self] (prev, new) -> String? in guard let s = self else { return prev } // 1. maxLength 검사 // 2. 입력 제한 타입 검사 // 그 밖의 검사 로직 // 위 검사를 통과하지 못하면 이전 값인 prev를 방출합니다. return new } .asDriver(onErrorJustReturn: "")
- 역할
- map 연산
- 역할
- map 연산자는 수식에 맞게 변환한 값을 방출시킵니다.
- 기능
- 포맷 타입(ex. 전화번호, 이메일 등)에 맞게 변환
-
사용
.map { [weak self] str -> String? in guard let s = self else { return "" } // 포맷에 맞는 변환 처리 return str }
- 역할
- asDriver
- 역할
- 최종적으로 변환한 값을 방출하는 Driver로 변환합니다.
- CMTextField의 rx.text에 이 Driver를 binding 하여 UI를 나타냅니다.
- 역할
let rxChangedTextDriver: Driver<String?> = self.rx.changedText
.orEmpty
.scan(self.text) { [weak self] (prev, new) -> String? in
guard let s = self else { return prev }
// 1. maxLength 검사
// 2. 입력 제한 타입 검사
// 위 검사를 통과하지 못하면 이전 값인 prev를 방출합니다.
return new
}
.map { [weak self] str -> String? in
guard let s = self else { return "" }
// 포맷에 맞는 변환
// Ex. 전화번호인 경우
if s.format == .tel {
return str?.insertHyphen()
}
return str
}
.asDriver(onErrorJustReturn: "")
// 최종 UI Binding
rxChangedTextDriver
.drive(self.rx.text)
.disposed(by: self.disposeBag)
2. rx.editingAction
정의
editing 시작, 종료 시 발생한 TargetedControlEvent
를 방출하는 Observable
(TargetedControlEvent은 Rx+Control에 정의된 제네릭 타입)
struct TargetedControlEvent<T> {
var event: UIControl.Event
var target: T
}
기능
editingDidBegin
, editingDidEnd
, editingDidEndOnExit
이벤트가 불릴 때 그 event를 방출합니다. editingEvent를 하나의 Stream에서 관리할 수 있어 각각의 event 함수에서 처리해야 하는 delegate 사용 불편함을 개선할 수 있었습니다.
설명
UIControl.event
에 따라 별도 처리를 수행해야 하는 TextField를 위해 정의된 Observable입니다.
예시)
온라인몰 주소 변경의 CMTextField는 UIControl.Event
마다 다른 처리가 필요합니다.
- editingDidBegin
- editing이 시작 시 editing 상태의 color 적용
- editingDidEnd, editingDidEndOnExit
- editing 종료 후 입력된 text의 validation 검증
- Y: normal 상태의 color 적용
- N: error 상태의 color 적용, 온라인몰 주소 변경 UILabel의 textColor도 변경
- editing 종료 후 입력된 text의 validation 검증
예시처럼 UIControl.Event
마다 별도 처리를 하기 위해서는 CMTextField를 사용하는 ViewController에서 textField에 어떤 이벤트가 불렸는지 알아야 합니다.
RxSwift에서 제공하는 rx.controlEvent
를 사용해도 되지만 editingDidBegin
, .editingDidEnd
, .editingDidEndOnExit
각각 구독해 처리해야 되기 때문에 불편합니다.
따라서 필요한 Event가 불릴 때 그 TargetedControlEvent
를 방출하는 Observable
을 정의했습니다.
var editingAction: Observable<TargetedControlEvent<Base>> {
let events: [UIControl.Event] = [
.editingDidBegin,
.editingDidEnd,
.editingDidEndOnExit
]
let eventObservables = events.map({ controlEvent(event: $0) })
return Observable.merge(eventObservables)
}
사용
TargetedControlEvent
을 방출하기 때문에 event만 사용하기 위해서 map 연산자로 가공해 사용합니다.
ViewController에서 사용하기 위해 UIControl.Event
를 방출하는 PublishRelay
를 생성하고 rx.editingAction
이 방출할 때 map 연산자로 뽑은 UIControl.event
를 accept 해줍니다.
self.rx.editingAction
.map { $0.event }
.subscribe(onNext: { [weak self] in
guard let s = self else { return }
// VC에서 사용할 PublishRelay에 방출된 event값을 accept.
s.rxEditingAction.accept($0)
})
.disposed(by: self.disposeBag)
ViewController에서의 사용은 다음과 같습니다.
self.testTextField.rxEditingAction.subscribe(onNext: { [weak self] event in
guard let s = self else { return }
switch event {
case .editingDidBegin:
// editing 시작 시 처리해야할 로직
case .editingDidEnd, .editingDidEndOnExit:
// editing 종료 시 처리해야할 로직
default:
break
}
}).disposed(by: self.disposedBag)
3. rxStatus
정의
CMTextField의 상태(normal, editing, error)를 방출하는 Observable
기능
- 상태에 따른 변경을 처리해주기 위한 Observable 입니다.
- CMTextField의 상태 Enum(normal, editing, error)을 방출하는 PublishRelay 입니다.
사용
rxEditingAction
을 subscribe할 때 rxStatus
의 값을 accept해주는 코드 입니다.
self.testTextField.rxEditingAction.subscribe(onNext: { [weak self] event in
guard let s = self else { return }
switch event {
case .editingDidBegin:
// editing 상태 accept
s.testTextField.rxStatus.accept(.editing)
case .editingDidEnd, .editingDidEndOnExit:
// editing 종료 후 normal/error 판별 로직 수행
if 텍스트 validation == true {
s.testTextField.rxStatus.accept(.normal)
} else {
s.testTextField.rxStatus.accept(.error)
}
default:
break
}
}).disposed(by: self.disposedBag)
ViewController에서는 rxStatus
를 subscribe해서 각 상태별로 별도 처리 할 수 있습니다.
self.testTextField.rxStatus.subscribe(onNext: { [weak self] status in
switch status {
case .normal:
// normal 상태의 경우
case .editing:
// editing 상태의 경우
case .error:
// error 상태의 경우
}
}).disposed(by: self.disposedBag)
위 3개의 Stream으로 파트내에서 취합한 요구사항들을 RxSwift를 이용해 개선할 수 있었습니다.
고민사항
작업하며 어떤 식으로 처리해야 할지 고민 중인 부분들을 정리했습니다.
-
코드에서 값 대입 시 처리
rx.changedText는 ControlProperty이기 때문에 사용자가 직접 text를 입력&붙여넣기 하는 경우 방출됩니다. 따라서 개발자가 코드에서
textField.text = "new"
와 같이 직접 대입할 때는 불리지 않습니다. 이것에 대한 처리를 CMTextField에 넣어줘야 할지, 특수한 경우니 사용자가 VC에서 별도 처리하게 해야 할지 고민 중입니다. -
Delegate, Rx 파편화
현재 한 ViewController에 여러 개의 UITextField가 있는 경우 UITextFieldDelegate 함수 안에서 모든 TextField를 관리하고 있습니다.
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if textField == self.tf1 { // 처리 } else if textField == self.tf2 { // 처리 } else if textField == self.tf3 { // 처리 } return true }
Rx로 방법을 통일할 경우 각 textField마다 subscribe를 해줘야 해서, 오히려 코드가 늘어나는 경우가 생깁니다.
따라서 textField가 많은 VC인 경우엔 delegate를 사용해서 한 함수에서 모두 관리하도록 하는 게 좋을지에 대한 고민이 있습니다.
하지만 이는 리팩토링 하는 사람이 강제해야 할 부분인 것 같아 Rx를 사용하는 방식으로 통일하도록 할 생각입니다.
마무리
리팩토링 진행은 현재 중간 피드백을 받고 신상마켓 내부에 있는 기존 CMTextField를 교체하는 식으로 진행하고 있습니다.
팀원 모두가 써야 하는 공통 컴포넌트인 만큼 사용하는 입장에서 편하게 사용할 수 있는지가 제일 큰 고민이었습니다.
하지만 올해 진행했던 RxSwift 스터디를 실제 프로젝트에 녹인다는 것은 즐거운 경험이었습니다.
아직 완전한 상태의 작업은 아니지만, 앞으로도 중간중간 팀원분들의 피드백을 받으며 깔끔하게 기존 CMTextField를 리팩토링 버전으로 교체하는 것이 목표입니다.
![](/assets/image/writers/유현지.jpeg)
유현지
신상마켓 iOS 개발자
"야망있게 살고싶은 개발자 입니다."