iOS(Swift) -UIDatePicker,UITableView,SnapKit-[MVVM Design pattern](3)

JSLee·2021년 11월 13일
0
post-thumbnail

안녕하세요!
오늘 알아볼것은~~UIDatePicker,UITableView,SnapKit 들 입니다!
UI에 Data들이 업로드 되는 것들을 View와 ViewModel 로 나누어 MVVM 패턴으로 만들어볼까 합니다!!
일단 앱 먼저 어떤 느낌인지 보면용!


이런식으로 진행 될것입니다~!
rootVC 에서 RightBarButton Tap 을 하게되면 ViewController 가 변경되고
업로드Controller 안에 있는 tableView에서 작업이 되게 되는데요!
contentInsetAdjustmentBehavior 를 이용해서 저렇게 귀엽게 헤더느낌을 줄수도 있습니다 ㅎㅎ
일단 한번 살펴볼까요!
일단 저희가 저번에 image를 Controller 안에서 업로드 하면 로그가 나오는 부분까지 했었잖아요 ㅎ
이제 그 부분을 UI와 함께 구현해보겠습니다!


addBarButtonItem 을 Tap 하게 되면 밑에 @objc tappedAddBarButton 함수가 실행되게
되구요! 프로퍼티에 있는 똥뷰모델 안에 있는 tappedAdd가 실행됩니다!

그럼 똥뷰모델로 가보면!

아~주 간단하죠 ㅋㅋ
왜냐하면 일단!! ViewController를 컨트롤 할곳은 ViewModel이 아닙니다!!
바로 Coordinator 이지유 ㅎ 그니깐 View에서 데이터 주세요! 하면서 ViewModel에게 던지면!
ViewModel은 다시 Coordinator에게 던져서 데이터를 가지고 오는겁니다!!
그래서! 다시 coordinator에게 데이터를 달라고 쫄라보겠습니다!!ㅎ

똥강아지Coordinator에 addDDongEvent를 보시면!
어..? 또 던지는게 보이시나유?
AddDDongCoordinator에! init으로 받은 navigationController를 파라미터로 준뒤 인스턴스를 만들고 그걸 또 protocol로 받은 childCoordinator 배열에 append 하고 또!
인스턴스 안에 있는 함수를 실행시키죠?? 왜냐면!! 저희가 원하는 목적은 데이터 추가 이기때문에 AddCoordinator에서 일처리를 할것이기 때문입니다 ㅎㅎ!!

요놈 인데요!
방금전 보냈던 goingStart 함수를 보시면
네비게이션 인스턴스를 만들고~

extension UIViewController {
    //MARK: - storyboard.instantiateViewController
     static func instantiate<T>() -> T {
         let storyboard = UIStoryboard(name: "Main", bundle: .main)
         let controller = storyboard.instantiateViewController(withIdentifier: "\(T.self)") as! T
         return controller
     }
}

UIViewController를 extension 해서 만들어 둔! instantiate 를 실행시켜줍니다
그럼 UIViewController가 무엇인지만 타입지정해주면~ 이함수는 static 이기 때문에 언제어디서나 사용가능합니다
그럼~ 아주 간단하고 깔끔하게 뷰전환이 가능하게 되는 함수이지용
아주 유용하답니다~
그렇게 해서 반환 받은 값을 네비게이션컨트롤러에 저장해준뒤에
또다시 에드똥뷰모델 인스턴스를 생성해줍니다!
이유는 아시겠지요? 원하는건 Add를 하기 위한 값이니깐! 값을 ViewModel에서 가져와야되잖아요!ㅎㅎ
그 값들을 AddViewController에 저장해준뒤 네비게이션을 이용해서 뷰를 열어주는겁니다!

import Foundation
import UIKit
import SnapKit

final class AddDDongViewModel {
    
    let title = "똥강아지 기록 남기기📸"
    
    var updateOn: () -> Void = {}
    
    enum Cell {
        case ddongTitleSub(DDongTitleSubtitleViewModel)
        case ddongImage
    }
    private(set) var cells : [Cell] = []
    
    var coordinator : AddDDongCoordinator?
    
