apple/iOS

[iOS] UICollectionView에 대해서 알아보기 4편 (Rx + FlexLayout + PinLayout)

lgvv 2022. 8. 31. 20:31

UICollectionView에 대해서 알아보기 4편 (Rx + FlexLayout + PinLayout)

 

 

이번에는 RxSwfit + MVVM + FlexLayout + PinLayout을 사용해서 구성해보자.

 

FlexLayout 및 PinLayout을 학습하기 위해서 구글링으로 프리뷰를 하고, 공식문서의 예제를 참고해서 어떻게 사용하는지 알아본 후 공부해 보았다.

이미지 처리는 Kingfisher와 SDWebImage는 사용해 본 경험이 있어서 Nuke를 학습하면서 적용해 보았다.

근데 Nuke 뭔가 좋다,,

 

무엇보다도 오랜만에 MVVM + Rx를 사용하고 있어서 차근차근 되짚어보자.

 

<학습코드>

AppleCollectionView.zip
2.66MB

(공식문서 예제)

https://github.com/layoutBox/FlexLayout/blob/master/Example/FlexLayoutSample/UI/Examples/Intro/IntroView.swift

 

GitHub - layoutBox/FlexLayout: FlexLayout adds a nice Swift interface to the highly optimized facebook/yoga flexbox implementati

FlexLayout adds a nice Swift interface to the highly optimized facebook/yoga flexbox implementation. Concise, intuitive & chainable syntax. - GitHub - layoutBox/FlexLayout: FlexLayout adds a ni...

github.com

 

 

🌿 결과물 🌿 

FlexLayout + PinLayout의 결과물

 

 

 

우선 MVVM + Rx를 학습하기 위해서 위와같은 구조로 작성했다.

폴더구조

 

APIService의 코드는 다음과 같이 작성했다.

 

아래는 APIService의 StringSet.URLSet.member의 urlString

"https://my.api.mockaroo.com/members_with_avatar.json?key=44ce18f0"
//
//  APIService.swift
//  AppleCollectionView
//
//  Created by Hamlit Jason on 2022/08/23.
//

import UIKit
import RxSwift

class APIService {
    static let urlString = StringSet.URLSet.member
    
    /// APIService Load
    static func load<T: Codable>(_ t: T.Type, urlString: String = urlString) -> Observable<[T]> {
        return Observable.create { emitter in
            let urlRequest = URL(string: urlString)!
            
            let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
                if let error = error { // 에러가 존재한다면
                    emitter.onError(error)
                    return
                }
                
                guard let data = data else {
                    let httpResponse = response as! HTTPURLResponse
                    
                    emitter.onError(
                        NSError(domain: "🚨 데이터가 존재하지 않습니다",
                                code: httpResponse.statusCode,
                                userInfo: nil)
                    )
                    return
                }
                
                // 올바르게 성공했다면!
                let result = try! JSONDecoder().decode([T].self, from: data)
                emitter.onNext(result)
                emitter.onCompleted()
            }
            task.resume()
            
            return Disposables.create {
                task.cancel() // dispose되는 경우
            }
        }
    }
    
    
}

 

 

viewModel에서는 이렇게 처리하고 있다. 

//
//  MembeViewModel.swift
//  AppleCollectionView
//
//  Created by Hamlit Jason on 2022/08/23.
//

import UIKit
import RxSwift
import RxCocoa

final class MembeViewModel {
    let disposeBag = DisposeBag()
    
    /// 서버로부터 받아온 멤버의 정보들
    var membersSubject = BehaviorSubject<[Member]>(value: [])
    
    // MARK: - OutPut
    var membersDriver: Driver<[Member]>
    
    init() {
        membersDriver = membersSubject
            .asDriver(onErrorJustReturn: [])
           
        loadMembers()
    }
    
    /// 멤버를 로드합니다.
    func loadMembers() {
        APIService.load(Member.self, urlString: StringSet.URLSet.member)
            .take(1)
            .subscribe(onNext: { self.membersSubject.onNext($0) })
            .disposed(by: disposeBag)
    }
}

 

