iOS/Toy project

[iOS : Toy Project] Apple Framework List (4) : Combine

yevdev 2022. 7. 8. 20:20

➰ 이전 코드 내용

Apple Framework List(1)

Apple Framework List(2)

Apple Framework List(3) : Modal

 

➰ Combine 개념 정리

Combine(1) : Overview
Combine(2) : Publisher/Subscriber/Operator

 

 

 

💡 Apple Framework List(4)에서 할 것은 Combine 적용!

- Combine을 적용할 View Controller에 Combine을 Import 해야함!

 

 

 

 

 

일단, 이전 코드를 정리해볼까?

 

이전코드

//  FrameworkListViewController.swift

import UIKit

class FrameworkListViewController: UIViewController {
    
    // CollectionView 자체를 연결
    @IBOutlet weak var collectionView: UICollectionView!
    
    // 데이터를 일단 가져오기
    let list: [AppleFramework] = AppleFramework.list
    
    var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    
    typealias Item = AppleFramework
    
    enum Section {
        case main
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        collectionView.delegate = self  // collectionView의 위임을 나에게 하겠다. 내가 담당하겠다!
        navigationController?.navigationBar.topItem?.title = "🎀 Apple Frameworks"
        
    // Data, Presentation, Layout
//      (1) presentation -> diffable datasource
        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { collectionView, indexPath, item in
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FrameworkCell", for: indexPath) as? FrameworkCell else {
                 return nil
            }
            cell.configure(item)    // item이 AppleFramework와 같음
            return cell
        })
        
//      (2) data -> snapshot
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(list, toSection: .main)
        // dataSource에 Snapshot을 적용시키기
        dataSource.apply(snapshot)
        
//      (3) layout -> compositional layout
        collectionView.collectionViewLayout = layout()
    }
    
    private func layout() -> UICollectionViewCompositionalLayout {
        // item, group, section, layout 만들기
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1))    // item의 width는 group너비의 1/3, height는 group높이과 같음
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.33))    // group의 width는 section너비, height는 section너비의 1/3
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)  // item을 3등분으로 균일하게 쓰겠다!
        
        let section = NSCollectionLayoutSection(group: group)
        // section 좌우에 안쪽패딩주기
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
        
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
    
    
}

// item이 선택되었을 때 효과 넣기
extension FrameworkListViewController: UICollectionViewDelegate{
    
    // item이 선택되었을 때 호출되는 method
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let framework = list[indexPath.item] // 몇번째 item인지?
        
        // FrameworkDetailViewController 띄우기
        let storyboard = UIStoryboard(name: "Detail", bundle: nil)
        let vc = storyboard.instantiateViewController(withIdentifier: "FrameworkDetailViewController") as! FrameworkDetailViewController  // FrameworkDetailViewController로 강제 캐스팅
        vc.framework = framework    // FrameworkDetailView가 떴을 때, 이미 업데이트가 완료된 상태로 뜨게 됨.
//        vc.modalPresentationStyle = .fullScreen // fullScreen으로 모달이 뜨게 -> 제스쳐로 모달을 닫을 수 없음
        present(vc, animated: true) // present 메소드로 띄워주기
    }
}

 

 

 

1️⃣ CollectionView 설정을 위한 Presentation, Layout, Data 따로 빼두기

- Presentation, Layout → configureCollectionView()

- Data applySectionItems()

//  FrameworkListViewController.swift

import UIKit
import Combine

class FrameworkListViewController: UIViewController {
    
    // CollectionView 자체를 연결
    @IBOutlet weak var collectionView: UICollectionView!
    
    // 데이터를 일단 가져오기
    let list: [AppleFramework] = AppleFramework.list
    
    var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    
    typealias Item = AppleFramework
    
    enum Section {
        case main
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        collectionView.delegate = self  // collectionView의 위임을 나에게 하겠다. 내가 담당하겠다!
        navigationController?.navigationBar.topItem?.title = "🎀 Apple Frameworks"
        
        
        // Collection View Presentation, Layout 설정
        configureCollectionView()
        
        // Collection View Data 설정
        
        
    }
    
