SSAC iOS 앱 개발자 데뷔과정 - 10

🔨 Custom TableViewCell

지난 시간에 TableView 에 대해서 공부할 때는, Apple 에서 제공하는 system style 의 Cell 을 이용해서 Table View 를 만들었습니다.

이번에는 Custom Table View Cell 을 이용해서 Table View 를 만들어 보겠습니다.

❗️Custom cell 을 만드는 과정은 씬을 만들고 로직파일을 생성해 연결하는 과정과 비슷합니다!


1 ) Custom Cell

위의 그림처럼 우리의 목적에 맞는 디자인으로 cell 을 만들어 줍니다.


2 ) Custom Cell 로직 파일 생성 및 연결

  • UITableViewCell 을 상속받는 로직 파일을 생성합니다.
  • StoryBoard 에서 cell 과 로직 파일을 연결합니다.
  • cellidentifier 를 설정합니다.

❗️ Identity inspector 에서의 identity 랑 identifier 는 다릅니다.


3 ) 로직 파일 작성

Custom Cell 에 우리가 추가한 View 객체들을 아웃렛, 액션 열결 합니다.

❗️ Assistant 사용하면 cell 과 연결된 로직 파일이 나오지 않습니다
❗️Table View Controller 로직 파일이 아닙니다

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
//UITableViewCell 타입이 들어오는건 알겠는데 (우리가 만든 custom cell 의 슈퍼클래스) 
//혹시 그게 ShoppingTableViewCell 로 다운캐스팅이 되니?
//guard 를 통해서 캐스팅이 안되면 UITalbeViewCell 타입으로 줘~
        guard let cell = tableView.dequeueReusableCell(withIdentifier: ShoppingTableViewCell.identifier, for: indexPath) as? ShoppingTableViewCell else {
            return UITableViewCell()
        }
        
        let row = list[indexPath.row]
        
        cell.shopLabel.textColor = .black
        cell.shopLabel.font = .systemFont(ofSize: 20, weight: .bold)
        cell.shopLabel.text = row.checkList
        
        return cell
        
    }

Custom Cell 을 만들고 연결하고 난 후 Table View Controller 에서 우리가 만든 해당 커스텀 셀을 호출하면,

위와 같이 우리가 로직파일에서 연결한 아웃렛을 사용할 수 있습니다.


⚖️ 구조체와 클래스

구조체와 클래스는 값 저장을 위해 프로퍼티, 기능 실행을 위해 메서드를 정의할 수있는 점에서 형태와 역할이 매우 비슷합니다.

둘의 가장큰 차이점은

  • 구조체의 인스턴스는 값 타입
  • 클래스의 인스턴스는 참조 타입

입니다. 예제를 통해서 확인 해보겠습니다.

enum DrinkSize {
    case short, tall, grande, venti
}

struct DrinkStruct {
    let name: String
    var count: Int
    var size: DrinkSize
}

class DrinkClass {
    let name: String
    var count: Int
    var size: DrinkSize
    
    init(name:String, count:Int, size:DrinkSize) {
        self.name = name
        self.count = count
        self.size = size
    }
}

var drinkStruct = DrinkStruct(name: "돌체라떼", count: 3, size: .tall) //let 이면 오류

let drinkClass = DrinkClass(name: "아아", count: 2, size: .venti)

drinkClass.count = 5
drinkClass.size = .tall

drinkStruct.count = 2
drinkStruct.size = .venti

우선 클래스와 구조체의 프로퍼티는 모두 초기값 설정이 필수 입니다.
구조체는 생성자를 따로 구현하지 않아도 멤버 와이즈 생성자를 제공하지만 클래스는 초기에 값을 할당하지 않으면 생성자를 구현해야만 합니다.

위의 예제에서 상수로 선언된 drinkClass 의 프로퍼티 값을 변경하는데 왜 오류가 발생하지 않을까요?

클래스의 인스턴스는 값 타입 이기 때문입니다!

위의 그림과 같이 구조체의 인스턴스인 drinkStruct 는 drinkStruct가 저장된 메모리 공간에 name, count, size 프로퍼티를 가지는 형태입니다.

따라서, let 을 통해서 구조체 인스턴스를 생성하게 되면 해당 공간이 상수로 취급되어 변경이 불가능하기 때문에 name, count, size 프로퍼티를 바꿀수 없습니다.

반면, 클래스의 인스턴스가 저장된 메모리 공간에는 주소값이 존재합니다. 그리고 해당 메모리 주소를 참조하면 name, count, size 프로퍼티가 저장되어 있는 형태입니다.

