Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
SwiftUI/SwiftUI(Intermediate)

20. @Published / Subscriber / Combine

by Toughie 2023. 6. 14.

⭐️@Published / Subscriber / Combine⭐️

지난 시간에 이어 Combine을 활용해서 로그인 화면을 구현해 보자.

 

4글자 이상, 타이머는 10초 이상인 경우에만 로그인이 가능한 구조이다.

이번에는 코드마다 주석 처리를 해서 정리를 해보았다.

//  Created by Toughie on 2023/06/13.
//

import SwiftUI
import Combine

final class SubscriberViewModel: ObservableObject {
    //해당 값의 변경이 발생하면 자동으로 관련 뷰에 알림을 보내 렌더링 업데이트가 이루어짐.
    @Published var count: Int = 0
    
    //Combine에서 구독을 취소할 때 사용되는 객체 타입인 AnyCancellable 객체의 집합(sink 메서드가 반환)
    var cancellables = Set<AnyCancellable>()
    
    //텍스트 확인
    @Published var textFieldText: String = ""
    @Published var textIsValid: Bool = false
    
    //타이머 변수를 직접 사용한다면
//    var timer: AnyCancellable?
    
    //버튼 토글
    @Published var showButton: Bool = false
    
    init() {
        setUpTimer()
        addTextFieldSubscriber()
        addButtonSubscriber()
    }
    
    func setUpTimer() {
        Timer
            .publish(every: 1, on: .main, in: .common)
            .autoconnect()
            //self?.count도 가능하지만 아래와 같이 guard문을 사용해서 옵셔널 바인딩을 해주는 방법도 있음.
            //[weak self]를 통해 약한 참조 캡쳐 for 메모리 누수 방지.
            .sink { [weak self] _ in
                guard let self = self else { return }
                self.count += 1
                
//                만약 타이머가 10이상이 되었을 때 멈추도록 하려면?(
//                if self.count >= 10 {
//                    self.timer?.cancel()
//                }
                /*
                 ⭐️.sink 메서드란?
                 Subscriber를 등록하고, 구독을 취소할 수 있는 AnyCancellable 객체를 반환
                 여기서는 Timer Publisher가 값을 방출할 때마다 sink의 클로저가 호출 됨.
                 이 클로저가 해당 값에 대한 처리를 수행할 수 있음.
                 즉 subscriber는 클로저 자체라고 볼 수 있음.
                 */
                
                    // 여기서는 Set<AnyCancellable>에서
//                    for item in cancellables {
//                        item.cancel()
//                    }
//                }
            }
        //AnyCancellable 객체를 cancellables 집합에 저장해서 구독을 관리함.
        //Timer 퍼블리셔의 구독을 취소할 때 해당 AnyCancellable을 사용해서 구독 취소 가능
        //ex. 앱이 종료되거나 더이상 필요하지 않을 때 취소 가능
            .store(in: &cancellables)
    }
    