    func viewDidDisappear() {
        coordinator?.finishAddDDong()
    }
    func numberOfRowsSection() -> Int {
        return cells.count
    }
    func cell(for indexPath: IndexPath) -> Cell {
        return cells[indexPath.row]
    }
    func viewDidLoad(){
        cells =
        [
            .ddongTitleSub(DDongTitleSubtitleViewModel(title: "🐶제목", subtitle: "", placeholder: "제목을 입력해주세왈왈!🐶",type: .text,onCellUpdate: {}))
            ,
            .ddongTitleSub(DDongTitleSubtitleViewModel(title: "🐶날짜", subtitle: "", placeholder: "날짜를 설정해주세왈왈!🐶",type: .date, onCellUpdate: {
            [weak self] in
            self?.updateOn()
        })),
            .ddongTitleSub(DDongTitleSubtitleViewModel(title: "🐶사진", subtitle: "", placeholder: "",type: .image, onCellUpdate: {
            [weak self] in
            self?.updateOn()
        }))
        ]
        updateOn()
    }
    func tappedDone(){
        
    }
    func updateCell(indexPath:IndexPath,subtitle:String) {
        switch cells[indexPath.row] {
        case .ddongTitleSub(let titleCellViewModel) :
            titleCellViewModel.updateSubtitle(subtitle)
        default :
            print("🌆 디폴트~")
        }
    }
}

Add에 필요한 값들은 여기에서 가지고 있다고 보시면 됩니다!
다른건 거의 다 아실텐데!
DDongTitleSubtitleViewModel <- 이 듣보잡은 뭐지..
하시는 분들도 있을껍니다!
AddController는 tableView로 구성되어있어요!
그럼 그 tableView도 mvvm적용이 되어야 되겠지요?
그렇다는말은?! 네! viewModel이 있어야합니다 ㅎㅎ

보게 되면@ 아~ enum으로 타입을 3가지 받는구나~ 라는건 딱 아시겠지용
그리고 함수가 2개지용?!
그렇다는말은!? 추가를 2개 할수있구나!
하나는 날짜랑~ 하나는 subtitle 이구나!
그런데 날짜는 DateFormatter로 변경하는구나~
근데 DateFormatter는 뭐징??
쉽게 말해서 UIDatePicker 에서 나오는 날짜 데이터를 저희가 맘대로? 바꿀수 있는겁니다!
예를 들면~ "dd.MM.yy" <- 13 11 21
"dd.MM.yy" <- 13 11 2021 이런식으로 말이요!
그리고 string으로 형변환까지 할수 있습니다 ! UIDatePicker 사용시 필수로 해주셔야해용
자 그럼 일단 구성은 어느정도 알겠으니! 어떻게 로직이 돌아가는지 볼까요??

import UIKit
import RxSwift
import RxCocoa

class AddDDongViewController: UIViewController {
    
    @IBOutlet weak var tableView: UITableView!
    
    var viewModel : AddDDongViewModel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        viewSetup()
        viewModel.updateOn = {
            [weak self] in
            self?.tableView.reloadData()
        }
        viewModel.updateOn = { [weak self] in
            self?.tableView.reloadData()
        }
        viewModel.viewDidLoad()
    }
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        viewModel.viewDidDisappear()
    }
    @objc private func doneTapped(){
        viewModel.tappedDone()
    }
    private func viewSetup(){
        tableView.dataSource = self
        tableView.register(DDongTitleSubtitleCell.self, forCellReuseIdentifier: "DDongTitleSubtitleCell")
        tableView.tableFooterView = UIView()
        navigationItem.title = viewModel.title
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped))
        navigationController?.navigationBar.tintColor = .black
        tableView.contentInsetAdjustmentBehavior = .always
        tableView.setContentOffset(.init(x: 0, y: -2), animated: true)
    }
}
extension AddDDongViewController : UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.numberOfRowsSection()
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cellVM = viewModel.cell(for:indexPath)
        switch cellVM {
        case .ddongTitleSub(let dDongTitleSubtitleViewModel):
            let cell = tableView.dequeueReusableCell(withIdentifier: "DDongTitleSubtitleCell", for: indexPath) as! DDongTitleSubtitleCell
            cell.updateViewModel(with: dDongTitleSubtitleViewModel)
            cell.subtitleTextField.delegate = self
            return cell
        default :
           return UITableViewCell()
        }
    }
    
}
extension AddDDongViewController : UITextFieldDelegate {
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        guard let currentText = textField.text else { return false }
        let text = currentText + string
        let point = textField.convert(textField.bounds.origin, to: tableView)
        if let indexPath = tableView.indexPathForRow(at: point) {
            viewModel.updateCell(indexPath : indexPath,subtitle:text) 
        }
        return true
    }
}