그렇기 때문에 우리가 let 을 통해서 클래스의 인스턴스를 생성하더라도 해당 메모리에 저장되어 있는 주소는 변경하지 않고, 주소값을 참조하여 name, count, size 프로퍼티를 변경하기 때문에 위에서 오류가 나지 않는것을 확인할 수 있습니다.

typedef struct _drink {
    char name [10];
    int count;
    int size;
}drink

const drink A // 구조체
const drink* B // 클래스

C 언어에서 위와 같은 형태로 구성되지 않을까? 생각하고 있습니다


🧱 프로퍼티와 메서드

  • 저장 프로퍼티 : 인스턴스와 관련된 값을 저장
  • 연산 프로퍼티 : 특정상태에 따른 값을 연산
  • 타입 프로퍼티 : 인스턴스가 아닌 타입 자체에 속하는 프로퍼티

1 ) 저장 프로퍼티

//지연 저장 프로퍼티
struct Poster {
    var image: UIImage = UIImage(systemName: "star") ?? UIImage()
    
    init() {
        print("Poster initailized")
    }
}

struct MediaInformation {
    var mediaTitle: String
    var mediaRuntime: Int
    lazy var mediaPoster: Poster = Poster()
    //poster image 를 사용자가 확인하지 않았는데 굳이 초기화 할 필요 없다
}

var media = MediaInformation(mediaTitle: "오징어게임", mediaRuntime: 222)

print("1") // 1
media.mediaPoster // Poster initailized
print("2") // 2
//media 초기화되고, mediaPoster 프로퍼티에 접근할 때, Poster 생성

일반적인 저장 프로퍼티는 앞선 예제에서 확인했기 때문에 지연저장 프로퍼티를 확인해 보겠습니다.

먼저, 왜 굳이 지연 저장 프로퍼티를 사용하는가? 에 대해서 생각해 보겠습니다.

영화 정보를 알려주는 NavigationView & Collection View 가 있을때,
초기화면에서는 영화 이름과 러닝타임을 보여주고, 해당 cell 을 클릭하면 영화의 포스터를 보여주는 상황이 있습니다.

이때, 사용자가 cell 을 클릭해서 다음 화면으로 넘어가지 않았는데 미리 영화 포스터를 초기화 할 필요가 있을까요?

이와 유사한 상황들을 대비해서 지연 저장 프로퍼티를 사용합니다.

2 ) 연산 프로퍼티

// 연산 프로퍼티 & 프로퍼티 감시자
class BMI {
    
    typealias BMIValue = Double // c typedef 와 동일
    
    var userName: String {
        willSet {
            print("닉네임 변경 예정입니다. \(userName) -> \(newValue)")
        }
        didSet {
            print("닉네임 바뀌었습니다. \(oldValue) -> \(userName)")
        }
    }
    
    var userWeight: BMIValue
    var userHeight: BMIValue
    
    var BMIResult: String {
        get {
            let bmiValue = (userWeight * userWeight) / userHeight
            let bmiStatus = bmiValue < 18.5 ? "저체중" : "정상 이상"
            return "\(userName)님의 BMI 지수는 \(bmiValue)으로 \(bmiStatus)입니다."
        }
        set(nickname) {
            //저장 프로퍼티 값들을 변경할 수 있음 (set 은 get 이 있어야 사용할 수 있음)
            userName = nickname
        }
    }
    
    init(userName: String, userWeight: BMIValue, userHeight: Double) {
        self.userName = userName
        self.userWeight = userWeight
        self.userHeight = userHeight
    } 
}

let bmi = BMI(userName: "Shin", userWeight: 76, userHeight: 180)
let result = bmi.BMIResult
print(result) //Shin님의 BMI 지수는 32.0889으로 정상 이상입니다.

bmi.BMIResult = "최익현"
//닉네임 변경 예정입니다. Shin -> 최익현
//닉네임 바뀌었습니다. Shin -> 최익현
bmi.BMIResult = "김판호"
//닉네임 변경 예정입니다. 최익현 -> 김판호
//닉네임 바뀌었습니다. 최익현 -> 김판호

연산 프로퍼티는 실제로 값이 저장되는게 아닌, 특정 상태에 따른 값을 연산해주는 프로퍼티입니다.
따라서 생성자에서 따로 값을 초기화 하지 않는 것을 확인 할 수 있습니다.

그럼 메서드와 기능이 동일한데 왜 굳이 연산 프로퍼티를 사용하는가? 에 대해서 생각해보겠습니다.

인스턴스 메서드에서 확인하겠지만, 구조체의 메서드에서 자신의 프로퍼티 값을 수정하려면 mutating 을 사용해야 합니다.