viewController에서는 ReactorKit을 적용하기 위한 사전단계로 아래처럼 작성했다.

//
//  MemberViewController.swift
//  AppleCollectionView
//
//  Created by Hamlit Jason on 2022/08/23.
//

import UIKit
import RxSwift
import RxCocoa
import SnapKit

class MemberViewController: UIViewController {
    let disposeBag = DisposeBag()
    
    let viewModel = MembeViewModel()
    
    private let loadButton = UIButton()
    lazy var collectionView: UICollectionView = {
        var layout = UICollectionViewFlowLayout()
        layout.minimumLineSpacing = 0
        layout.scrollDirection = .vertical
        layout.sectionInset = .zero
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupLoadButton()
        setupCollectionView()
        
        bindAction(with: viewModel)
        bindState(with: viewModel)
    }
    
    private func bindAction(with viewModel: MembeViewModel) {
        loadButton.rx.tap
            .debug("로드버튼 클릭")
            .bind { viewModel.loadMembers() }
            .disposed(by: disposeBag)
        
        collectionView.rx.modelSelected(Member.self)
            .bind {
                let viewController = MemberDetailViewController(member: $0)
                self.navigationController?.pushViewController(viewController, animated: true)
            }
            .disposed(by: disposeBag)
    }
    
    private func bindState(with viewModel: MembeViewModel) {
        viewModel.membersDriver
            .asObservable()
            .bind(to: collectionView.rx.items(
                cellIdentifier: MemberCell.identifier,
                cellType: MemberCell.self)
            ) { index, item, cell in
                cell.configureCell(with: item)
            }
            .disposed(by: disposeBag)
    }
}

extension MemberViewController {
    private func setupLoadButton() {
        view.addSubview(loadButton)
        
        loadButton.setTitle("Load API", for: .normal)
        loadButton.backgroundColor = .black
        loadButton.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide)
            $0.leading.trailing.equalToSuperview()
        }
    }
    
    private func setupCollectionView() {
        view.addSubview(collectionView)
        
        collectionView.delegate = self
        collectionView.register(
            MemberCell.self,
            forCellWithReuseIdentifier: MemberCell.identifier
        )
        
        collectionView.snp.makeConstraints {
            $0.top.equalTo(loadButton.snp.bottom)
            $0.leading.trailing.bottom.equalToSuperview()
        }
    }
}

extension MemberViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
        return CGSize(
            width: collectionView.bounds.width,
            height: 120
        )
    }
}

 

다음은 Cell 부분이다.

이번에 Nuke을 학습하고 적용해 보았다. 

//
//  MemberCell.swift
//  AppleCollectionView
//
//  Created by Hamlit Jason on 2022/08/23.
//

import UIKit
import SnapKit
import Nuke

class MemberCell: UICollectionViewCell {
    
    var avatarImage = UIImageView()
    var nameLabel = UILabel()
    var jobLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: .zero)
        
        setupAvatarImage()
        setupNameLabel()
        setupJobLabel()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    /// 셀 세팅
    func configureCell(with item: Member) {
        if let url = URL(string: item.avatar) {
            let options = ImageLoadingOptions(failureImageTransition: .fadeIn(duration: 0.5))
            Nuke.loadImage(
                with: url,
                options: options,
                into: avatarImage
            )
        }
        nameLabel.text = item.name
        jobLabel.text = item.job
    }
}

extension MemberCell {
    private func setupAvatarImage() {
        addSubview(avatarImage)
        avatarImage.layer.cornerRadius = 12
        avatarImage.backgroundColor = .gray
        
        avatarImage.snp.makeConstraints {
            $0.top.leading.equalToSuperview().inset(12)
            $0.width.height.equalTo(80)
        }
    }
    
