주니곰의 괴발노트
Swift - Protocol(준수에 대한 검사, 상속, 옵셔널 프로토콜, 확장) (4/4) 본문
확장으로 프로토콜 준수성 추가
- 기존 타입에 대해 소스 코드에서 접근할 수 없지만 새로운 프로토콜을 채택하고 준수하기 위해 기존 타입을 확장가능
- 확장은 기존 타입에 새로운 프로퍼티, 메서드, 그리고 서브 스크립트를 추가할 수 있으므로 프로토콜이 요구할 수 있는 모든 요구사항추가 가능
- 타입의 기존 인스턴스는 확장에 인스턴스의 타입이 추가될 때 자동으로 프로토콜을 채택하고 준수
protocol TextRepresentable {
var textualDescription: String { get }
}
extension Dice: TextRepresentable {
var textualDescription: String {
return "A \(sides)-sided dice"
}
}
let d12 = Dice(sides: 12, generator: LinearCongruentialGenerator())
print(d12.textualDescription)
// Prints "A 12-sided dice"
extension SnakesAndLadders: TextRepresentable {
var textualDescription: String {
return "A game of Snakes and Ladders with \(finalSquare) squares"
}
}
print(game.textualDescription)
// Prints "A game of Snakes and Ladders with 25 squares"
- 위에서와 같이 Dice 를 확장한 후 TextRepresentable 채택하고 준수
- 프로토콜 이름은 콜론으로 구분된 타입 이름 뒤에 채택하고 프로토콜의 모든 요구사항은 확장의 중괄호 내에 구현
- 이제 모든 Dice 인스턴스를 TextRepresentable 로 처리가능
- 마찬가지로 SnakesAndLadders 게임 클래스는 TextRepresentable 프로토콜을 채택하고 준수하기 위해 확장가능
조건적으로 프로토콜 준수
extension Array: TextRepresentable where Element: TextRepresentable {
var textualDescription: String {
let itemsAsText = self.map { $0.textualDescription }
return "[" + itemsAsText.joined(separator: ", ") + "]"
}
}
let myDice = [d6, d12]
print(myDice.textualDescription)
// Prints "[A 6-sided dice, A 12-sided dice]"
- 타입을 확장할 때 제약조건을 나열하여 일반 타입이 프로토콜을 조건적으로 준수할 수 있도록 만들 수 있음
- 일반적인 where 절을 작성하여 채택 중인 프로토콜의 이름 뒤에 제약조건을 작성
확장과 함께 프로토콜의 채택 선언
struct Hamster {
var name: String
var textualDescription: String {
return "A hamster named \(name)"
}
}
extension Hamster: TextRepresentable {}
let simonTheHamster = Hamster(name: "Simon")
let somethingTextRepresentable: TextRepresentable = simonTheHamster
print(somethingTextRepresentable.textualDescription)
// Prints "A hamster named Simon"
- 타입이 이미 프로토콜의 모든 요구사항을 준수하지만 해당 프로토콜을 책택한다고 아직 명시하지 않은 경우 빈 확장을 사용하여 프로토콜을 채택하도록 만들 수 있음
- Hamster 의 인스턴스는 TextRepresentable 이 요구된 타입 어디서든 사용될 수 있음
- 요구사항이 충족된다고 해서 프로토콜을 자동으로 채택하지 않으므로 항상 프로토콜 채택을 명시적으로 선언해야 함
합성된 구현을 사용하여 프로토콜 채택
- Swift는 많은 경우에 Equatable, Hashable, 그리고 Comparable 에 대해 프로토콜 준수성을 자동으로 제공할 수 있음
- 합성된 구현을 사용하면 프로토콜 요구사항 구현을 위해 반복적인 상용구 코드를 작성할 필요가 없음
- Swift는 다음과 같은 사용자 정의 타입에 대해 Equatable 의 합성된 구현을 제공
- Equatable 프로토콜을 준수하는 저장된 프로토콜만 있는 구조체
- Equatable 프로토콜을 준수하는 연관된 타입만 있는 열거형
- 연관된 타입이 없는 열거형
struct Vector3D: Equatable {
var x = 0.0, y = 0.0, z = 0.0
}
let twoThreeFour = Vector3D(x: 2.0, y: 3.0, z: 4.0)
let anotherTwoThreeFour = Vector3D(x: 2.0, y: 3.0, z: 4.0)
if twoThreeFour == anotherTwoThreeFour {
print("These two vectors are also equivalent.")
}
// Prints "These two vectors are also equivalent."
- == 의 합성된 구현을 받기 위해선 == 연산자를 직접 구현하지 않고 원래 선언을 포함한 파일에서 Equatable 에 대한 준수성을 선언
- Equatable 프로토콜은 != 의 기본 구현을 제공
- Vector2D 구조체와 유사한 3차원의 벡터 (x, y, z) 에 대한 Vector3D 구조체를 정의
- x, y, 그리고 z 프로퍼티는 모두 Equatable 타입이므로 Vector3D 는 등가 연산자의 합성된 구현을 받음
enum SkillLevel: Comparable {
case beginner
case intermediate
case expert(stars: Int)
}
var levels = [SkillLevel.intermediate, SkillLevel.beginner,
SkillLevel.expert(stars: 5), SkillLevel.expert(stars: 3)]
for level in levels.sorted() {
print(level)
}
// Prints "beginner"
// Prints "intermediate"
// Prints "expert(stars: 3)"
// Prints "expert(stars: 5)"
- Swift는 아래와 같은 사용자 정의 타입에 대해 Hashable 에 합성된 구현을 제공
- Hashable 프로토콜을 준수하는 저장된 프로퍼티만 가지는 구조체
- Hashable 프로토콜을 준수하는 연관된 타입만 가지는 열거형
- 연관된 타입이 없는 열거형
- hash(into:) 에 합성된 구현을 받기 위해선 hash(into:) 메서드를 직접 구현하지 않고 원래 선언을 포함한 파일에서 Hashable 에 대한 준수성을 선언
- Swift는 원시값이 없는 열거형에 대해 Comparable 에 합성된 구현을 제공
- 열거형이 연관된 타입을 가지고 있다면 모두 Comparable 프로토콜을 준수해야 함
- < 의 합성된 구현을 받기 위해선 < 연산자를 직접 구현하지 않고 원래 열거형 선언을 포함한 파일에서 Comparable 에 대한 준수성을 선언
- <=, >, 그리고 >= 의 Comparable 프로토콜의 기본 구현은 나머지 비교 연산자를 제공
프로토콜 타입의 콜렉션
- 프로토콜은 배열 또는 딕셔너리와 같은 콜렉션에 저장되기 위해 타입으로 사용될 수 있음
let things: [TextRepresentable] = [game, d12, simonTheHamster]
for thing in things {
print(thing.textualDescription)
}
// A game of Snakes and Ladders with 25 squares
// A 12-sided dice
// A hamster named Simon
- 이제 배열에 해당타입의 프로퍼티를 넣고 반복문을 통해 각 항목의 설명을 출력
- thing 상수는 TextRepresentable 타입
- 실제 인스턴스가 Dice 또는 DiceGame 또는 Hamster 중 하나 이지만 thing은 이것들의 타입은 아님
- TextRepresentable 타입이고 TextRepresentable은 textualDescription 프로퍼티를 가지고 있다는 것을 알고 있으므로 루프를 통해 매번 thing.textualDescription 에 안전하게 접근가능
프로토콜 상속
protocol InheritingProtocol: SomeProtocol, AnotherProtocol {
// protocol definition goes here
}
- 프로토콜은 하나 또는 그 이상의 다른 프로토콜을 상속 할 수 있고 상속한 요구사항 위에 다른 요구사항 추가 가능
- 프로토콜 상속에 대한 구문은 클래스 상속에 대한 구문과 유사하지만 콤마로 구분하여 여러개의 상속된 프로토콜을 목록화
protocol PrettyTextRepresentable: TextRepresentable {
var prettyTextualDescription: String { get }
}
- PrettyTextRepresentable 을 채택하는 모든 것은 TextRepresentable 과 PrettyTextRepresentable 의 모든 요구사항을 충족해야 함
- PrettyTextRepresentable 은 String 을 반환하는 prettyTextualDescription 이라는 gettable 프로퍼티를 제공하기 위해 하나의 요구사항을 추가
extension SnakesAndLadders: PrettyTextRepresentable {
var prettyTextualDescription: String {
var output = textualDescription + ":\n"
for index in 1...finalSquare {
switch board[index] {
case let ladder where ladder > 0:
output += "▲ "
case let snake where snake < 0:
output += "▼ "
default:
output += "○ "
}
}
return output
}
}
print(game.prettyTextualDescription)
// A game of Snakes and Ladders with 25 squares:
// ○ ○ ▲ ○ ○ ▲ ○ ○ ▲ ▲ ○ ○ ○ ▼ ○ ○ ○ ○ ▼ ○ ○ ▼ ○ ▼ ○
- PrettyTextRepresentable 프로토콜을 채택하고 SnakesAndLadders 타입에 대해 prettyTextualDescription 프로퍼티의 구현을 제공
- PrettyTextRepresentable 인 모든 것은 TextRepresentable 이어야 함 (상속 받았으므로)
- prettyTextualDescription 의 구현은 출력 문자열을 시작하기 위해 TextRepresentable 프로토콜에서 textualDescription 프로퍼티를 접근하는 것으로 시작
- prettyTextualDescription 프로퍼티는 이제 모든 SnakesAndLadders 인스턴스의 텍스트 설명을 출력하기 위해 사용될 수 있음
클래스 전용 프로토콜
protocol SomeClassOnlyProtocol: AnyObject, SomeInheritedProtocol {
// class-only protocol definition goes here
}
- 프로토콜의 채택 목록에 AnyObject 프로토콜을 추가하여 구조체 또는 열거형이 아닌 클래스 타입으로 제한 가능
- SomeClassOnlyProtocol 을 구조체 또는 열거형 정의에 채택하면 컴파일 시 에러가 발생
- 프로토콜의 요구사항에 의해 정의된 동작이 준수하는 타입에 값 의미 체계가 아닌 참조 의미 체계가 있다고 가정하거나 요구하는 경우 클래스 전용 프로토콜을 사용
프로토콜 구성
- 프로토콜 구성 (protocol composition) 을 사용하여 여러 프로토콜을 단일 요구사항으로 결합할 수 있음
- 프로토콜 구성은 구성에 모든 프로토콜의 결합된 요구사항을 가진 임시 로컬 프로토콜로 정의된 것처럼 동작
- 프로토콜 구성은 새로운 프로토콜 타입을 정의하지 않음
- 프로토콜 구성은 SomeProtocol & AnotherProtocol 형식
- 앰퍼샌드 (&)로 구분하여 많은 프로토콜을 목록화 가능
- 프로토콜 목록 외에도 프로토콜 구성은 요구된 상위 클래스를 지정하는데 사용할 수 있는 하나의 클래스 타입을 포함할 수도 있음
protocol Named {
var name: String { get }
}
protocol Aged {
var age: Int { get }
}
struct Person: Named, Aged {
var name: String
var age: Int
}
func wishHappyBirthday(to celebrator: Named & Aged) {
print("Happy birthday, \(celebrator.name), you're \(celebrator.age)!")
}
let birthdayPerson = Person(name: "Malcolm", age: 21)
wishHappyBirthday(to: birthdayPerson)
// Prints "Happy birthday, Malcolm, you're 21!"
- Named와 Aged, 두 프로토콜 모두 Person 이라는 구조체에 의해 채택
- wishHappyBirthday(to:) 함수에서 celebrator 파라미터의 타입은 "Named 와 Aged 프로토콜 모두 준수하는 타입" 이라는 의미인 Named & Aged
- 요구된 프로토콜 모두 준수하는 한 함수에 전달되는 특정 유형은 중요하지 않음
- Person 은 프로토콜 모두 준수하기 때문에 이 호출은 유효하고 wishHappyBirthday(to:) 함수 동작
class Location {
var latitude: Double
var longitude: Double
init(latitude: Double, longitude: Double) {
self.latitude = latitude
self.longitude = longitude
}
}
class City: Location, Named {
var name: String
init(name: String, latitude: Double, longitude: Double) {
self.name = name
super.init(latitude: latitude, longitude: longitude)
}
}
func beginConcert(in location: Location & Named) {
print("Hello, \(location.name)!")
}
let seattle = City(name: "Seattle", latitude: 47.6, longitude: -122.3)
beginConcert(in: seattle)
// Prints "Hello, Seattle!"
- beginConcert(in:) 함수는 "Location 의 하위 클래스와 Named 프로토콜을 준수하는 모든 타입" 이라는 뜻의 Location & Named 타입의 파라미터를 가짐
- Person 은 Location 의 하위 클래스가 아니므로 beginConcert(in:) 함수로 birthdayPerson 전달은 유효하지 않음
- 마찬가지로 Named 프로토콜을 준수하지 않고 Location 의 하위 클래스를 만들어 타입의 인스턴스로 beginConcert(in:) 을 호출해도 유효하지 않음
프로토콜 준수에 대한 검사
- 프로토콜 준수성에 대해 확인하고 특정 프로토콜로 캐스팅 하기 위해 is 와 as 연산자를 사용가능
- 프로토콜을 확인하고 캐스팅하는 것은 타입을 확인하고 캐스팅 하는 것과 정확하게 같은 구문을 따름
- is 연산자는 인스턴스가 프로토콜을 준수한다면 true 를 반환하고 그렇지 않으면 false 를 반환
- as? 는 프로토콜의 타입의 옵셔널 값을 반환하고 인스턴스가 프로토콜을 준수하지 않으면 그 값은 nil
- as! 는 프로토콜 타입으로 강제로 다운 캐스팅 하고 다운 캐스트가 성공하지 못하면 런타임 에러 발생
protocol HasArea {
var area: Double { get }
}
class Circle: HasArea {
let pi = 3.1415927
var radius: Double
var area: Double { return pi * radius * radius }
init(radius: Double) { self.radius = radius }
}
class Country: HasArea {
var area: Double
init(area: Double) { self.area = area }
}
- Circle 클래스는 저장된 radius 프로퍼티 기반으로 계산된 프로퍼티로 area 프로퍼티 요구사항을 구현
- Country 클래스는 저장된 프로퍼티로 직접 area 요구사항을 구현
- 두 클래스 모두 HasArea 프로토콜을 준수
class Animal {
var legs: Int
init(legs: Int) { self.legs = legs }
}
let objects: [AnyObject] = [
Circle(radius: 2.0),
Country(area: 243_610),
Animal(legs: 4)
]
- Circle, Country 그리고 Animal 클래스는 공유된 기본 클래스가 없음
- 하지만 모두 클래스이므로 모든 세가지 타입의 인스턴스는 타입 AnyObject 의 값을 저장하는 배열을 초기화 하기위해 사용될 수 있음
- objects 배열은 2의 반지름을 가진 Circle 인스턴스, 영국의 표면적으로 초기화 된 Country 인스턴스 그리고 4개의 다리를 가진 Animal 인스턴스를 포함하는 배열 리터럴로 초기화
for object in objects {
if let objectWithArea = object as? HasArea {
print("Area is \(objectWithArea.area)")
} else {
print("Something that doesn't have an area")
}
}
// Area is 12.5663708
// Area is 243610.0
// Something that doesn't have an area
- 반복문을 통해 objects 배열의 각 객체가 HasArea 프로토콜을 준수하는지 확인 가능
- 배열의 객체가 HasArea 프로토콜을 준수할 때마다 as? 연산자에 의해 반환된 옵셔널 값은 objectWithArea 라는 상수에 옵셔널 바인딩으로 언래핑
- objectWithArea 상수는 HasArea 타입으로 알고 있으므로 area 프로퍼티는 접근 가능하고 안전하게 출력 가능
- 기본 객체는 캐스팅 프로세스에 의해 변경되지 않음
- objectWithArea 상수에 저장될 때 모두 다른 클래스이지만 HasArea 타입으로 캐스팅했기 때문에 area 프로퍼티만 접근 가능
옵셔널 프로토콜 요구사항
- 프로토콜에 대해 옵셔널 요구사항 (optional requirements) 을 정의할 수 있음
- 이 요구사항은 프로토콜을 준수하는 타입으로 구현될 필요가 없음
- 옵셔널 요구사항은 프로토콜의 정의의 부분으로 optional 수식어를 앞에 붙임
- 옵셔널 요구사항은 Objective-C와 상호운용되는 코드를 작성할 수 있음
- 프로토콜과 옵셔널 요구사항 모두 @objc 속성으로 표시되어야 함
- @objc 프로토콜은 Objective-C 클래스나 다른 @objc 클래스로 부터 상속한 클래스에만 채택될 수 있음
- 구조체나 열거형에 의해 채택될 수 없음
- 옵셔널 요구사항에서 메서드나 프로퍼티를 사용할 때 타입은 자동으로 옵셔널, 예를 들어 (Int) -> String 타입의 메서드는 ((Int) -> String)?
- 전체 함수 타입은 메서드의 반환값이 아니라 옵셔널로 래핑
- 옵셔널 프로토콜 요구사항은 프로토콜을 준수하는 타입에 의해 요구사항이 구현되지 않았을 가능성을 나타내기 위해 옵셔널 체이닝으로 호출될 수 있음
- 호출될 때 someOptionalMethod?(someArgument) 와 같이 메서드의 이름 뒤에 물음표를 작성하여 옵셔널 메서드의 구현에 대해 확인
@objc protocol CounterDataSource {
@objc optional func increment(forCount count: Int) -> Int
@objc optional var fixedIncrement: Int { get }
}
- CounterDataSource 프로토콜은 increment(forCount:) 라는 옵셔널 메서드 요구사항과 fixedIncrement 라는 옵셔널 프로퍼티 요구사항을 정의
- 이 요구사항은 Counter 인스턴스에 대해 적절한 증가값을 제공하기 위해 데이터 소스에 대한 2가지 다른 방법을 정의
- 엄밀히 말하면 프로토콜 요구사항을 구현하지 않고도 CounterDataSource 를 준수하는 사용자 정의 클래스를 작성 가능
- 둘다 옵셔널이라 기술적으로는 허용되지만 좋은 데이터 소스로는 적합합지 않음
class Counter {
var count = 0
var dataSource: CounterDataSource?
func increment() {
if let amount = dataSource?.increment?(forCount: count) {
count += amount
} else if let amount = dataSource?.fixedIncrement {
count += amount
}
}
}
- Counter 클래스는 count 라는 프로퍼티 변수에 현재값을 저장
- Counter 클래스는 메서드가 호출될 때마다 count 프로퍼티를 증가하는 increment 라는 메서드도 정의
- increment() 메서드는 먼저 데이터 소스에 increment(forCount:) 메서드의 구현을 통해 증가값을 조회
- increment() 메서드는 increment(forCount:) 호출에 대해 옵셔널 체이닝을 사용하고 메서드의 단일 인자로 현재 count 값을 전달
- dataSource 가 nil 이거나 데이터 소스가 increment(forCount:) 를 구현하지 않아 increment(forCount:) 로 부터 값을 조회할 수 없는 경우, increment() 메서드는 데이터 소스의 fixedIncrement 프로퍼티를 대신 조회 시도
- fixedIncrement 프로퍼티도 옵셔널 요구사항이므로 그 값은 fixedIncrement 가 CounterDataSource 프로토콜 정의의 부분으로 옵셔널이 아닌 Int 프로퍼티로 정의되었어도 옵셔널 Int 값
class ThreeSource: NSObject, CounterDataSource {
let fixedIncrement = 3
}
var counter = Counter()
counter.dataSource = ThreeSource()
for _ in 1...4 {
counter.increment()
print(counter.count)
}
// 3
// 6
// 9
// 12
- 새로운 Counter 인스턴스를 생성하고 데이터 소스를 새로운 ThreeSouce 인스턴스로 설정
- 카운터의 increment() 메서드를 4번 호출
- 예상대로 카운터의 count 프로퍼티는 increment() 가 호출될 때마다 3씩 증가
class TowardsZeroSource: NSObject, CounterDataSource {
func increment(forCount count: Int) -> Int {
if count == 0 {
return 0
} else if count < 0 {
return 1
} else {
return -1
}
}
}
counter.count = -4
counter.dataSource = TowardsZeroSource()
for _ in 1...5 {
counter.increment()
print(counter.count)
}
// -3
// -2
// -1
// 0
// 0
- TowardsZeroSource 클래스는 CounterDataSource 프로토콜로 부터 옵셔널 increment(forCount:) 메서드를 구현
- 카운트 방향을 정하기 위해 count 인자값을 사용
- count 가 0이면 이 메서드는 더이상 카운트 작업을 진행하지 않음을 나타내기 위해 0 을 반환
-
-4 부터 0까지 카운트 하기 위해 존재하는 Counter 인스턴스와 함께 TowardsZeroSource 의 인스턴스를 사용 가능
프로토콜 확장
- 프로토콜은 채택된 타입에 제공하기 위한 메서드, 초기화 구문, 서브 스크립트, 그리고 계산된 프로퍼티 구현을 확장 가능
- 이를 통해 각 타입의 개별 적합성 또는 전역 함수가 아닌 프로토콜 자체에 동작을 정의 가능
extension RandomNumberGenerator {
func randomBool() -> Bool {
return random() > 0.5
}
}
let generator = LinearCongruentialGenerator()
print("Here's a random number: \(generator.random())")
// Prints "Here's a random number: 0.3746499199817101"
print("And here's a random Boolean: \(generator.randomBool())")
// Prints "And here's a random Boolean: true"
- RandomNumberGenerator 프로토콜은 임의의 Bool 값을 반환하기 위해 필요한 random() 메서드의 결과를 사용하는 randomBool() 메서드를 제공하기 위해 확장
- 프로토콜 확장을 생성함으로써 모든 준수하는 타입은 추가 수정없이 메서드 구현을 자동으로 얻음
- 프로토콜 확장은 준수하는 타입에 구현을 추가할 수 있지만 프로토콜을 확장하거나 다른 프로토콜을 상속할 수 없음
- 프로토콜 상속은 항상 프로토콜 선언 자체에 지정
기본 구현 제공
- 해당 프로토콜의 모든 메서드 또는 계산된 프로퍼티 요구사항에 기본 구현을 제공하기 위해 프로토콜 확장을 사용할 수 있음
- 준수하는 타입이 필수 메서드 또는 프로퍼티의 자체 구현을 제공하면 해당 구현은 확장에 의해 제공되는 구현 대신 사용됨
- 확장에 의해 제공된 기본 구현을 가진 프로토콜 요구사항은 옵셔널 프로토콜 요구사항과 다름
- 준수하는 타입이 자체 구현을 제공할 필요는 없지만 기본 구현을 가진 요구사항은 옵셔널 체이닝 없이 호출될 수 있음
extension PrettyTextRepresentable {
var prettyTextualDescription: String {
return textualDescription
}
}
- TextRepresentable 프로토콜을 상속하는 PrettyTextRepresentable 프로토콜은 textualDescription 프로퍼티 접근의 결과를 반환하기 위해 필요한 prettyTextualDescription 프로퍼티의 기본 구현을 제공할 수 있음
프로토콜 확장에 제약사항 추가
- 프로토콜 확장을 정의할 때 확장의 메서드와 프로퍼티를 사용할 수 있기 전에 준수하는 타입이 충족해야 하는 제약조건을 지정 가능
- 일반적인 where 절을 작성하여 확장하는 프로토콜의 이름 뒤에 제약조건을 작성
extension Collection where Element: Equatable {
func allEqual() -> Bool {
for element in self {
if element != self.first {
return false
}
}
return true
}
}
- Equatable 프로토콜을 준수하는 항목의 모든 콜렉션에 적용하는 Collection 프로토콜의 확장을 정의
- 콜렉션의 요소를 표준 라이브러리의 일부인 Equatable 프로토콜로 제한하면 두 요소간의 같음과 다름에 대한 확인을 위해 == 와 != 연산자를 사용 가능
- allEqual() 메서드는 콜렉션에 모든 요소가 같을 때만 true 를 반환
- 모든 요소가 같고 하나만 다른 정수의 2개의 배열의 경우, 배열은 Collection 을 준수하고 정수는 Equatable 을 준수하므로 equalNumbers 와 differentNumbers 는 allEqual() 메서드를 사용할 수 있음
- 준수하는 타입이 같은 메서드 또는 프로퍼티에 대한 구현을 제공하는 여러 제약조건의 확장에 대한 요구사항을 충족한다면 Swift는 가장 전문화 된 제약조건에 해당하는 구현을 사용
자료 출처
https://docs.swift.org/swift-book/LanguageGuide/Protocols.html
'iOS' 카테고리의 다른 글
Swift - Extensions (0) | 2023.03.05 |
---|---|
Swift - Concurrency (동시성) (0) | 2023.02.26 |
Swift - Protocol(메서드, 초기화) (2/4) (0) | 2023.02.06 |
Swift - Protocol(정의, 프로퍼티) (1/4) (0) | 2023.02.03 |
Table View의 Self-Sizing Cell (0) | 2022.08.10 |
Comments