그렇기 때문에 기능에 따라 메서드를 접근자, 설정자 2개를 선언해야 하는 번거로움이 있기 때문에 연산 프로퍼티를 사용합니다.

위의 예제에서, let result = bmi.BMIResult 를 통해서 result 에 BMIResult 의 get() 반환값이 저장되고,

bmi.BMIResult = "최익현" 을 통해서 bmi의 프로퍼티 userName최익현 으로 변경되는 것을 프로퍼티 감시자 를 통해서 확인 할 수 있습니다.

3 ) 타입 프로퍼티

class User {
    
    static let nickname2 = "shinsang"
    static var totalOrderCount: Int = 0 {
        didSet {
            print("총 주문횟수 : \(oldValue) -> \(totalOrderCount)")
        }
    }
    
    static var orderProduct: Int {
        get {
            return totalOrderCount
        }
        set {
            totalOrderCount += newValue
        }
    }
}

User.nickname2 // shinsang
User.totalOrderCount // 0
User.orderProduct // 0

User.orderProduct = 10 // 총 주문횟수: 0 -> 10
print(User.totalOrderCount) // 10
User.orderProduct = 20 // 총 주문횟수: 10 -> 30
print(User.totalOrderCount) // 30

타입 프로퍼티는 각각의 인스턴스가 아닌 타입 자체에 속하는 프로퍼티를 말합니다. (c++ 클래스의 Static Member 와 유사)

위의 예제에서 static 으로 선언한 프로퍼티와 메서드들은 클래스를 통해 인스턴스를 생성하지 않고 클래스 자체에서 프로퍼티와 메서드를 호출하는 것을 확인 할 수 있습니다!

❗️cell 의 identifier 이렇게 사용해서 String 을 지양


  • 인스턴스 메서드 : 특정 타입의 인스턴스에 속한 함수
  • 타입 메서드 : 타입 자체에 호출이 가능한 메서드

사실 프로퍼티 예제들을 자연스럽게 메서드들을 추가했었습니다. 간단하게 핵심만 짚고 넘어가도록 하겠습니다.

class Point {
    var x = 0.0
    var y = 0.0
    
    func moveBy(x: Double, y: Double) -> (String) {
        self.x += x
        self.y += y
        return "Hello"
    }
    
}

struct  Point2 {
    var x = 0.0
    var y = 0.0
    
    mutating func moveBy(x: Double, y: Double) {
        self.x += x
        self.y += y
    }
}

연산 프로퍼티에서 잠깐 언급했듯이, Class 는 상관 없지만 구조체에서 프로퍼티의 값을 변경할 경우에는, mutating 을 사용해야 합니다.

class Coffee {
    static var name = "아메리카노"
    static var shot = 2
    
    static func plusShot() {
        shot += 1
    }
    
    class func minusShot() {
        shot -= 1
    }
    
    func hello() {
        print("hello")
    }
}


class Latte: Coffee {
    
    override class func minusShot() {
        print("타입 메서드를 상속받아 재정의 하고 싶을 경우, 부모 클래스에서 타입 메서드 선언할 때 static -> class")
    }
}

그리고 타입 메서드를 상속 받아 해당 메서드를 오버라이딩 하기 위해서는 위와 같이 static -> class 로 사용해야 합니다.

싱글턴 패턴 정리하기! + 블로그 11

🎣 타입 캐스팅

Custom Table View CellTable View Controller
에서 불러 올때 아래와 같은 코드가 있습니다.

guard let cell = tableView.dequeueReusableCell(withIdentifier: ShoppingTableViewCell.identifier, for: indexPath) as? ShoppingTableViewCell else {
            return UITableViewCell()
        }

해당 코드를 하나씩 뜯어서 살펴 보겠습니다.
tableView.dequeuReusableCell() 의 리턴 타입은 UITableViewCell

우리는 UITableViewCell 를 상속받는 Custom Cell 을 정의했고, 이를 cell 로 불러고오 싶습니다.
❗️ 그래야 우리가 설정한 outlet 들을 사용할 수 있습니다.

그래서 as? 를 통해서 tableView.dequeuReusableCell() 로 불러온 값이 우리가 만든 Custom Cell 타입으로 다운 캐스팅 할 수 있을까?
(as? 의 반환 type 은 옵셔널이기 때문에 guard 를 통해서 바인딩)

하고 물어본다고 생각하면 될것 같습니다. guard 를 통해서 Custom Cell 로 타입 캐스팅이 되지 않으면 원래 리턴 타입인 UITableViewCell 을 반환 받습니다.

as! 를 통해서도 타입 캐스팅이 가능합니다. 하지만 as! 연산자의 반환 타입이 옵셔널이 아니기 때문에, 실패할 경우 nil 이 반환되어 런타임 에러가 날 수 있습니다.