    private func setupNameLabel() {
        addSubview(nameLabel)
        
        nameLabel.adjustsFontSizeToFitWidth = true
        nameLabel.snp.makeConstraints {
            $0.top.equalTo(avatarImage)
            $0.leading.equalTo(avatarImage.snp.trailing).offset(10)
            $0.trailing.equalToSuperview().inset(10)
        }
    }
    
    private func setupJobLabel() {
        addSubview(jobLabel)
        jobLabel.font = .systemFont(ofSize: 16, weight: .medium)
        jobLabel.textColor = .purple
        jobLabel.numberOfLines = 0
        jobLabel.textAlignment = .left
        
        jobLabel.snp.makeConstraints {
            $0.top.equalTo(nameLabel.snp.bottom)
            $0.leading.equalTo(nameLabel)
            $0.trailing.equalToSuperview().inset(10)
        }
    }
}

 

마지막으로 MemberDetailViewController의 전체코드이다.

여기서 주의할 점은 init()에

viewDidLayoutSubviews()에 들어가는 값들 작성하면 레이아웃 안맞음

//
//  MemberDetailViewController.swift
//  AppleCollectionView
//
//  Created by Hamlit Jason on 2022/08/30.
//

import UIKit
import FlexLayout
import PinLayout
import Nuke

class MemberDetailViewController: UIViewController {
    
    // MARK: - Properties
    var member: Member // 외부에서 전달되는 값
    
    // MARK: - View
    let avatarImage = UIImageView()
    let nameLabel = UILabel()
    let idLabel = UILabel()
    let jobLabel = UILabel()
    let ageLabel = UILabel()
    
    // MARK: - FlexLayout
    let rootFlexContainer: UIView = UIView()
    
    // MARK: - Life Cycle
    init(member: Member) {
        self.member = member
        
        super.init(nibName: nil, bundle: nil)
        
        view.addSubview(rootFlexContainer)
        rootFlexContainer.flex.define { flex in
            
            flex.addItem()
                .alignItems(.center)
                .define { flex in
                    flex.addItem(avatarImage)
                        .width(300)
                        .aspectRatio(1)
                }
                .marginBottom(10)

            flex.addItem(idLabel)
                .marginLeft(10%)
            
            flex.addItem()
                .alignItems(.center)
                .define { flex in
                    flex.addItem(nameLabel).grow(2)
                    flex.addItem(jobLabel).grow(1)
                    flex.addItem(ageLabel).grow(1)
                }
                .grow(1)
            
            flex.addItem().grow(3)
        }
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        self.rootFlexContainer.pin.all(view.safeAreaInsets)
        self.rootFlexContainer.flex.layout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupAvatarImage()
        setupNameLabel()
        setupIdLabel()
        setupJobLabel()
        setupAgeLabel()
    }
}

extension MemberDetailViewController {
    
    func setupAvatarImage() {
        avatarImage.backgroundColor = .gray
        //        let urlString = member.avatar.replacingOccurrences(of: "size=50x50&", with: "")
        Nuke.loadImage(
            with: URL(string: member.avatar)!,
            into: avatarImage
        )
    }
    
    func setupNameLabel() {
        nameLabel.font = .systemFont(ofSize: 22, weight: .bold)
        nameLabel.text = member.name
        nameLabel.textAlignment = .center
    }
    
    func setupIdLabel() {
        idLabel.font = .systemFont(ofSize: 16, weight: .medium)
        idLabel.textColor = .gray
        idLabel.text = "# \(member.id)"
        
    }
    
    func setupJobLabel() {
        jobLabel.font = .systemFont(ofSize: 16, weight: .medium)
        jobLabel.textColor = .black
        jobLabel.textAlignment = .center
        jobLabel.text = member.job
    }
    
    func setupAgeLabel() {
        ageLabel.font = .systemFont(ofSize: 16, weight: .regular)
        ageLabel.textColor = .gray
        ageLabel.textAlignment = .center
        ageLabel.text = "\(member.age)"
        
    }
}

 

 

(참고)

https://colinch4.github.io/2021-05-14/FlexLayout/

 

colin's 블로그

개발 강좌 블로그

colinch4.github.io