apple/iOS, UIKit, Documentation

[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가 대세인 것 같아서 이것도 병행해서 학습

 

SwiftUI + Combine만 사용하다가 RxSwift 오랜만에 사용하니까 확실히 뭐가 많아서 좋다

 

pin + flex사용할 때 viewDidLayoutSubviews() 호출해야 하는데, 이것만 반드시 주의

 

내가 보려고 남기는 샘플코드

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

 

 

결과물

이미지로 손으로 그려가면서 분석.

웹 HTML 태그 공부할 때랑 비슷한 느낌이 들어서 신기했다.

 

FlexLayout + PinLayout의 결과물

 

 

예제 샘플 코드

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

"https://my.api.mockaroo.com/members_with_avatar.json?key=44ce18f0"

 

API 코드

//
//  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

//
//  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
        )
    }
}

 

 

셀 그리고 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)
        }
    }
}

 

전체 콛,

 

여기서 주의할 점은 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