as! : 다운 캐스팅 해!
as? : 다운 캐스팅 해줄래? ~ 안됨 말구

🏷 P.S.

블로그 정리를 하루 미루니까 정말 힘들어지네요 ㅠㅠ🤮

어제 과제에서 Table View Header 에 추가하는게 아닌 다른 Custom Cell 을 추가해서 섹션을 2개로 나눠서 과제를 구현해보려고 시도하다가 결국 실패하고 시간을 너무 많이 소비했습니다...

오늘 배운 내용도 한꺼번에 정리하려다가 너무 대충적게 될것 같아서 하루씩 미루려고 합니다.
(점점 깔끔하게 정리하기가 힘드네요 ㅠ)

어제와 오늘 과제 코드는 깃허브 에서 확인 할 수 있습니다!

추가적으로, 오늘 과제 내용이 UserDefaults 에 쇼핑 리스트 추가 한 내용을 저장하고 불러오는 예제였습니다.

  • 쇼핑 리스트 목록
  • 구매여부
  • 즐겨찾기

세가지를 저장해야 하기 때문에 튜플을 만들어서 (Bool, String, Bool) 로 UserDefaults 에 저장하는 형태로 만들었는데 오류가 발생했습니다.
UserDefaults 에는 Tuple 형태를 저장할 수 없습니다 ㅠ


import Foundation

struct Check {
    var checkBuy: Bool
    var checkStar: Bool
}

struct ShoppingList {
    
    var checkList: String
    var check: Check
    
}

func saveData() {
        
        var shop: Array<[String:Any]> = []
        
        for listData in list {
            let data: [String:Any] = [
                "Content": listData.checkList,
                "CheckBuy": listData.check.checkBuy
            ]
            shop.append(data)
        }
        let userDefaults = UserDefaults.standard
        userDefaults.set(shop, forKey: "shopListBuy")
        
        
        var shop2: Array<[String:Any]> = []
        
        for listData in list {
            let data: [String:Any] = [
                "Content": listData.checkList,
                "CheckStar": listData.check.checkStar
            ]
            shop2.append(data)
        }
        userDefaults.set(shop2, forKey: "shopListStar")
        
        tableView.reloadData()
    }

func loadData() {
        
        let userDefaults = UserDefaults.standard
        
        var shop = [ShoppingList]()
        
        if let data = userDefaults.object(forKey: "shopListBuy") as? [[String:Any]] {
            for datum in data {
                guard let content = datum["Content"] as? String else {return}
                guard let checkBuy = datum["CheckBuy"] as? Bool else {return}
                shop.append(ShoppingList(checkList: content, check: Check(checkBuy: checkBuy, checkStar: false)))
            }
        }
        
        if let data2 = userDefaults.object(forKey: "shopListStar") as? [[String:Any]] {
            for datum in data2 {
                guard let content = datum["Content"] as? String else {return}
                guard let checkStar = datum["CheckStar"] as? Bool else {return}
                //shop 에 저장된 이름 찾아서 checkstar 값 넣어주고 종료
                for i in 0...shop.count - 1 {
                    if shop[i].checkList == content {
                        shop[i].check.checkStar = checkStar
                        break
                    }
                }
            }
        }
        
        self.list = shop
        
    }

그래서 위와 같이 [String:Bool] [String:Bool] 두 쌍의 딕셔너리를 만들어서 UserDefaults 를 2개 만들어 나눠서 저장하고,

불러 올 때는, 먼저 첫번째 Userdefaults 를 불러와 var shop = [ShoppingList]() 배열의 checkList, check.checkBuy 를 채우고,

두번째, UserDefaults 를 돌면서 var shop = [ShoppingList]() 에 저장된 checkList 와 현재 UserDefaults 에 저장된 checkList 가 동일한 인덱스의 var shop = [ShoppingList]() 의 check.checkStar 값을 채워줬습니다.

우선, 이렇게 함수를 구성하게 되면 시간복잡도가 O(N^2) 이기 때문에 데이터의 양이 많아 질 수록 구현이 비효율적입니다.

하지만 UserDefaults 에 값이 어떻게 저장되는지 아직은 모르기 때문에 인덱스가 checkList 를 찾도록 구현했습니다.

  • UserDefaults 에 값이 어떤 자료구조에 어떻게 저장되는가?
  • 값이 세 개 이상이 될 때, UserDefaults 에 어떻게 저장하는게 효율적인가?

를 밀린 블로그 정리를 하면서 더 공부하고 찾아보겠습니다! 🔥🔥🔥

좋은 웹페이지 즐겨찾기