AddViewController 입니다!
별거 없어요 ㅎㅎ
일단 제목에서 설명드린 navigationController title 설정 부터 설명 드릴께요!

 private func viewSetup(){
        tableView.dataSource = self
        tableView.register(DDongTitleSubtitleCell.self, forCellReuseIdentifier: "DDongTitleSubtitleCell")
        tableView.tableFooterView = UIView()
        navigationItem.title = viewModel.title
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped))
        navigationController?.navigationBar.tintColor = .black
        tableView.contentInsetAdjustmentBehavior = .always
        tableView.setContentOffset(.init(x: 0, y: -2), animated: true)
    }

contentInsetAdjustmentBehavior <- 요놈이 그 역할을 해줄 친구입니다 ㅎ
그리고 offset y값을 얼마나 줄껀지만 설정해 주시면 됩니다 ㅎㅎ
테이블을 만들어 주셨으면 Cell에 대한 구성도 해야겠지요?
그럼 먼저 register로 연결을 해주셔야 합니다!
그럼 register로 연결되어있는 DDongTitleSubtitleCell 로 가볼께요!

import Foundation
import SnapKit

final class DDongTitleSubtitleCell : UITableViewCell {
 
 private let titleLabel = UILabel()
 
 let subtitleTextField = UITextField()
 
 private let verticalStackView = UIStackView()
 
 private let stackOffset : CGFloat = 20
 
 private let datePickerView = UIDatePicker()
 
 private let toolbar = UIToolbar(frame:.init(x: 0, y: 0, width: 100, height: 50))
 
 private var viewModel : DDongTitleSubtitleViewModel?
 
 private let dogPhotoImageView = UIImageView()
 
 lazy var doneButton : UIBarButtonItem = {
     UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(tappedDone))
 }()
 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
     super.init(style: style, reuseIdentifier: reuseIdentifier)
     viewSetup()
     hierarchySetup()
     layoutSetup()
 }
 required init?(coder: NSCoder) {
     fatalError("init(coder:) has not been implemented")
 }
 func updateViewModel(with viewModel : DDongTitleSubtitleViewModel) {
     self.viewModel = viewModel
     titleLabel.text = viewModel.title
     subtitleTextField.text = viewModel.subtitle
     subtitleTextField.placeholder = viewModel.placeholder
     subtitleTextField.inputView = viewModel.type == .text ? nil : datePickerView
     subtitleTextField.inputAccessoryView = viewModel.type == .text ? nil : toolbar
     dogPhotoImageView.isHidden = viewModel.type != .image
     subtitleTextField.isHidden = viewModel.type == .image
     verticalStackView.spacing = viewModel.type == .image ? 15 : verticalStackView.spacing
     verticalStackView.spacing = viewModel.type == .text ? 10 : verticalStackView.spacing
     verticalStackView.spacing = viewModel.type == .date ? 10 : verticalStackView.spacing

 }
 private func viewSetup(){
     verticalStackView.axis = .vertical
     titleLabel.font = UIFont.systemFont(ofSize: 22,weight: .bold)
     subtitleTextField.font = UIFont.systemFont(ofSize: 15,weight: .medium)
     subtitleTextField.leftPadding()
     toolbar.setItems([doneButton], animated: false)
     datePickerView.preferredDatePickerStyle = .wheels
     datePickerView.datePickerMode = .date
     dogPhotoImageView.backgroundColor = .blue.withAlphaComponent(0.4)
     dogPhotoImageView.layer.cornerRadius = 10
 }
 private func hierarchySetup(){
     contentView.addSubview(verticalStackView)
     verticalStackView.addArrangedSubview(titleLabel)
     verticalStackView.addArrangedSubview(subtitleTextField)
     verticalStackView.addArrangedSubview(dogPhotoImageView)
 }
 private func layoutSetup(){
     verticalStackView.snp.makeConstraints { (make) in
         make.top.equalTo(contentView.snp.top).offset(stackOffset)
         make.left.equalTo(contentView.snp.left).offset(stackOffset)
         make.bottom.equalTo(contentView.snp.bottom).offset(-stackOffset)
         make.right.equalTo(contentView.snp.right).offset(-stackOffset)
     }
     dogPhotoImageView.snp.makeConstraints { (make) in
         make.height.equalTo(200)
     }
 }
@objc private func tappedDone(){
     print("📸tapped Done!!!!!ㅎㅎ")
    viewModel?.updateDate(datePickerView.date)
 }
}

그리고 UI LayOut관련 라이브러리인 snapKit 을 설명드릴까 하는데요
저희 맨날 오토 레이아웃 잡을때 코드도 길어지고...복잡하고 드릅고.. 그랬잖아요
snapKit 사용 하시면 아주간단하게 해결할수 있습니다!

