SwiftUI Lecture3-4 정리
30 Jun 2021MVVM(Model View ViewModel)
Model과 View, ViewModel로 나누는 방식을 MVVM 모델이라고 부른다. Data를 저장하고 그에 관련한 Logic을 포함하고 있는 Model, Data를 바탕으로 UI를 만들어주는 UI Architecture인 View, 마지막으로 둘 사이에서 징검다리처럼 연결을 해주는 ViewModel로 나뉘어지는 구조이다. 하나하나 나눠서 살펴보자.
1. Model
UI Independent한 형태로 Data와 그와 관련된 Logic으로 구성되어있다. 우리의 코드를 MVVM모델로 적용시키기 위해 만든 Model의 모습은 다음과 같다. 놀랍게도 UI Independent하기 때문에 import SwiftUI가 없는 것을 확인할 수 있다.
메모리 게임을 실행시키면 해당 게임의 카드 Set (Data) 을 저장해줘야하고 해당 카드를 클릭했을 때 카드의 속성이 변하는 것을 처리해주는 연산 함수 (Logic)가 포함되어있다. 실제로 choose함수를 보면 특정 카드를 눌렀을 때 처리할 연산이 함수형태로 구현되어있다. 놀랍게도 UI와는 전혀 무관하게 가지고 있는 card set만으로 동작한다. 우리는 어떤 카드가 선택되었는지 그 정보만 View로부터 받으면 되는 형태이다.
import Foundation
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>
private var indexOfTheOneAndOnlyFaceUpCard: Int?
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }),
!cards[chosenIndex].isFaceUp,
!cards[chosenIndex].isMatched {
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
if cards[potentialMatchIndex].content == cards[chosenIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
for index in cards.indices {
cards[index].isFaceUp = false
}
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
cards[chosenIndex].isFaceUp.toggle()
}
}
init(numberOfPairsOfCards: Int, createCardContent: (Int) -> CardContent) {
cards = Array<Card>()
// add numberOfPairsOfCards x 2 to array
for pairIndex in 0..<numberOfPairsOfCards {
let content = createCardContent(pairIndex)
cards.append(Card(content: content, id: pairIndex*2))
cards.append(Card(content: content, id: pairIndex*2+1))
}
}
struct Card: Identifiable {
var isFaceUp: Bool = false
var isMatched: Bool = false
var content: CardContent
var id: Int
}
}
구현자체는 간단하니 조금만 읽어보면 알 수 있다. Model에서 중요한 포인트가 몇가지 있는데 이를 알아보고 넘어가려고 한다.
-
Generic Type
스위프트에서는 나중에 타입을 매칭시켜주는 generic type 선언이 존재한다.
<CardContent>가 이에 해당하는데 우리가 카드의 이미지로 사용하는 값이 현재는 emoji String이지만 설정에 따라 JPEG가 될 수도 있고 다양할 것이다. 이는 ViewModel에 맡기고 Model에서는 Logic구현에 좀 더 신경을 써주는 모습이다. -
Identifiable
struct Card: Identifiable로 표현이 되어있는 것을 Card 구조체에서 확인이 가능하다. 우리가 View에서 각각의 카드를 하나의 View로 표현하기 위해서는 id가 존재해야 했다는 것을 기억할 것이다. 따라서 Card 구조체는 Identifiable하다고 선언해주고 구조체 내부에 이를 구분지어주는 id를 설정해주는 형태이다. id는 꼭 Int가 아니여도 Hashable하다면 다른 형태를 사용해도 무방하다. -
Read Only
private(set) var cards: Array<Card>로 표현된 부분을 볼 수 있다. 이 것이 우리의 Data인 Card Set을 저장해주는 배열이다. 각각의 Card들은 뒤집혀있는지, 매치가 완료되었는지, 그림 정보는 무엇인지, id는 무엇인지를 저장해주고 있다. 후에 View에서는 카드들의 해당 정보를 ViewModel을 통해 읽어와서 이를 바탕으로 UI를 그린다.그런데 해당 배열을 private으로 선언을 한다면 다른 곳에서 읽어오는 것이 불편해질 것이다. 그렇다고 public으로 선언을 하면 view에서 값을 변경해버리는 위험이 존재하기 때문에 read only 형태인 private(set) 을 사용한다.
2. ViewModel
Model과 View의 징검다리 역할을 하는 ViewModel이다. Interpreter, GateKeeper의 성질을 지니고 있다. View에서 UI를 그리려고 한다면 다음의 방식을 따른다.
- Model의 Card 정보를 알기 위해 ViewModel의 cards 변수를 호출
- ViewModel은 Model의 cards 변수를 호출
- 얻어낸 Model의 cards를 통해 UI를 그림
마찬가지로 View에서 특정 카드를 선택하는 등 변화가 생긴다면 다음의 방식을 따른다.
- ViewModel의 Logic 함수를 호출
- ViewModel은 Model의 Logic 함수를 호출
- Model에서 Logic을 통해 Data 정보를 변경
- 변경된 정보가 존재하므로 Model이 다시 UI를 그리라고 ViewModel에게 요청
- ViewModel은 View에게 UI를 다시 그리라고 요청
- 위의 UI 그리는 방식을 다시 반복하여 새로운 UI 작성
이렇듯 Model과 View는 직접 소통할 수 없고 ViewModel이 중간에서 연결시켜주는 형태로 되어있다는 것이다. Model이 정의가 되어있다면 해당 Model을 사용하면 되지만 우리의 경우는 Model을 직접 생성해주는 형태로 되어있다. 유의할 것은 해당 모델은 private으로 선언해서 View에서 직접 Model을 불러오는 일이 없도록 해야한다는 것이다.
import SwiftUI
class EmojiMemoryGame: ObservableObject {
static let emojis = ["🚖", "🛵", "🚆", "🚍", "🛺", "🛫", "🏖", "🚔", "🛸", "🚁", "🏍", "🛳", "🚐", "🚛", "🚚"]
static func createMemoryGame() -> MemoryGame<String> {
MemoryGame<String>(numberOfPairsOfCards: 3) { pairIndex in
emojis[pairIndex]
}
}
@Published private var model: MemoryGame<String> = createMemoryGame()
var cards: Array<MemoryGame<String>.Card> {
model.cards
}
// MARK: - Intent(s)
func choose(_ card: MemoryGame<String>.Card) {
//objectWillChange.send()
model.choose(card)
}
}
그런데 Model의 Data가 변경되었을 때 다시 UI를 그리도록 요청하기 위해서는 몇가지 수정이 필요하다. 바로 Observe 속성을 설정해주는 것인데 이는 해당 Model을 보고있다가 변화가 생긴다면 자동으로 View에게 다시 UI를 그리라고 요청해주는 역할을 한다. 이를 사용하기 위해서는 2가지 방식이 존재한다.
먼저 class EmojiMemoryGame: ObservableObject와 같이 ObservableObject로 ViewModel을 선언해주어야한다. 그리고 나중에 View Struct 안에서 ViewModel을 선언해줄 때 @ObservedObject var viewModel: EmojiMemoryGame 형태로 @ObservedObject임을 명시해줘야한다. 이는 해당 ViewModel을 지속적으로 관찰해서 요청이 들어오면 UI Update를 하겠다는 의미이다.
두번째로는 Model에서 요청이 들어왔을 때 이를 ViewModel이 감시해야한다는 것인데 Model 자체를 @Published로 선언해서 변화가 생길 때마다 UI를 새로 그리도록 할 수도 있고 choose function의 objectWillChange.send()를 통해서 필요할 때 Update를 하도록 직접 명시해줄 수도 있다.
3. View
마지막 파트인 View이다. View에는 되도록 @State선언과 같이 데이터를 직접 조작하는 일이 없도록 구현해주는 것이 좋다. 모든 정보는 Model에 저장되어 있는 정보를 ViewModel을 통해 호출하는 형식으로 구현한다. 위에서 봤듯이 @ObservedObject로 ViewModel을 선언해주는 것을 잊지말자.
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel: EmojiMemoryGame
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))]) {
ForEach(viewModel.cards){ card in
CardView(card: card)
.aspectRatio(2/3, contentMode: .fit)
.onTapGesture {
viewModel.choose(card)
}
}
}
}
.foregroundColor(.red)
.padding(.horizontal)
}
}
struct CardView: View {
let card: MemoryGame<String>.Card
var body: some View {
ZStack {
let shape = RoundedRectangle(cornerRadius: 20.0)
if card.isFaceUp {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 3.0)
Text(card.content).font(.largeTitle)
} else if card.isMatched {
shape.opacity(0)
} else {
shape.fill()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let game = EmojiMemoryGame()
ContentView(viewModel: game)
.preferredColorScheme(.dark)
ContentView(viewModel: game)
.preferredColorScheme(.light)
}
}