VesselWheel

CollectionView를 활용한 키오스크 메뉴화면 만들기(feat. 코드 베이스) 본문

Xcode Study

CollectionView를 활용한 키오스크 메뉴화면 만들기(feat. 코드 베이스)

JasonYang 2023. 12. 31. 20:10

들어가기 앞서, 

 

평소 사용하던 키오스크 앱이나, 카페, 페스트 푸드 어플리케이션을 보면 내가 원하는 메뉴를 선택하기 위해 메뉴 그림, 가격을 나열해 둔 것을 볼 수 있다. 

스타벅스 같은 경우, 위에서 아래로 메뉴가 나열되어 있어, 데이블뷰로 구현되어 있다. 

하지만, 블로그에서 다루지는 않겠지만 다른 메뉴를 선택하고 주문하는 앱들을 보면 MD 상품이나 회원가입이 우선되어 원하는 메뉴를 찾기 위해서 여러번 클릭해야하는 경험을 해왔다. 

이점에서 불편함을 경험하였고, 부분적이지만 collectionView로 해당 상품들을 직관적으로 볼 수 있도록 구현해 보았다. 

 

화면 중단에 음료 카테고리를 선택한 후 음료 메뉴가 구현된 화면

 

MenuView 클래스에 구현된 CollectionView

더보기
//
//  MenuView.swift
//  SPABUCKS-Kiosk-iOS
//
//  Created by JasonYang on 2023/12/29.
//
import UIKit

class MenuView: UIView {
    
    // MARK: - Properties
    
    private let orderView =  OrderListView()
    
    var dataSource: [SpabucksMenuItem] = []
    var drinkItems: [SpabucksMenuItem] = [
        SpabucksMenuItem(id: 0, name: "Caffè Americano", imageName: "americano", price: 5700),
        SpabucksMenuItem(id: 1, name: "Caramel Macchiato", imageName: "caramel_macchiato", price: 5900),
        SpabucksMenuItem(id: 2, name: "Flat White", imageName: "flat_white", price: 6000),
        SpabucksMenuItem(id: 3, name: "Caffe Latte", imageName: "Caffe Latte", price: 8000)]
    
    var foodItems: [SpabucksMenuItem] = [
        SpabucksMenuItem(id: 0, name: "Croissant", imageName: "croissant", price: 5000),
        SpabucksMenuItem(id: 1, name: "Sandwich", imageName: "sandwich", price: 6000),
        SpabucksMenuItem(id: 2, name: "Salad", imageName: "salad", price: 6000),
        SpabucksMenuItem(id: 3, name: "Scorn", imageName: "scorn", price: 5500)]
    
    var merchandiserItems: [SpabucksMenuItem] = [
        SpabucksMenuItem(id: 0, name: "Tumbler", imageName: "Tumbler", price: 5500),
        SpabucksMenuItem(id: 1, name: "Pen", imageName: "Pen", price: 1500),
        SpabucksMenuItem(id: 2, name: "Notebook", imageName: "Notebook", price: 12500),
        SpabucksMenuItem(id: 3, name: "Tent", imageName: "Tent", price: 25000)]
    
    // MARK: - Life Cycle
    
    private let collectionView: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
// test
// MARK: - Extensions

extension MenuView {
    func showBeverageView() {
        dataSource = drinkItems
        collectionView.reloadData()
    }
    func showFoodMenuView() {
        dataSource = foodItems
        collectionView.reloadData()
    }
    
    func showMdMenuView() {
        dataSource = merchandiserItems
        collectionView.reloadData()
    }
}

// MARK: - UICollectionViewDelegate

extension MenuView: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("Selected: \(dataSource[indexPath.row])")
        let selectedData = dataSource[indexPath.row] // 선택된 셀의 데이터
        self.orderView.getOrderItem(selectedData)
        
        collectionView.deselectItem(at: indexPath, animated: true)
    }
}

// MARK: - UICollectionViewDataSource