    /*
     ⭐️정리
     Publisher 프로토콜을 채택한 타입들은 .sink 메서드를 사용할 수 있다. (extension으로 구현되어 있음)
     sink의 역할은 Subscriber를 등록하고, Publisher가 값을 방출할 때마다 클로저를 실행함.
     그리고 구독을 취소할 수 있는 AnyCancellable 객체를 반환함.
     */
    
    
    func addTextFieldSubscriber() {
        //@Published 프로퍼티인 textFieldText를 구독
        $textFieldText
        
        //사용자가 텍스트 입력을 계속 하기 때문에, 텍스트 입력이 변경될 때마다 textFieldText는 값을 방출함.
        //텍스트 입력이 바뀔 때마다 처리하는 것이 아니라, 일정 시간 동안 변경이 없을 때에만 값을 방출하도록
        //최소 0.5초 지연 후에 마지박 변경 값을 방출함.
            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
        
        //textFieldText의 값인 text를 받아와서 변환 작업 수행 -> 'Bool 타입이 필요함'
        //텍스트의 길이를 확인하고 조건에 따라 true/false를 반환한다.
            .map { (text) -> Bool in
                if text.count > 3 {
                    return true
                }
                return false
            }
        /*
        만약 assign을 사용한다면?
        매핑을 통한 bool값을 textIsValid에 직접 할당하는 방식이 될 것.
         \.textIsValid는 프로퍼티 접근을 위한 KeyPath, self는 KeyPath 적용 대상 객체
         
         assign은 간결하고 직관적이지만, sink와 달리 클로저를 활용하지 못하는 특징이 있음.
         클로저를 활용하면 내부에서 값을 직접 처리하거나 다른 메서드를 호출할 수도 있고 메모리 관리 부분에서도
         개발자의 재량을 발휘할 수 있는 부분이 상대적으로 많음.
         */
        
//            .assign(to: \.textIsValid, on: self)
        
            .sink(receiveValue: { [weak self] isValid in
                self?.textIsValid = isValid
            })
            .store(in: &cancellables)
    }
    
    
    func addButtonSubscriber() {
        /*
         textIsValid와 count 두 개의 Publisher를 결합하고, 해당 값들의 조합에 따라
         showButton에 값을 할당하는 Subscriber를 추가하는 메서드.
         */
        $textIsValid
        /*
         ⭐️combineLatest / Combine Operator
         두 개 이상의 Publisher를 결합해서 각 Publisher가 값을 방출할 때마다
         새로운 값을 방출하는 새로운 Publisher를 생성함.
         (모든 Publisher의 가장 최신 값을 결합해서 튜플 형태로 방출함)
         ex. 여기에서 반환 형태는 (true,10) (false, 5) 이런 식일 것
         */
            .combineLatest($count)
            .sink { [weak self] (isValid, count) in
                guard let self = self else { return }
                //isValid가 true이고, count가 10 이상일 경우 showButton에 true 할당
                if isValid && count >= 10 {
                    self.showButton = true
                } else {
                    self.showButton = false
                }
            }
            .store(in: &cancellables)
    }
}

struct SubscriberPrac: View {
    @StateObject var vm = SubscriberViewModel()
    
    var body: some View {
        VStack {
            Text("\(vm.count)")
                .font(.largeTitle)
            
            Text(vm.textIsValid.description)
            
            TextField("Type Here", text: $vm.textFieldText)
                .padding(.leading)
                .frame(height: 55)
                .font(.headline)
                .background(vm.textIsValid ? .green : .gray)
                .cornerRadius(10)
            
                .overlay (
                    ZStack {
                        Image(systemName: "xmark")
                            .foregroundColor(.red)
                        //입력 전에는 안보이다가, 조건에 따라 투명도 조절
                            .opacity(
                                vm.textFieldText.count < 1 ? 0 :
                                vm.textIsValid ? 0 : 1)
                        
                        Image(systemName: "checkmark")
                            .foregroundColor(.blue)
                            .opacity(vm.textIsValid ? 1 : 0)
                    }
                    .font(.headline)
                    .padding(.trailing)
                    ,alignment: .trailing
                )
            Button {
                
            } label: {
                Text("Login")
                    .font(.headline)
                    .foregroundColor(.white)
                    .frame(height: 55)
                    .frame(maxWidth: .infinity)
                    .background(Color.blue)
                    .cornerRadius(10)
                    .opacity(vm.showButton ? 1 : 0.5)
            }
            //showButton이 false일 때 (즉 아직 조건들이 만족되지 않았을 때) 버튼 비활성화
            .disabled(!vm.showButton)

        }
        .padding()
    }
}

 

Publisher와 Subscriber,

assign과 sink, AnyCancellable, combineLatest, map, store, [weak self] 등의 키워드에 집중해서 보면 좋다 :)

'SwiftUI > SwiftUI(Intermediate)' 카테고리의 다른 글

22. NSCache 캐시/ 이미지 캐싱  (0) 2023.06.15
21. FileManager 파일매니저  (0) 2023.06.15
19. Timer 타이머  (0) 2023.06.13
18. Combine 컴바인 .feat JSON  (0) 2023.06.13
17. JSON Download with @escaping  (0) 2023.06.12