ContentView
struct ContentView: View { ... }
다시 이야기 하지만 View 는 객체지향(OOP)의 슈퍼클래스가 아니다. 구조체에서는 View 처럼 작동한다는 의미이다.
struct ContentView: View {
var body: some View {
Text(“hello")
기존의 ContentView 안의 코드를 다 삭제하고 Text(“hello”) 만 입력하니 문제없이 작동합니다. 다시 var body 부분을 아래와 같이 수정해도 잘 작동합니다.
struct ContentView: View {
var body: Text {
Text(“hello")
하지만 다시 이 문장을 수정하면
에러에 나온 문구대로 VStack은 두개의 Text를 가진 TupleView를 Text 타입으로 리턴할 수 없다는 말입니다. 이를 위해 다시 아래와 같이 수정합니다.
struct ContentView: View {
var body: some View {
VStack {
Text("hello")
Text("there")
}
Trailing closure syntax
struct CardView: View {
var isFaceUp: Bool = false
var body: some View {
ZStack (alignment: .top, content: {
이 때의 ZStack은 두 인수를 가질 수 있으며 레이블인 content는 마지막 레이블인 content는 생략가능하다.
struct CardView: View {
var isFaceUp: Bool = false
var body: some View {
ZStack {
if isFaceUp {RoundedRectangle(cornerRadius: 12)
.foregroundColor(.white)
RoundedRectangle(cornerRadius: 12)
.strokeBorder(style: StrokeStyle(lineWidth: 2))
Text("👻").font(.largeTitle)
} else {
RoundedRectangle(cornerRadius: 12)
}
}
}
}
ZStack( ) { … } 구문을 ZStack { … } 으로 간소화하였다. 이는 후행 클로저 구문이 있는 경우에만 수행할 수 있다. 즉 함수 호출이나 구조체 생성의 빈 괄호는 삭제할 수 없다.
if isFaceUp {RoundedRectangle(cornerRadius: 12)
.fill(.white)
RoundedRectangle(cornerRadius: 12).fill()
fill( )은 기본값으로 직사각형을 생성할 때 입력하지 않아도 적용된다.
CardView에서 isFaceUp 변수의 기본값을 삭제 하였다. ContentView에서 CardView를 생성시 변수의 값을 입력해야 한다.
CardView(isFaceUp: true)
CardView(isFaceUp: true)
CardView(isFaceUp: true)
CardView(isFaceUp: true)
Locals in @ViewBuilder
RoundedRectangle 라는 중복된 구조체를 ViewBuilder 인 ZStack에 하나의 로컬 변수로 만들어 보면
struct CardView: View {
var isFaceUp: Bool = true
var body: some View {
ZStack {
var base: RoundedRectangle = RoundedRectangle(cornerRadius: 12)
if isFaceUp { base.fill(.white)
base.strokeBorder(style: StrokeStyle(lineWidth: 2))
Text("👻").font(.largeTitle)
} else {
base.fill()
}
}
}
}
즉 뷰 빌더 안에는 IF, Switch문, 변수에 값을 할당하는 식이 전부이다.
let vs var
var base: RoundedRectangle = RoundedRectangle(cornerRadius: 12)
Base라는 변수는 이 프로젝트에서 값이 변할 필요가 없다. 그래서 var 에서 let으로 수정가능하다.
만일 var isFaceUp를 상수인 let으로 설정하면 위와 같은 에러가 발생한다. 만일 값을 정하지 않고 let으로 설정하면 작동하는데 문제가 없다.
let isFaceUp: Bool
하지만 우리의 프로젝트에서는 카드의 앞면 뒷면이 변해야 하므로 var isFaceUp: Bool = false 를 사용하겠습니다.
Type Inference
var base: RoundedRectangle = RoundedRectangle(cornerRadius: 12)
변수명인 base뒤에 나오는 RoundedRectangle은 변수의 타입을 나타냅니다. 이것은 유형 추론이라 해서 생략이 가능합니다.
var base = RoundedRectangle(cornerRadius: 12)
옵션키를 누른 상태에서 base의 타입을 확인 보면 Roundedrectangle 이라는 것을 확인 할 수 있습니다.
마찬가지로 var isFaceUp = false 로 수정하겠습니다.
onTapGesture
이제는 카드를 탭 했을 때 카드의 앞뒷면이 변경 되도록 하겠습니다. 이 때에 사용되는것이 ViewModifier인 onTapGesture 입니다.
.onTapGesture(perform: { ... })
Perform은 후행 클로저 이므로 아래와 같이 수정가능하다.
struct CardView: View {
var isFaceUp: Bool = false
var body: some View {
ZStack {
var base = RoundedRectangle(cornerRadius: 12)
if isFaceUp { base.fill(.white)
base.strokeBorder(style: StrokeStyle(lineWidth: 2))
Text("👻").font(.largeTitle)
} else {
base.fill()
}
}.onTapGesture(perform: {
})
}
}
만일 더 두번 탭 할 경우 실행되는 함수를 만들려면 아래와 같이 count 인수를 사용한다.
.onTapGesture(count: 2) { print("Detect double tap") }
onTapGesture는 클로저를 가지고 있는 이 안에는 x = x+ 1 같은 일반적인 코드를 넣을 수 있다. 즉 ViewBuilder가 아니다.
Views are immutable, @State
이제는 onTapGesture가 작동하도록 해 보겠습니다.
isFaceUp 의 부정을 다시 isFaceUp에 assign 하였으나 self가 변경 불가능하다고 나오네요. 이때의 self는 CardView 즉 View가 되겠죠. 일반적인 언어에는 가능한데. 그럼 어떻게 해야 할까요?
아래와 같이 @State 키워드를 붙이는 것입니다. 이는 포인터로 작은 메모리 공간을 할당받으며 주소는 변하지 않습니다.
@State var isFaceUp: Bool = false
이제 잘 작동하는 군요. 좀 더 세련된 메서드를 사용해 볼까요?
}.onTapGesture {
isFaceUp.toggle()
}
isFaceUp은 Bool 형태의 구조체로 toggle이라는 메서드를 가지고 있습니다. 이 toggle 는 true를 false로 false를 true로 바꾸는 역활을 합니다.
다시 말하지만 뷰를 변경 불가능 합니다. 이는 뷰 자체의 변수는 변경할 수 없다는 것입니다. 하지만 @State를 임시상태 라고 말하겠습니다.
그럼 카드의 모양이 고스트만 되어 있으니 카드게임이 재미가 없겠네요. 이를 위해 다양한 이모지를 적용해 봅니다.
struct ContentView: View {
var body: some View {
HStack {
CardView(content: "💀")
CardView(content: "👽")
CardView(content: "👿", isFaceUp: true)
CardView(content: "👻", isFaceUp: true)
}
.foregroundStyle(.orange)
.padding()
}
}
struct CardView: View {
let content: String
@State var isFaceUp: Bool = false
var body: some View {
ZStack {
var base = RoundedRectangle(cornerRadius: 12)
if isFaceUp { base.fill(.white)
base.strokeBorder(style: StrokeStyle(lineWidth: 2))
Text(content).font(.largeTitle)
} else {
base.fill()
}
}.onTapGesture {
isFaceUp.toggle()
}
}
}
그런데 이모지를 넣는 방법이 허접하다고 느껴지네요. Array를 쓰면 어떨까요?
Array
struct ContentView: View {
// let emojis: Array<String> = ["💀", "👽", "👿", "👻"] -- 1
// let emojis: [String] = ["💀", "👽", "👿", "👻"] -- 2
let emojis = ["💀", "👽", "👿", "👻"]
var body: some View {
HStack {
CardView(content: emojis[0])
CardView(content: emojis[1])
CardView(content: emojis[2], isFaceUp: true)
CardView(content: emojis[3], isFaceUp: true)
}
.foregroundStyle(.orange)
.padding()
}
}
위의 코드 1번과 2번으로 배열을 정의할 수 있지만 Swift에서 지원하는 타입추론으로 배열의 타입을 생략하여 간소화 할수 있습니다.
하지만 CardView(content…..) 식으로 카드를 배치하면 코드가 엄청 늘어나게 됩니다. 새로운 이모지를 적용할 때 마다 하나의 구조체를 만들어야 하군요. 이를 위해 Swift는 ForEach 라는 구문을 제공합니다. 저번에 말씀 드렸드시 ViewBuilder에서는 If, 변수나 상수 정의와 같은 부분만 허용되고 for문은 제공되지 않는것을 유의 하세요.
Arguments to closures
ForEach(0..<4, id: \.self) { index in }
위의 코드를 보면 forEach 함수는 0에서 부터 3까지 작동하며, (id: \.self 는 나중에 다룸) 인수로 index를 가질 수 있음을 뜻합니다. 이 때의 index는 범위는 0 부터 3까지 입니다. forEach 구문 또한 ViewBuilder가 됨을 확인 하시길 바랍니다.
ForEach(0..<4, id: \.self) { index in
CardView(content: emojis[index]) }
배열의 인덱스 범위 나타내는 indices를 사용하면 되겠네요.
ForEach(emojis.indices, id: \.self) { index in
CardView(content: emojis[index]) }
Button
iOS에서는 일반적으로 파란색 택스트가 들어가는게 버튼입니다. 버튼을 생성하기전 먼저 이모지의 갯수를 늘리고 표시되는 카드의 갯수를 제어할 수 있도록 아래와 같이 수정합니다. 먼저 카드의 갯수는 4부터 시작하고 해당 변수를 증가시키는 버튼을 추가하겠습니다.
struct ContentView: View {
// let emojis: Array<String> = ["💀", "👽", "👿", "👻"] -- 1
// let emojis: [String] = ["💀", "👽", "👿", "👻"] -- 2
let emojis = ["💀", "👽", "👿", "👻", "😼", "👹", "👺", "😈", "🤖", "🎃", "💩"]
var cardCount: Int = 4
var body: some View {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
.padding()
}
}
그런데 cardCount += 1 부분에 에러가 나네요. 마찬가지로 @State 부분을 추가하면 아래와 같이 잘 작동하는것을 알 수 있습니다. Add card 버튼을 누를 때 마다 카드가 추가됩니다.
struct ContentView: View {
// let emojis: Array<String> = ["💀", "👽", "👿", "👻"] -- 1
// let emojis: [String] = ["💀", "👽", "👿", "👻"] -- 2
let emojis = ["💀", "👽", "👿", "👻", "😼", "👹", "👺", "😈", "🤖", "🎃", "💩"]
@State var cardCount: Int = 4
var body: some View {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
Button("Add Card") {
cardCount += 1
}
}
.foregroundStyle(.orange)
.padding()
}
}
카드 갯수를 줄이는 버튼을 넣어보면
Button("Remove Card") { cardCount -= 1 }
좀 더 코드를 수정하여 버튼을 재배치 해보면
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
Button("Remove Card") {
cardCount -= 1
}
Button("Add Card") {
cardCount += 1
}
}
.foregroundStyle(.orange)
.padding()
}
좀 더 버튼을 나란히 만들어 봅니다.
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
HStack {
Button("Remove Card") {
cardCount -= 1
}
Button("Add Card") {
cardCount += 1
}
}
}
.foregroundStyle(.orange)
.padding()
}
한발 더 나아갑니다. Spacer( ) 를 두어 버튼의 간격을 설정하고 버튼의 색을 기본값(하늘색)으로 하면
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
HStack {
Button("Remove Card") {
cardCount -= 1
}
Spacer()
Button("Add Card") {
cardCount += 1
}
}
}
.padding()
}
이제는 새로운 버튼을 추가할 것입니다. 이는 버튼을 누를 때 동작하는 action 함수와 ViewBuilder 인 label 함수를 가집니다. Action 함수에는 일반적인 코드를 넣을 수 있으며, Label에는 유연하고 복잡한 뷰를 만들 수 있는 장점이 있습니다.
Button(action: { }, label: { })
이제 Text로 되어 있는 버튼을 이미지로 되어 있는 버튼으로 만들어 봅시다.
HStack {
Button(action: {
cardCount -= 1
}, label: {
Image(systemName: "globe")
})
Button("Remove Card") {
cardCount -= 1
}
Spacer()
Button("Add Card") {
cardCount += 1
}
}
Globe 부분을 선택하고 아래와 같이 “+”버튼을 누르면 Symbols가 나타납니다. 원하는 이미지 검색하면 됩니다.
버튼이미지를 좀 더 크게 하기 위해 HStack 에 설정값을 주면
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
HStack {
Button(action: {
cardCount -= 1
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
Spacer()
Button(action: {
cardCount += 1
}, label: {
Image(systemName: "rectangle.stack.fill.badge.plus")
})
}
.imageScale(.large)
.font(.largeTitle)
}
.padding()
}
그런데 우리의 앱에는 문제가 있네요. “-“ 버튼을 계속 누르면 아래와 같이 됩니다. 이 상태에서 한번 더 마이너스 버튼을 누르면 크래쉬가 되죠.
이를 수정하면 아래와 같습니다.
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
HStack {
Button(action: {
if cardCount > 1 {
cardCount -= 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
Spacer()
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.fill.badge.plus")
})
}
.imageScale(.large)
.font(.largeTitle)
}
.padding()
}
마이너스 부분은 카드가 1 이하로 숫자가 줄어 드는 것을 방지하였고 플러스 부분은 카드의 갯수가 이모지의 갯수를 초과할 수 없도록 if문으로 구성했습니다.
그런데 var body code가 너무 복잡합니다. 이를 캡슐화하여 읽기 좋도록 구성해 보겠습니다.
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
HStack {
cardRemover
Spacer()
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.fill.badge.plus")
})
}
.imageScale(.large)
.font(.largeTitle)
}
.padding()
}
var cardRemover: some View {
Button(action: {
if cardCount > 1 {
cardCount -= 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
}
아! cardRemover를 변수로 하여 하나의 프로퍼티를 만들어 카드를 제거하는 로직부분을 옮기고, HStack에는 프로퍼티를 호출하도록 했습니다. 또 같은 방법으로 cardAdder를 구성하면 아래와 같습니다.
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
HStack {
cardRemover
Spacer()
cardAdder
}
.imageScale(.large)
.font(.largeTitle)
}
.padding()
}
var cardRemover: some View {
Button(action: {
if cardCount > 1 {
cardCount -= 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
}
var cardAdder: some View {
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.fill.badge.plus")
})
}
좀 더 나아가 보겠습니다. ForEach 구문이 신경쓰이군요. 수정한 코드를 부분을 보면 아래와 같습니다.
var body: some View {
VStack {
cards
HStack {
cardRemover
Spacer()
cardAdder
}
.imageScale(.large)
.font(.largeTitle)
}
.padding()
}
var cards: some View {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
}
var cardRemover: some View {
Button(action: {
if cardCount > 1 {
cardCount -= 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
}
var cardAdder: some View {
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.fill.badge.plus")
})
}
죄송하지만 마지막으로 한 단계를 더 줄일 께요. cardcountAdjusters 변수를 추가합니다. ContentView의 전체내용은 아래와 같습니다.
struct ContentView: View {
// let emojis: Array<String> = ["💀", "👽", "👿", "👻"] -- 1
// let emojis: [String] = ["💀", "👽", "👿", "👻"] -- 2
let emojis = ["💀", "👽", "👿", "👻", "😼", "👹", "👺", "😈", "🤖", "🎃", "💩"]
@State var cardCount: Int = 4
var body: some View {
VStack {
cards
cardCountAdjusters
}
.padding()
}
var cards: some View {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
}
var cardCountAdjusters: some View {
HStack {
cardRemover
Spacer()
cardAdder
}
.imageScale(.large)
.font(.largeTitle)
}
var cardRemover: some View {
Button(action: {
if cardCount > 1 {
cardCount -= 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
}
var cardAdder: some View {
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.fill.badge.plus")
})
}
}
이제 var body 가 불러오는 변수가 명확해 졌고 아주 가벼워 졌습니다. Var body는 card의 정보를 읽어오고, card의 추가, 삭제하는 로직과 버튼을 읽어 옵니다.
Implicit return
아래의 코드를 보시면 왜? Return HStack { … } 이라고 하는 지가 궁금할 것 입니다. 이 HStack에 있는 compute property는 인라인 함수이므로 한 라인이라고 가정해서 생략가능합니다. Swift 문법에서는 한 라인은 생략가능하는 것을 알 수 있습니다. 이것을 암시적 반환이라고 합니다.
var cards: some View {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
}
그런데 굳이 카드를 추가하거나 제거하는 변수(compute property)를 별도로 만들 필요가 있을까요? 그래서 이제 함수를 사용해 볼려고 합니다.
func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
Button(action: {
cardCount += offset
}, label: {
Image(systemName: symbol)
})
}
위와 같이 cardCountAdjuster 함수를 추가했습니다. 이 함수는 2개의 인수를 받는데 offset에서는 -1 혹은 +1을 받고, symbol 에는 + 혹은 – 버튼의 이미지를 받습니다.
그럼 이제 cardRemover와 cardAdder 에 적용을 해 보면
var cardRemover: some View {
cardCountAdjuster(by: -1, symbol: "rectangle.stack.badge.minus.fill")
}
var cardAdder: some View {
cardCountAdjuster(by: +1, symbol: "rectangle.stack.badge.plus.fill")
}
하지만 여전히 – 버튼을 누를 때 카드가 0개 이하 일 때 크래쉬가 발생하므로 cardCountAdjuster에 ViewModifier인 .disabled( )를 적용하면
func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
Button(action: {
cardCount += offset
}, label: {
Image(systemName: symbol)
})
.disabled(cardCount + offset < 1 || cardCount + offset > emojis.count)
}
LazyVGrid
이제는 카드를 보기 좋게 정렬하는 방법을 알아보겠습니다. 그래서 LazyVGrid 라는 것을 씁니다. 아래의 코드는 columns은 인수로 Int값이 아닌 GridItem()을 가집니다. 즉 GridItem이 3개 일떼 3열이 되는것 입니다. 그리고 플러스 버튼과 마이너스 버튼이 완벽히 작동하는 것을 알 수 있습니다.
var cards: some View {
LazyVGrid(columns: [GridItem(), GridItem(), GridItem()]) {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
}
하지만 LazyvGrid는 가능한 한 적은 공간을 사용하는 방면 HStack은 가능한 한 큰 공간을 차지합니다. 일단 카드와 버튼사이에 공간을 의미하는 Spacer()를 둡니다. 그런 다음 GridItem의 너비의 최소값을 지정합니다.
var body: some View {
VStack {
cards
Spacer()
cardCountAdjusters
}
.padding()
}
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundStyle(.orange)
}
불행하게도 카드를 탭하는 순간 아래와 같은 화면이 됩니다. 이는 카드를 뒤집는 순간 이모지가 없기 때문에 최대한 작게 만드는 LazyGrid의 습성 때문입니다.
Opacity
이 문제를 해결하기 위해 opacity를 사용해 보겠습니다. 아래의 코드는 HStack을 대신하여 Group을 사용하였습니다. Group은 HStack이나 VStack는 10개 내외의 뷰로 구성가능하나 Group일 경우에는 거의 무한대까지 가능합니다.
struct CardView: View {
let content: String
@State var isFaceUp: Bool = true
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: 12)
Group {
base.fill(.white)
base.strokeBorder(style: StrokeStyle(lineWidth: 2))
Text(content).font(.largeTitle)
}
.opacity(isFaceUp ? 1 : 0)
base.fill().opacity(isFaceUp ? 0 : 1)
}.onTapGesture {
isFaceUp.toggle()
}
}
}
또한 위의 코드 중 opacity를 두번이나 쓴 이유에 대해서는 좀 더 알아보고 설명 드리겠습니다.
base.fill().opacity(isFaceUp ? 0 : 1)
만 입력한 경우 오른쪽 카드 증가 버튼이 비활성화 되는 경우가 있음.
좀 더 나아가서 카드를 좀 더 카드 답게 보이도록 만들겠습니다.
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
.aspectRatio(2/3, contentMode: .fit)
}
}
.foregroundStyle(.orange)
}
하지만 추가 버튼을 누를 때 버튼의 범위가 넘어서는 군요.
이를 방지하기 위해 ScollView를 body안에 두면 강의 3는 끝납니다.
var body: some View {
VStack {
ScrollView {
cards
}
Spacer()
cardCountAdjusters
}
.padding()
}