extension MenuView: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        
        return dataSource.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MenuCell.collectionViewCellIdentifier, for: indexPath) as? MenuCell else {
            
            return UICollectionViewCell()
        }
        cell.imageView.image = UIImage(named: dataSource[indexPath.row].imageName)
        cell.nameLabel.text = dataSource[indexPath.row].name
        cell.priceLabel.text = String("\(Int(dataSource[indexPath.row].price)) 원")
        
        return cell
    }
}

// MARK: - UICollectionViewDelegateFlowLayout

extension MenuView: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
        return CGSize(width: 165, height: 70)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        
        return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
        
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        
        return 30
        
    }
    //    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
    //        return 30
    //    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        
        return CGSize(width: 400, height: 15)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
        
        return CGSize(width: 400, height: 15)
    }
}

extension MenuView {
    private func setUI() {
        heightAnchor.constraint(equalToConstant: 455).isActive = true
        
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.register(MenuCell.self, forCellWithReuseIdentifier: MenuCell.collectionViewCellIdentifier)
        collectionView.backgroundColor = .white
        addSubview(collectionView)
        
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: self.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 15),
            collectionView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -10),
            collectionView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -10)
        ])
    }
}

 

MenuView에 생성된 프로퍼티 정의

더보기
//
//  SpabucksMenuItem.swift
//  SPABUCKS-Kiosk-iOS
//
//  Created by Jason Yang on 12/27/23.
//

import Foundation

class SpabucksMenuItem : Eatable {
    var id: Int
    var name: String
    var imageName: String
    var price: Double
    
    init(id: Int, name: String, imageName: String, price: Double) {
        self.id = id
        self.name = name
        self.imageName = imageName
        self.price = price
    }
}

생선된 프로퍼티를 규약하는 프로토콜 Eatable

더보기
//
//  Eatable.swift
//  SPABUCKS-Kiosk-iOS
//
//  Created by Jason Yang on 12/28/23.
//

import Foundation

protocol Eatable {
    var id: Int { get set }
    var name: String { get set }
    var imageName: String { get set }
    var price: Double { get set }
    
    func displayInfo()
}

extension Eatable {
    func displayInfo() {
        print("\(id)  | W \(name)  | \(price)")
    }
}

 

생성된 collectionView에 Cell의 layout을 정의하는 extension MenuCell 

더보기
//
//  MenuCell.swift
//  SPABUCKS-Kiosk-iOS
//
//  Created by Jason Yang on 12/28/23.
//

import UIKit

class MenuCell: UICollectionViewCell {
    
    // MARK: - Properties
    
    static let collectionViewCellIdentifier = "CollectionViewCellIdentifier"
    
    // MARK: - UI Properties
    
    let imageView = UIImageView()
    let nameLabel = UILabel()
    let priceLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        viewCell()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func viewCell() {
        backgroundColor = .white
        imageView.contentMode = .scaleAspectFit
        contentView.addSubview(imageView)
        imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), // 위 여백
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
            imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
            imageView.widthAnchor.constraint(equalToConstant: 50),
            imageView.heightAnchor.constraint(equalToConstant: 50)
        ])
        
        nameLabel.textAlignment = .left
        nameLabel.numberOfLines = 2
        nameLabel.lineBreakMode = .byWordWrapping
        contentView.addSubview(nameLabel)
        nameLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
            nameLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 5),
            nameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 5),
        ])
        
        priceLabel.textAlignment = .left
        contentView.addSubview(priceLabel)
        priceLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            priceLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 5),
            priceLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5)
        ])
    }
}

 


상기 코드는 코드베이스로 storyboard를 사용하지 않고 delegate pattern과 ViewController와의 상호작용을 통해 구현하였다. 

- 스토리보드로 구현할 경우, 협업의 경우 github로 업무를 하기 때문에, 비대면의 특성상 동시에 commit-push하고 merge할 때에 코드 충돌이 발생하는 경우가 많다. 

 

