-
[iOS/Swift] Combine 알아보기 v야매 iOS 2023. 3. 5. 22:57
Combine으로 User Input에 반응하기
유저가 Tap, Drag 등과 같은 액션을 하면 데이터가 변경되고 앱의 상태를 변경한다.
동적으로 일어나는 이런 행동들을 Combine을 통해 어떻게 대응할 수 있는지에 대해 알아본다.
User Input에 따라 UI 갱신하기
이때까지 assign(to: on:)을 사용해 publisher가 방출한 데이터를 UI 요소에 넣어줬다.
- publisher가 방출한 문자열을 UILabel의 텍스트로 넣어주는 방식
하지만 그것보다 Property가 방출한 값이 프로퍼티를 갱신하고 그 프로퍼티가 UI를 갱신하게 할 수도 있다.
날 것 그대로 버전
class SliderViewController: UIViewController { let slider = UISlider() let label = UILabel() init() { super.init(nibName: nil, bundle: nil) view.backgroundColor = .white render() updateLabel() slider.addTarget(self, action: #selector(updateLabel), for: .valueChanged) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func render() { view.addSubview(slider) view.addSubview(label) slider.snp.makeConstraints { $0.top.equalToSuperview().inset(100) $0.leading.trailing.equalToSuperview().inset(20) } label.snp.makeConstraints { $0.top.equalTo(slider.snp.bottom).offset(30) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(30) } } @objc func updateLabel() { label.text = "Slider is at \\(slider.value)" } }
예시와 같은 접근 방법은 아래와 같은 한계점을 갖는다.
- slider.value에 값을 직접 할당할 경우 slider의 valueChanged 액션이 발동되지 않는다.
- UILabel의 업데이트를 직접 해줘야 한다.
didSet 사용
// Using Didset class SliderViewController: UIViewController { let slider = UISlider() let label = UILabel() var sliderValue: Float = 50 { didSet { slider.value = sliderValue label.text = "Slider is at \\(slider.value)" } } init() { super.init(nibName: nil, bundle: nil) view.backgroundColor = .white render() updateLabel() slider.addTarget(self, action: #selector(updateLabel), for: .valueChanged) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func render() { view.addSubview(slider) view.addSubview(label) slider.snp.makeConstraints { $0.top.equalToSuperview().inset(100) $0.leading.trailing.equalToSuperview().inset(20) } label.snp.makeConstraints { $0.top.equalTo(slider.snp.bottom).offset(30) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(30) } } @objc func updateLabel() { sliderValue = slider.value } }
위 접근법 또한 한계점을 가진다.
- didSet은 값이 갱신되어야 호출되므로 초기값을 설정했을 때 didSet의 블럭이 호출되지 않는다.
- ViewDidLoad에서 초기값을 사용해 UILabel을 업데이트 하면 문제 해결 가능
Combine 사용
class SliderViewController: UIViewController { let slider = UISlider() let label = UILabel() @Published var sliderValue: Float = 0.5 var cancellables = Set<AnyCancellable>() init() { super.init(nibName: nil, bundle: nil) view.backgroundColor = .white bind() render() updateLabel() slider.addTarget(self, action: #selector(updateLabel), for: .valueChanged) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func bind() { $sliderValue .map({ value in "Slider is at \\(value)" }) .assign(to: \\.text, on: label) .store(in: &cancellables) $sliderValue .assign(to: \\.value, on: slider) .store(in: &cancellables) } func render() { view.addSubview(slider) view.addSubview(label) slider.snp.makeConstraints { $0.top.equalToSuperview().inset(100) $0.leading.trailing.equalToSuperview().inset(20) } label.snp.makeConstraints { $0.top.equalTo(slider.snp.bottom).offset(30) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(30) } } @objc func updateLabel() { sliderValue = slider.value } }
위 접근법은 Combine을 적용했다.
Combine을 적용하지 않은 해결법보다 많은 코드를 작성했지만 sliderValue의 변경값이 UI에 확실히 반영된다.
UIKit에 Combine이 내장되어 있지 않다.
- RxSwift 또는 RxCocoa는 UITextField.rx.text를 사용해 UITextField의 text 변경사항을 얻을 수 있었지만 Combine은 특별하게 제공하는 기능이 없다.
그러므로 two-way binding이 일어난다.
- UISlider의 값은 sliderValue가 변경될 때 갱신
- 사용자가 UISlider를 움직였을 때 SliderValue 갱신
애플은 NSObject에게 publisher(for:)를 구현했다. 이 publisher는 NSObject를 상속받은 클래스들의 KVO 변경사항들을 구독할 수 있게 한다. UISlider의 상위 클래스인 UIControl이 NSObject를 상속받는다. slider.publisher(for: \.value)를 사용해 변경사항을 구독할 수 있을 것 같지만 UIControl의 서버클래스들은 KVO를 지원하지 않으므로 동작하지 않는다.
SwiftUI and Combine
struct ExampleView: View { @State private var sliderValue: Float = 50 var body: some View { VStack { Text("Slider's valkue \\(sliderValue)") Slider(value: $sliderValue, in: (1...100)) } } }
SwiftUI와 Combine을 같이 사용하면 UIKit과 Combine을 사용했을 때와 달리 명시적으로 sliderValue를 업데이트 할 필요가 없다.
@State
- UI 요소와 바인딩을 생성하기 위해서 사용
- @State로 마크된 프로퍼티가 변경되면 UI 업데이트를 요청한다.
User Input의 빈도를 제한하기
User Input이 들어왔을 때 네트워크 요청을 보내야 하는 상황이 많다.
- UITextField에 값이 들어왔을 때 그 값을 바탕으로 Request를 보내고 Response를 받아와 화면에 보여준다.
- 요청이 많이 생성된다
- 요청으로 받아오는 데이터 중 대부분의 데이터는 필요없는 데이터
Debouncing
들어온 user input을 처리하기 전 일정 시간을 기다리는 것
without Debouncing
class DebounceViewController: UIViewController { let textField = UITextField().then { $0.borderStyle = .roundedRect } let label = UILabel() @Published var searchQuery: String? var cancellables = Set<AnyCancellable>() override func viewDidLoad() { super.viewDidLoad() } init() { super.init(nibName: nil, bundle: nil) view.backgroundColor = .white bind() render() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func bind() { textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) $searchQuery .assign(to: \\.text, on: label) .store(in: &cancellables) } func render() { view.addSubview(textField) view.addSubview(label) textField.snp.makeConstraints { $0.top.equalToSuperview().inset(100) $0.leading.trailing.equalToSuperview().inset(20) } label.snp.makeConstraints { $0.top.equalTo(textField.snp.bottom).offset(50) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(100) } } @objc func textChanged() { searchQuery = textField.text } }
TextField의 값이 변경되면 @Published 프로퍼티가 갱신되고 UILabel의 text 값 또한 변경된다.
Combine은 debounce()연산자를 내장한다.
with Debouncing
class DebounceViewController: UIViewController { let textField = UITextField().then { $0.borderStyle = .roundedRect } let label = UILabel() @Published var searchQuery: String? var cancellables = Set<AnyCancellable>() init() { super.init(nibName: nil, bundle: nil) view.backgroundColor = .white bind() render() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func bind() { textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) $searchQuery // 300ms 내에 들어온 입력 중 마지막 입력만 subscriber에게 전달된다. .debounce(for: 0.3, scheduler: DispatchQueue.main) .assign(to: \\.text, on: label) .store(in: &cancellables) } func render() { view.addSubview(textField) view.addSubview(label) textField.snp.makeConstraints { $0.top.equalToSuperview().inset(100) $0.leading.trailing.equalToSuperview().inset(20) } label.snp.makeConstraints { $0.top.equalTo(textField.snp.bottom).offset(50) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(100) } } @objc func textChanged() { searchQuery = textField.text } }
유저가 문자열을 입력하다가 잠깐 멈추면 라벨이 업데이트된다.
with Debouncing and Filter
일정 길이의 문자열이 들어올 때만 조회해야 할 때 filter를 같이 사용한다.
class DebounceViewController: UIViewController { let textField = UITextField().then { $0.borderStyle = .roundedRect } let label = UILabel() @Published var searchQuery: String? var cancellables = Set<AnyCancellable>() override func viewDidLoad() { super.viewDidLoad() } init() { super.init(nibName: nil, bundle: nil) view.backgroundColor = .white bind() render() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func bind() { textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) $searchQuery .debounce(for: 0.3, scheduler: DispatchQueue.main) .filter({ ($0 ?? "").count > 2}) .assign(to: \\.text, on: label) .store(in: &cancellables) } func render() { view.addSubview(textField) view.addSubview(label) textField.snp.makeConstraints { $0.top.equalToSuperview().inset(100) $0.leading.trailing.equalToSuperview().inset(20) } label.snp.makeConstraints { $0.top.equalTo(textField.snp.bottom).offset(50) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(100) } } @objc func textChanged() { searchQuery = textField.text } }
위 코드로 인해 아래의 조건은 만족한다.
- UITextField의 컨텐츠가 너무 짧을 때 라벨은 갱신되지 않는다.
- 유저가 UITextField에 값을 넣고 있다면 라벨은 갱신되지 않는다.
하지만 유저가 아래의 프로세스와 같이 행동한다면 동일한 행동을 2번 하게 된다.
- “안녕하세요” 입력 → “안녕하세요” 스트림에 추가
- 다른 문자열 추가 후 추가한 문자열 삭제 → “안녕하세요” 스트림에 추가
“안녕하세요”가 2번 들어오는 것을 확인할 수 있다.
with Debounce, Filter and removeDuplicates
Debounce와 Filter만 사용했을 때 마주하는 한계점을 해결하기 위해 removeDuplicates를 사용한다.
이제 “안녕하세요” 이벤트가 2번 방출되어도 subscriber에게는 단 하나의 이벤트만 전달된다.
class DebounceViewController: UIViewController { let textField = UITextField().then { $0.borderStyle = .roundedRect } let label = UILabel() @Published var searchQuery: String? var cancellables = Set<AnyCancellable>() override func viewDidLoad() { super.viewDidLoad() } init() { super.init(nibName: nil, bundle: nil) view.backgroundColor = .white bind() render() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func bind() { textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) $searchQuery .debounce(for: 0.3, scheduler: DispatchQueue.main) .filter({ ($0 ?? "").count > 2}) .removeDuplicates() .assign(to: \\.text, on: label) .store(in: &cancellables) } func render() { view.addSubview(textField) view.addSubview(label) textField.snp.makeConstraints { $0.top.equalToSuperview().inset(100) $0.leading.trailing.equalToSuperview().inset(20) } label.snp.makeConstraints { $0.top.equalTo(textField.snp.bottom).offset(50) $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(100) } } @objc func textChanged() { searchQuery = textField.text } }
Throttle
// 마지막으로 들어온 값 사용 throttle(latest: true) // 처음으로 들어온 값 사용 throttle(latest: false)
Debounce는 입력이 들어오면 대기 시간이 초기화된다.
- 입력이 연속적으로 들어오게 되면 이벤트가 방출되지 않는다
Throttle은 일정 기간이 지나면 이벤트를 방출한다.
Throttle을 할 때 그 기간에 들어온 첫 번째 값을 방출할 것인지 마지막 값을 방출할 것인지 결정할 수 있다.
워드에서 글자 수를 계산할 때 유용하게 사용할 수 있다.
여러 User Input을 하나의 publisher로 합치기
성을 입력받는 UITextField가 있고 이름을 입력받는 UITextField가 있다.
입력받은 성과 이름 합쳐서 표시하는 UILabel이 있다.
- 유저가 제공하는 정보에 따라 UILabel의 값이 동적으로 변한다.
@Published var firstName = "" @Published var lastName = ""
목표는 firstName publisher와 lastName publisher의 ouput을 합쳐 하나의 요소를 방출하는 publisher를 만드는 것
여러 Publisher가 방출한 값을 통합하거나 합치는 Combine의 publishers 중 하나를 사용한다.
- Publishers.Zip
- Publishers.Merge
- Publishers.CombineLatest
Publishers.zip
2개의 publisher에서 방출한 값을 튜플 형태로 subscriber에게 전달한다.
- Publishers.Zip3, Publishers.Zip4 등 동일한 기능을 하지만 다른 매개변수 수를 지원하는 Publishers가 있다.
- Variadic Generic을 사용하지 않아서(?)
var cancellables = Set<AnyCancellable>() let firstNotificationName = Notification.Name("first") let secondNotificationName = Notification.Name("second") let firstNotification = Notification(name: firstNotificationName) let secondNotification = Notification(name: secondNotificationName) let first = NotificationCenter.default.publisher(for: firstNotificationName) let second = NotificationCenter.default.publisher(for: secondNotificationName) Publishers.Zip(first, second) .sink(receiveValue: { print($0, "zipped") }) .store(in: &cancellables) //first.zip(second).sink(receiveValue: { // print($0, "zipped") //}).store(in: &cancellables) print("send first") NotificationCenter.default.post(firstNotification) print("send second") NotificationCenter.default.post(secondNotification) print("send third") NotificationCenter.default.post(firstNotification) print("send fourth") NotificationCenter.default.post(secondNotification) /* Output send first send second (name = first, object = nil, userInfo = nil, name = second, object = nil, userInfo = nil) zipped send third send fourth (name = first, object = nil, userInfo = nil, name = second, object = nil, userInfo = nil) zipped */
2개의 publisher가 모두 값을 방출해야 zip에서 튜플이 방출된다.
- 하나의 publisher가 값을 방출할 준비가 되어 있고 다른 publisher는 그렇지 않다면 zip은 튜플을 방출하지 않는다.
let left = CurrentValueSubject<Int,Never>(0) let right = CurrentValueSubject<Int,Never>(0) left.zip(right).sink(receiveValue: { print($0) }).store(in: &cancellables) left.value = 1 left.value = 2 left.value = 3 right.value = 1 right.value = 2 /* (0, 0) (1, 1) (2, 2) left가 4번째 요소를 방출했지만 right는 그렇지 않았으므로 zip으로부터 새로운 튜플이 만들어지지 않았다. */
성과 이름 중에 하나라도 변경될 때 UILabel에 적용하고 싶기 때문에 zip을 사용하는 것은 적절하지 않다.
Publishers.Merge
Merge도 Zip과 마찬가지로 매개변수의 수에 따른 여러 버전이 존재한다
- Publishers.Merge ~ Publishers.Merge8 & Publishers.MergeMany
let firstNotificationName = Notification.Name("first") let secondNotificationName = Notification.Name("second") let firstNotification = Notification(name: firstNotificationName) let secondNotification = Notification(name: secondNotificationName) let first = NotificationCenter.default.publisher(for: firstNotificationName) let second = NotificationCenter.default.publisher(for: secondNotificationName) Publishers.Merge(first, second) .sink(receiveValue: { print($0, "merged") }).store(in: &cancellables) first.merge(with: second).sink(receiveValue: { print($0, "merged") }).store(in: &cancellables) /* send first name = first, object = nil, userInfo = nil merged send second name = second, object = nil, userInfo = nil merged send third name = first, object = nil, userInfo = nil merged send fourth name = second, object = nil, userInfo = nil merged */
각 publisher가 값을 방출할 때마다 Publishers.Merge는 값을 방출한다.
- 단일 값을 방출한다.
성과 이름 중 어떤 것이 변경되었을 때 그에 대한 이벤트를 받는 것은 좋지만 어떤 곳에서 변경이 일어났는지 알 수 없으므로 부적절하다.
Publishers.CombineLatest
CombineLatest 또한 Publishers.Zip과 Publishers.Merge처럼 다양한 버전이 존재한다.
let firstNotificationName = Notification.Name("first") let secondNotificationName = Notification.Name("second") let firstNotification = Notification(name: firstNotificationName) let secondNotification = Notification(name: secondNotificationName) let first = NotificationCenter.default.publisher(for: firstNotificationName) let second = NotificationCenter.default.publisher(for: secondNotificationName) first.combineLatest(second).sink(receiveValue: { print($0, "combined") }).store(in: &cancellables) print("send first") NotificationCenter.default.post(firstNotification) print("send second") NotificationCenter.default.post(secondNotification) print("send third") NotificationCenter.default.post(firstNotification) print("send fourth") NotificationCenter.default.post(secondNotification) /* send first send second (name = first, object = nil, userInfo = nil, name = second, object = nil, userInfo = nil) combined send third (name = first, object = nil, userInfo = nil, name = second, object = nil, userInfo = nil) combined send fourth (name = first, object = nil, userInfo = nil, name = second, object = nil, userInfo = nil) combined */
어느 한 Publisher가 값을 방출하면 다른 Publisher가 가장 최근에 방출한 값을 튜플로 묶어 subscriber에게 전달한다.
성과 이름이 변경됐을 때 그 최신값을 항상 받아올 수 있는 CombineLatest가 적절하다.
$firstName .combineLatest($lastName) .map({ combined in return "\\(combined.0) \\(combined.1)" }) .assign(to: \\.text, on: fullNameLabel) .store(in: &cancellables)
'야매 iOS' 카테고리의 다른 글
[iOS/Swift] Combine 알아보기 vii (0) 2023.03.26 [iOS/Swift] Combine 알아보기 vi (0) 2023.03.26 [iOS/Swift] Combine 알아보기 iv (0) 2023.02.27 [iOS/Swift] Combine 알아보기 iii (0) 2023.02.25 [iOS/Swift] Combine 알아보기 ii (0) 2023.02.23