    private func configureCollectionView() {
        
        // presentation -> diffable datasource
        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { collectionView, indexPath, item in
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FrameworkCell", for: indexPath) as? FrameworkCell else {
                 return nil
            }
            cell.configure(item)    // item이 AppleFramework와 같음
            return cell
        })
        
        // layout -> compositional layout
        collectionView.collectionViewLayout = layout()
        
    }
    
    private func applySectionItems(_ items: [Item], to section: Section = .main) {
        
        // data -> snapshot
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([section])
        snapshot.appendItems(items, toSection: section)
        // dataSource에 Snapshot을 적용시키기
        dataSource.apply(snapshot)
        
    }
    
    private func layout() -> UICollectionViewCompositionalLayout {
        // item, group, section, layout 만들기
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1))    // item의 width는 group너비의 1/3, height는 group높이과 같음
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.33))    // group의 width는 section너비, height는 section너비의 1/3
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)  // item을 3등분으로 균일하게 쓰겠다!
        
        let section = NSCollectionLayoutSection(group: group)
        // section 좌우에 안쪽패딩주기
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
        
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
    
    
}

// item이 선택되었을 때 효과 넣기
extension FrameworkListViewController: UICollectionViewDelegate{
    
    // item이 선택되었을 때 호출되는 method
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let framework = list[indexPath.item] // 몇번째 item인지?
        
        // FrameworkDetailViewController 띄우기
        let storyboard = UIStoryboard(name: "Detail", bundle: nil)
        let vc = storyboard.instantiateViewController(withIdentifier: "FrameworkDetailViewController") as! FrameworkDetailViewController  // FrameworkDetailViewController로 강제 캐스팅
        vc.framework = framework    // FrameworkDetailView가 떴을 때, 이미 업데이트가 완료된 상태로 뜨게 됨.
//        vc.modalPresentationStyle = .fullScreen // fullScreen으로 모달이 뜨게 -> 제스쳐로 모달을 닫을 수 없음
        present(vc, animated: true) // present 메소드로 띄워주기
    }
}

 

 

 

2️⃣ For Combine!

(1) Publisher 만들기 : PassthroughSubject 이용

let didSelect = PassthroughSubject<AppleFramework, Never>()

💡PassthroughSubject?
- Publisher의 일종인 Subject의 Built-in Type
- Subscriber가 달라고 요청하면, 그때부터 받은 값을 전달해주기만 함 → 가장 최근에 전달한 값을 들고 있지 않음

(2) Subscription set 만들기

var subscriptions = Set<AnyCancellable>()

💡Subscription?
- Subscriber가 Publisher와 연결됨을 나타냄
- Publisher가 발행한 구독 티켓
- Cancellable Protocol을 따름

 

 

 

3️⃣ View(해당 Page) 에서의 핵심로직을 담당할 부분을 Bind 함수에 집어넣기!

// item이 선택되었을 때 효과 넣기
extension FrameworkListViewController: UICollectionViewDelegate{
    
    // item이 선택되었을 때 호출되는 method
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let framework = list[indexPath.item] // 몇번째 item인지?
        
        // FrameworkDetailViewController 띄우기
        let storyboard = UIStoryboard(name: "Detail", bundle: nil)
        let vc = storyboard.instantiateViewController(withIdentifier: "FrameworkDetailViewController") as! FrameworkDetailViewController  // FrameworkDetailViewController로 강제 캐스팅
        vc.framework = framework    // FrameworkDetailView가 떴을 때, 이미 업데이트가 완료된 상태로 뜨게 됨.
//        vc.modalPresentationStyle = .fullScreen // fullScreen으로 모달이 뜨게 -> 제스쳐로 모달을 닫을 수 없음
        present(vc, animated: true) // present 메소드로 띄워주기
    }
}

↑ 바로 이 코드를 Bind 함수에 집어 넣는 것!

 

 

private func bind() {
    // input: 사용자 인풋을 받아서 처리해야할 것
    // - item 선택되었을 때 처리
    didSelect
        .receive(on: RunLoop.main)  // UI 변경이니, main thread에서 일어날 수 있게 하기
        .sink { [unowned self] framework in
        let storyboard = UIStoryboard(name: "Detail", bundle: nil)
        let vc = storyboard.instantiateViewController(withIdentifier: "FrameworkDetailViewController") as! FrameworkDetailViewController
        vc.framework = framework
        self.present(vc, animated: true)
    }.store(in: &subscriptions)
}

→ Subscriber 

 

 

데이터를 보내는 부분은?

// item이 선택되었을 때 효과 넣기
extension FrameworkListViewController: UICollectionViewDelegate{
    
    // item이 선택되었을 때 호출되는 method
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let framework = list[indexPath.item] // 몇번째 item인지?
        
