ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [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
        }
    
    }
    

    위 코드로 인해 아래의 조건은 만족한다.

    1. UITextField의 컨텐츠가 너무 짧을 때 라벨은 갱신되지 않는다.
    2. 유저가 UITextField에 값을 넣고 있다면 라벨은 갱신되지 않는다.

    하지만 유저가 아래의 프로세스와 같이 행동한다면 동일한 행동을 2번 하게 된다.

    1. “안녕하세요” 입력 → “안녕하세요” 스트림에 추가
    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
Designed by Tistory.