ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • rxSwift + mvvm 구조로 콜렉션뷰 설계하기
    iOS/mvvm + RxSwift 스터디 2018. 12. 4. 18:58

    요즘 새로 rxSwift와 mvvm을 학습하고 있습니다.

    개인적으로 요즘 공부하고 있는 유아이가 '듀오링고' 앱의 문장 완성 기능입니다.




    후보군을 누르면 밑 단어 목록에서 애니메이션이 쇽쇽 나와서 문장을 완성하는 UX입니다.


    해당 모듈을 콜렉션뷰로 만들어보자 해서 한번 짜 보았습니다.



    해당 앱에서 여러 영단어가 모여서 영문장이 됩니다.

    영단어와 영문장에 해당하는 모델이 필요하므로,

     다음과 같이 구현했습니다.


    protocol CodeBlock : Equatable {

        var codeBlockName : String { get }

        var codeBlockId : Int { get }

    }

    extension CodeBlock {

        static func == (lhs: Self, rhs: Self) -> Bool {

            return lhs.codeBlockName == rhs.codeBlockName

        }

    }


    protocol CodeSentence {

        associatedtype T : CodeBlock

        var selectedCodeBlocks : Variable<[T]> { get set }

        var candidateCodeBlocks : [T] { get }

        var answerCodeBlocks : [T] { get }

        

    }


    extension CodeSentence {

        func isRightSentence() -> Bool {

            return selectedCodeBlocks.value == answerCodeBlocks

        }

        

    }


    코드블록 프로토콜을 간단히 구현하고, 코드문장 모델에서는 변수로 셀렉티드 코드 블록 (현재 선택된 코드 블록)

    캔디디트 코드 블록 (문장을 만들 수 있도록 주어진 코드 블록), 앤서 코드 블록 (정확한 영문장을 이루는 코드 블록)


    을 선언했습니다.


    이 중, 셀렉티드 코드 블록은 계속 변하면서 관찰되므로 베리어블로 선언했습니다. 


    class EnglishCodeBlock : CodeBlock {

        var codeBlockName: String

        

        var codeBlockId: Int

        init(name: String){

            self.codeBlockName = name

            self.codeBlockId = 0

        }

    }


    class EnglishCodeSentenceViewModel : CodeSentence {

        var selectedCodeBlocks: Variable<[EnglishCodeBlock]>

        var candidateCodeBlocks: [EnglishCodeBlock]

        var answerCodeBlocks: [EnglishCodeBlock]

        

        private let disposeBag = DisposeBag()

        init(answerCodeBlocks : [EnglishCodeBlock], candidateCodeBlocks : [EnglishCodeBlock]){

            self.answerCodeBlocks = answerCodeBlocks

            self.candidateCodeBlocks = candidateCodeBlocks

            self.selectedCodeBlocks = Variable(EnglishCodeSentenceViewModel.setUpExampleEnglishCodeBlocks())

        }

        convenience init(answerCodeBlocks : [EnglishCodeBlock], candidateCodeBlocks : [EnglishCodeBlock], itemSelected : Driver<IndexPath>){

            self.init(answerCodeBlocks: answerCodeBlocks, candidateCodeBlocks: candidateCodeBlocks)

            itemSelected.drive(onNext: { [unowned self ] indexPath  in

                self.selectedCodeBlocks.value.remove(at: indexPath.item)

            }).disposed(by: disposeBag)

        }

        

        static func setUpExampleEnglishCodeBlocks() -> [EnglishCodeBlock]{

            return [EnglishCodeBlock.init(name: "I"), EnglishCodeBlock.init(name: "am") ,

                    EnglishCodeBlock.init(name: "a"), EnglishCodeBlock.init(name: "boy")]

        }

    }


    코드블록 프로토콜을 잉글리시 코드 블록으로, 코드센텐스 프로토콜을 잉글리시 코드 센텐트 뷰모델로 선언했습니다.



    mvvm 구조에서 뷰모델의 경우, 뷰콘트롤러에서 액션이 들어오면 적절한 드라이브를 트리거해야 합니다.


    일단 선언한 드라이버는 아이템 셀렉티드 드라이버.


    후보군에 해당하는 코드블록을 선택하면 해당 코드블록은 지워져야 합니다.

    해당 부분을 

    itemSelected.drive(onNext: { [unowned self ] indexPath  in

                self.selectedCodeBlocks.value.remove(at: indexPath.item)

            }).disposed(by: disposeBag)


    으로 구현했습니다.


    class EnglishCodeCollectionViewCell : UICollectionViewCell {

        let codeLabel : UILabel = {

            let label = UILabel()

            label.textAlignment = NSTextAlignment.center

            return label

        }()

        override init(frame: CGRect) {

            super.init(frame: frame)

            setUpViews()

        }

        

        required init?(coder aDecoder: NSCoder) {

            fatalError("init(coder:) has not been implemented")

        }

        func setUpViews(){

            self.addSubview(codeLabel)

            codeLabel.snp.makeConstraints { (make) in

                make.top.bottom.leading.trailing.equalTo(self)

            }

        }

    }

    class EnglishCodeViewController : UIViewController, UICollectionViewDelegateFlowLayout {

        lazy var collectionView : UICollectionView = {

            let layout = UICollectionViewFlowLayout()

            let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)

            cv.backgroundColor = .white

            return cv

        }()

        

        

        

        

        private let cellIdentifier = "Cell"

        private let disposeBag = DisposeBag()

        

        var viewModel : EnglishCodeSentenceViewModel!

        

        override func viewDidLoad() {

            setUpViews()

            setUpViewModels()

            setUpCollectionViewBinding()

        }

        func setUpViewModels(){

            viewModel = EnglishCodeSentenceViewModel.init(answerCodeBlocks: [], candidateCodeBlocks: [], itemSelected: collectionView.rx.itemSelected.asDriver())

        }

        func setUpViews(){

            self.view.backgroundColor = UIColor.white

            self.view.addSubview(collectionView)

            collectionView.snp.makeConstraints { (make) in

                make.bottom.top.leading.trailing.equalTo(self.view)

            }

            collectionView.rx.setDelegate(self).disposed(by: disposeBag)

            collectionView.register(EnglishCodeCollectionViewCell.self, forCellWithReuseIdentifier: cellIdentifier)

            

        }

        func setUpCollectionViewBinding(){

            viewModel.selectedCodeBlocks.asObservable()

                .bind(to: collectionView.rx.items(cellIdentifier: cellIdentifier)) {  row, element, cell in

                    // = element.codeBlockName

                    guard let englishCollectionViewCell : EnglishCodeCollectionViewCell = cell as! EnglishCodeCollectionViewCell else{

                        return

                    }

                    englishCollectionViewCell.codeLabel.text = element.codeBlockName

                    

                }

            .disposed(by: disposeBag)

           

        }

    }



    뷰에 해당하는 부분입니다.

    뷰모델을 바인드하고 뷰모델을 초기화합니다.


    아직 익숙치 않아서 코드가 잘 이해가 안 가는 감이 있지만, 모듈화가 딱딱 되는 것은 장점인 것 같습니다.



    이 코드를 구현하면서 이것저것 찾아봤는데...


    1. 콜렉션뷰에 여러 가지 종류의 셀이 들어갈 때 ->

    코드블록에 해당하는 데이터 소스를 enum 타입으로 정의하고, enum 타입에 연관된 값들을 이용해 케이스 문으로 바인딩하는 패턴이 일반적으로 보입니다.


    enum FriendTableViewCellType {

        case normal(cellViewModel: FriendCellViewModel)

        case error(message: String)

        case empty

    }



    이런 식으로요.


    2. 콜렉션뷰에 여러 섹션이 들어갈 때. ->


    아무리 찾아봐도 rxDataSources 라이브러리를 임포트해서 바인딩하는 게 최선의 수인 것 같습니다.


    해당 라이브러리의 이그젬플에서 적당한 예가 나와 있습니다 (https://github.com/RxSwiftCommunity/RxDataSources/blob/master/Examples/Example/Example4_DifferentSectionAndItemTypes.swift)


    //
    //  MultipleSectionModelViewController.swift
    //  RxDataSources
    //
    //  Created by Segii Shulga on 4/26/16.
    //  Copyright © 2016 kzaher. All rights reserved.
    //
    
    import UIKit
    import RxDataSources
    import RxCocoa
    import RxSwift
    import Differentiator
    
    // the trick is to just use enum for different section types
    class MultipleSectionModelViewController: UIViewController {
        
        @IBOutlet weak var tableView: UITableView!
        let disposeBag = DisposeBag()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let sections: [MultipleSectionModel] = [
                .ImageProvidableSection(title: "Section 1",
                    items: [.ImageSectionItem(image: UIImage(named: "settings")!, title: "General")]),
                .ToggleableSection(title: "Section 2",
                    items: [.ToggleableSectionItem(title: "On", enabled: true)]),
                .StepperableSection(title: "Section 3",
                    items: [.StepperSectionItem(title: "1")])
            ]
            
            let dataSource = MultipleSectionModelViewController.dataSource()
            
            Observable.just(sections)
                .bind(to: tableView.rx.items(dataSource: dataSource))
                .disposed(by: disposeBag)
        }
    }
    
    extension MultipleSectionModelViewController {
        static func dataSource() -> RxTableViewSectionedReloadDataSource<MultipleSectionModel> {
            return RxTableViewSectionedReloadDataSource<MultipleSectionModel>(
                configureCell: { (dataSource, table, idxPath, _) in
                    switch dataSource[idxPath] {
                    case let .ImageSectionItem(image, title):
                        let cell: ImageTitleTableViewCell = table.dequeueReusableCell(forIndexPath: idxPath)
                        cell.titleLabel.text = title
                        cell.cellImageView.image = image
    
                        return cell
                    case let .StepperSectionItem(title):
                        let cell: TitleSteperTableViewCell = table.dequeueReusableCell(forIndexPath: idxPath)
                        cell.titleLabel.text = title
    
                        return cell
                    case let .ToggleableSectionItem(title, enabled):
                        let cell: TitleSwitchTableViewCell = table.dequeueReusableCell(forIndexPath: idxPath)
                        cell.switchControl.isOn = enabled
                        cell.titleLabel.text = title
    
                        return cell
                    }
                },
                titleForHeaderInSection: { dataSource, index in
                    let section = dataSource[index]
                    return section.title
                }
            )
        }
    }
    
    enum MultipleSectionModel {
        case ImageProvidableSection(title: String, items: [SectionItem])
        case ToggleableSection(title: String, items: [SectionItem])
        case StepperableSection(title: String, items: [SectionItem])
    }
    
    enum SectionItem {
        case ImageSectionItem(image: UIImage, title: String)
        case ToggleableSectionItem(title: String, enabled: Bool)
        case StepperSectionItem(title: String)
    }
    
    extension MultipleSectionModel: SectionModelType {
        typealias Item = SectionItem
        
        var items: [SectionItem] {
            switch  self {
            case .ImageProvidableSection(title: _, items: let items):
                return items.map {$0}
            case .StepperableSection(title: _, items: let items):
                return items.map {$0}
            case .ToggleableSection(title: _, items: let items):
                return items.map {$0}
            }
        }
        
        init(original: MultipleSectionModel, items: [Item]) {
            switch original {
            case let .ImageProvidableSection(title: title, items: _):
                self = .ImageProvidableSection(title: title, items: items)
            case let .StepperableSection(title, _):
                self = .StepperableSection(title: title, items: items)
            case let .ToggleableSection(title, _):
                self = .ToggleableSection(title: title, items: items)
            }
        }
    }
    
    extension MultipleSectionModel {
        var title: String {
            switch self {
            case .ImageProvidableSection(title: let title, items: _):
                return title
            case .StepperableSection(title: let title, items: _):
                return title
            case .ToggleableSection(title: let title, items: _):
                return title
            }
        }
    }
    


    이거 테이블뷰와 콜렉션뷰를 rx로 자유자재로 사용하려면 좀 더 많은 연구를 해야 할 듯 싶습니다.

Designed by Tistory.