        // 데이터 보내기!
        didSelect.send(framework)
    }
}

 

 

이제 Output 을 세팅해보자

- @Published 로 property 선언

@Published var list: [AppleFramework] = AppleFramework.list

@Published?
- @Published로 선언된 Property를 Publisher로 만들어줌
- 클래스에 한해서 사용됨 (구조체에서 사용 X)
- type은 let이 아닌, var!
- $을 이용해서 Publisher에 접근 가능
private func bind() {
        // input: 사용자 인풋을 받아서 처리해야할 것
        // - item 선택되었을 때 처리
        didSelect
            .receive(on: RunLoop.main)  // UI 변경이니, main thread에서 일어날 수 있게 하기
            .sink { [unowned self] framework in
            let storyboard = UIStoryboard(name: "Detail", bundle: nil)
            let vc = storyboard.instantiateViewController(withIdentifier: "FrameworkDetailViewController") as! FrameworkDetailViewController
            vc.framework = framework
            self.present(vc, animated: true)
        }.store(in: &subscriptions)
        
        // output: data, state 변경에 따라서, UI 업데이트 할 것
        // - items 세팅이 되었을 때, 컬랙션뷰를 업데이트
        $list
            .receive(on: RunLoop.main )
            .sink { [unowned self] list in
                self.applySectionItems(list)
            }.store(in: &subscriptions)
}

 

 

 

 

🚀 전체 코드

//  FrameworkListViewController.swift

import UIKit
import Combine

class FrameworkListViewController: UIViewController {
    
    // CollectionView 자체를 연결
    @IBOutlet weak var collectionView: UICollectionView!
    
    var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    
    typealias Item = AppleFramework
    
    enum Section {
        case main
    }
    
    
    // Combine
    var subscriptions = Set<AnyCancellable>()
    let didSelect = PassthroughSubject<AppleFramework, Never>()
    @Published var list: [AppleFramework] = AppleFramework.list
   
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        collectionView.delegate = self  // collectionView의 위임을 나에게 하겠다. 내가 담당하겠다!
        navigationController?.navigationBar.topItem?.title = "🎀 Apple Frameworks"
        
        configureCollectionView()
        
        bind()
        
    }
    
    private func bind() {
        // input: 사용자 인풋을 받아서 처리해야할 것
        // - item 선택되었을 때 처리
        didSelect
            .receive(on: RunLoop.main)  // UI 변경이니, main thread에서 일어날 수 있게 하기
            .sink { [unowned self] framework in
            let storyboard = UIStoryboard(name: "Detail", bundle: nil)
            let vc = storyboard.instantiateViewController(withIdentifier: "FrameworkDetailViewController") as! FrameworkDetailViewController
            vc.framework = framework
            self.present(vc, animated: true)
        }.store(in: &subscriptions)
        
        // output: data, state 변경에 따라서, UI 업데이트 할 것
        // - items 세팅이 되었을 때, 컬랙션뷰를 업데이트
        $list
            .receive(on: RunLoop.main )
            .sink { [unowned self] list in
                self.applySectionItems(list)
            }.store(in: &subscriptions)
    }
    
    // Collection View Presentation, Layout 설정
    private func configureCollectionView() {
        
        // presentation -> diffable datasource
        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { collectionView, indexPath, item in
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FrameworkCell", for: indexPath) as? FrameworkCell else {
                 return nil
            }
            cell.configure(item)    // item이 AppleFramework와 같음
            return cell
        })
        
        // layout -> compositional layout
        collectionView.collectionViewLayout = layout()
        
    }
    
    // Collection View Data 설정
    private func applySectionItems(_ items: [Item], to section: Section = .main) {
        
        // data -> snapshot
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([section])
        snapshot.appendItems(items, toSection: section)
        // dataSource에 Snapshot을 적용시키기
        dataSource.apply(snapshot)
        
    }
    
    private func layout() -> UICollectionViewCompositionalLayout {
        // item, group, section, layout 만들기
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1))    // item의 width는 group너비의 1/3, height는 group높이과 같음
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.33))    // group의 width는 section너비, height는 section너비의 1/3
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)  // item을 3등분으로 균일하게 쓰겠다!
        
        let section = NSCollectionLayoutSection(group: group)
        // section 좌우에 안쪽패딩주기
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
        
        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    }
    
    
}

// item이 선택되었을 때 효과 넣기
extension FrameworkListViewController: UICollectionViewDelegate{
    