private func layoutSetup(){
  verticalStackView.snp.makeConstraints { (make) in
      make.top.equalTo(contentView.snp.top).offset(stackOffset)
      make.left.equalTo(contentView.snp.left).offset(stackOffset)
      make.bottom.equalTo(contentView.snp.bottom).offset(-stackOffset)
      make.right.equalTo(contentView.snp.right).offset(-stackOffset)
  }
  dogPhotoImageView.snp.makeConstraints { (make) in
      make.height.equalTo(200)
  }
}

이렇게요 아주간단하죠?? 저는 snapKit을 알고된뒤로 snapKit만 사용해서 레이아웃 잡는거 같아요 ㅎㅎ
일단 cell에 대해 설명하게 되면!

private func viewSetup(){
   verticalStackView.axis = .vertical
   titleLabel.font = UIFont.systemFont(ofSize: 22,weight: .bold)
   subtitleTextField.font = UIFont.systemFont(ofSize: 15,weight: .medium)
   subtitleTextField.leftPadding()
   toolbar.setItems([doneButton], animated: false)
   datePickerView.preferredDatePickerStyle = .wheels
   datePickerView.datePickerMode = .date
   dogPhotoImageView.backgroundColor = .blue.withAlphaComponent(0.4)
   dogPhotoImageView.layer.cornerRadius = 10
}

viewSetup 함수에서 UI 요소 를 설정할꺼에요
font라던지 Style 이라던지 물론 생성클로져 사용해서 해도 되는데 간단한 몇줄 적는데 코드만 더길어지는거 같더라구요.
아 그리고 textField에 leftPadding은 제가 extension 한 함수입니다.
텍스트필드가 원래 만들게 되면 text나 placeholder 가 왼쪽에 완전 바짝 붙어있어서 꼴보기 싫거든요..ㅠㅠ 그래서 왼쪽에 padding을 주는겁니다. 그냥 뷰하나 만들어서 왼쪽 끝에 추가해주는게 다에요 ㅎㅎ

이런식으로요! 간단하죠 이렇게 하면 왼쪽에 이미지또한 넣을수 있으니 기호에 따라 사용하시면 될꺼 같습니다.
아그리고 verticalStackView.spacing = viewModel.type == .image ? 15 : verticalStackView.spacing 이부분을 잘 모르시는 분도 있는데 이건 그냥 제약이랑 값을 정해주는거랑 같은겁니다! 그냥 스택뷰에~ spacing 값은 뷰모델의 타입이 이미지랑 같으면 15 spacing 줘라
이런 말입니다 ㅋㅋㅋ
간단하죠
또 toolbar 는
요렇게 날짜 텍스트 뷰를 켰을때!
inputView로 받은 DatePicker가 실행되는데 그위에 있는 Done버튼을 말해요
toolbar를 이용해서 만들고 그걸 추가하는것이지요!

     subtitleTextField.inputView = viewModel.type == .text ? nil : datePickerView
    subtitleTextField.inputAccessoryView = viewModel.type == .text ? nil : toolbar
     
 toolbar.setItems([doneButton], animated: false)

이렇게 보시면 될꺼 같습니다!.ㅎㅎ
마지막으로 Done 버튼 클릭시 액션에 대해서 설명드릴께요!

@objc private func tappedDone(){
    print("📸tapped Done!!!!!ㅎㅎ")
   viewModel?.updateDate(datePickerView.date)
}

실행되는 함수이고!
뷰모델인!DDongTitleSubtitleViewModel 에 있는 함수가 실행됩니다!
일단 파라미터로 받은 Date를 포맷해주고 서브타이틀로 올려주고
onCellUpdate라는 함수가 실행되는데요!

    func updateDate(_ date : Date ) {
       let dateString = dateFormatter.string(from: date)
       self.subtitle = dateString
       onCellUpdate()
   }

뷰컨트롤러에 있는 테이블뷰를 다시한번 리로드 시키는 함수를 실행시키는 것입니다.!!
후아...개발보다.. 블로그에 글쓰는게 더어려운거 같아요..
말로하면 쉬울꺼같은데 글로 쓰려니... 제가 멍청한것일까요..?ㅠㅠ 글재주가 없는거 같기도하고...
제대로 설명을 못하겠습닏..ㅠㅠ 그래도 계속 올리고 올리다보면 잘쓸수 있어지겠지요!!ㅎㅎ
아무튼 봐주신분들 모두 감사하고 하시는일 행운이 가득하시길 기도하겠습니다!!!
감사합니다!!

profile
iOS/Android/FE/BE

0개의 댓글