- 스토리보드로 구현할 경우에도 프로퍼티 별로 swift 파일을 연결하여서 작성하고, delegate 패턴을 활용하면 충돌은 상대적으로 막을 수 있다. 

 

다른 팀원들과의 협업 간 폴더링, 깃 컨밴션, 코드 컨벤션에 대해 깊히있게 배울 수 있었다. 

 

코드베이스를 위한 xcode 폴더링 화면

 

 

깃 컨밴션

더보기

Git Convention

1. Issue를 생성한다. // 작업의 단위, 번호 부여

2. Issue의 Feature Branch를 생성한다. // ex. feat/#이슈번호-기능 설명-닉네임

3. ~작업~ // Add - Commit - Push - Pull Request 의 과정

4. Pull Request가 작성되면 작성자 이외의 다른 팀원이 Code Review를 한다.

5. Code Review가 완료되고, 
	 2명 이상 Approve 하면 Pull Request 작성자가 develop Branch로 merge 한다.
	 // Conflicts 방지

6. 팀원들에게 merge 사실을 알린다.
  
7. 브랜치를 삭제한다.

8. 다른 팀원들은 merge된 작업물을 pull하고 다시 각자 맡은 작업을 이어나간다.
  1. 이슈 생성
  2. 브랜치 파기
  3. 작업…
  4. Pull Request
  5. 브랜치 삭제

📌 브랜치 단위

  • 브랜치 단위 = 이슈 단위 = PR단위

📌 브랜치명

feature/#이슈번호-간단기능설명-닉네임

브랜치 앞에 써주세여~

  • feature
  • network
  • fix
  • 뷰 단위로 크게 판다 (ui / 기능 구현 / 네트워크)
  • ex) feat/#1-MainViewUI-joon
    • feature/chore/fix
    • feature/#7-MainViewUI-joon
    • network/#4 - MypageAPI-joon

PR

Issue & Pull Request

  • Issue 이름 : [종류] 작업명 ex) [Feat] Main View UI 구현
  • template에 맞춰서 작성
  • 담당자, 리뷰어지정, 라벨 추가, 프로젝트 추가 꼭 하기
  • Pull Request : [종류] #이슈번호 작업명
    • ex) [Feat] #13 Main View UI 구현

Commit Convention

  • [Feat] : 새로운 기능 구현
  • [Fix] : 버그, 오류 해결
  • [Chore] : 코드 수정, 내부 파일 수정, 애매한 것들이나 잡일은 이걸로!
  • [Add] : 라이브러리 추가, 에셋 추가
  • [Del] : 쓸모없는 코드 삭제 (ex. 주석 삭제, 구조 변경 등)
  • [Docs] : README나 WIKI 등의 문서 개정
  • [Refactor] : 전면 수정이 있을 때 사용합니다
  • [Setting] : 프로젝트 설정관련이 있을 때 사용합니다.
  • [Merge] - Pull Developfea1

 

코드 컨밴션

더보기
  • 변수명 = 동사+명사
  • 중괄호 한 칸 띄어쓰기!!!!!!!!
  • 무조건~ 개행 시 공백 한 칸
  • 변수명 축약어X 그냥 길~게 쓰세요
  • : 이거 붙여쓰기 (삼항 연산자 제외)
  • let number: Int = 1 (O) let number : Int = 1 (X 백준님이 사망하심)
  • MARK 주석
    • 사용 시 개행 공백 한 칸
    class ViewController: UIViewController {
        // MARK: - Properties
        // MARK: - UI Properties
    '''
        // MARK: - Life Cycle (init - Life Cycle - deinit
    '''
    
    }
    extension ViewController {
    		// MARK: - Layout
        // MARK: - @objc
        // MARK: - Private Methods
    }
    
    // MARK: - UITableView Delegate
    .
    .
    .
    

 

참고자료

추가로 알아야 할 사항

- StackView, delegate pattern, UIButton, 데이터소스를 별도 클래스로 구현하기