    // item이 선택되었을 때 호출되는 method
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let framework = list[indexPath.item] // 몇번째 item인지?
        
        // 데이터 보내기!
        didSelect.send(framework)
    }
}

 

 

 


 

👀 PassthroughSubject 말고, CurrentValueSubject로 해보자

CurrentValueSubject?
- PassthroughSubject와 같이, Publisher의 일종인 Subject의 Built-in Type
- Subscriber가 달라고 요청하면, 최근에 가지고 있던 값을 전달하고, 그때부터 받은 값을 전달 → 가장 최근에 전달한 값을 들고 있음

let items = CurrentValueSubject<[AppleFramework], Never>(AppleFramework.list) 

→ 초깃값을 넣어줘야 함! AppleFramework.list

 

bind()에서,,

items
    .receive(on: RunLoop.main )
    .sink { [unowned self] list in
        self.applySectionItems(list)
    }.store(in: &subscriptions)

 

// item이 선택되었을 때 효과 넣기
extension FrameworkListViewController: UICollectionViewDelegate{
    
    // item이 선택되었을 때 호출되는 method
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
//        let framework = list[indexPath.item] // 몇번째 item인지?
        let framework = items.value[indexPath.item]
        
        // 데이터 보내기!
        didSelect.send(framework)
    }
}

→ list를 사용못하니, framework도 다음과 같이 수정

 

 

 

 

 


 

➰ FrameworkDetailViewController도 Combine으로 개선해보자

FrameworkDetailViewController 이전코드

//  FrameworkDetailViewController.swift

import UIKit
import SafariServices   // 사파리를 띄우기 위한 Framework.

class FrameworkDetailViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var descriptionLabel: UILabel!
    
    var framework: AppleFramework = AppleFramework(name: "Unknown", imageName: "", urlString: "", description: "")

    override func viewDidLoad() {
        super.viewDidLoad()
        updateUI()
        
    }
    
    func updateUI() {
        imageView.image = UIImage(named: framework.imageName)
        titleLabel.text = framework.name
        descriptionLabel.text = framework.description
    }
    
    // Learn More 버튼을 클릭했을 때, Action target
    @IBAction func learnMoreTapped(_ sender: Any) {
    
    // url 객체 만들기
        guard let url = URL(string: framework.urlString) else {
            return
        }
    
    // Safari View Controller
    let safari = SFSafariViewController(url: url)
    present(safari, animated: true)
        
    }

}

 

Combine 으로 개선시킨 코드

//  FrameworkDetailViewController.swift

import UIKit
import SafariServices   // 사파리를 띄우기 위한 Framework.
import Combine

class FrameworkDetailViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var descriptionLabel: UILabel!
    
    @Published var framework: AppleFramework = AppleFramework(name: "Unknown", imageName: "", urlString: "", description: "")
    
    var subscriptions = Set<AnyCancellable>()
    let buttonTapped = PassthroughSubject<AppleFramework, Never>()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        bind()
    }
    
    private func bind() {
        // input : Button 클릭
        // 구현할 logic :  framework -> url -> safari -> present
        buttonTapped
            .receive(on: RunLoop.main)
            .compactMap { URL(string: $0.urlString) }
            .sink { [unowned self] url in
                let safari = SFSafariViewController(url: url)
                self.present(safari, animated: true)
            }.store(in: &subscriptions)
         
        // output : Data setting될 떄, UI 업데이트
        $framework
            .receive(on: RunLoop.main)
            .sink { [unowned self] framework in
                self.imageView.image = UIImage(named: framework.imageName)
                self.titleLabel.text = framework.name
                self.descriptionLabel.text = framework.description
            }.store(in: &subscriptions)
    }
    
    // Learn More 버튼을 클릭했을 때, Action target
    @IBAction func learnMoreTapped(_ sender: Any) {
        buttonTapped.send(framework)
    }

}

 

 

 

 

 

 


📌 정리,

  • Combine을 적용하여 코드 개선
    - Publisher, Subscriber
  • Presentation, Layout, Data 설정은 따로 함수로 빼둠
    - Presentation, Layout : configureCollectionView()
    - Data  applySectionItems()
  • 해당 Page의 핵심이 되는 Logic은 bind() 함수에 집어 넣기
    (1) input 설정 : 사용자 인풋을 받아서, 처리해야할 것
    (2) output 설정 : data, state 변경에 따라서, UI 업데이트 해야할 것

 

 


Reference

패스트캠퍼스 온라인 강의