This article is related notes for the 2nd episode of Stanford University's CS193p course.

cs193p class:

The lectures for the Spring 2023 version of Stanford University's course CS193p (Developing Applications for iOS using SwiftUI) were given in person but, unfortunately, were not video recorded. However, we did capture the laptop screen of the presentations and demos as well as the associated audio. You can watch these screen captures using the links below. You'll also find links to supporting material that was distributed to students during the quarter (homework, demo code, etc.).

cs193p class website: https://cs193p.sites.stanford.edu/2023


Continue Creating Card

some View

If we have a demo code like below:

struct ContentView: View {
    var body: some View {
        Text("Hello")
    }
}

It would also works:

struct ContentView: View {
    var body: Text {
        Text("Hello")
    }
}

But if we not put something that not a text, we will get errors from complier: (Cannot convert return expression of type 'VStack<TupleView<(Text, Text, Text)>>' to return type 'Text')

struct ContentView: View {
    var body: Text {
        VStack {
            Text("Hello")
            Text("Hello")
            Text("Hello")
        }
        
    }
}

Thus, if we use some View, the compiler will work for different types for us.

Trailing closure syntax

Take a look at the VStack, we have content as an argument.

struct CardView: View {
    var isFaceUp: Bool = false
    var body: some View {
        ZStack(alignment: .top, content: {
            // ZStack Code
        })
    }
}

If the last argument to a function is a function itself, then we can remove its label and close the function.

struct CardView: View {
    var isFaceUp: Bool = false
    var body: some View {
        ZStack(alignment: .top) {
            // ZStack Code
        }
    }
}

RoundedRectangle

If we don't specify the shape to do things like stroke, the default will fill.

RoundedRectangle(cornerRadius: 12)
// These two codes are identical in terms of functionality.
RoundedRectangle(cornerRadius: 12).fill()

Local Variable

We can create a local variable:

struct CardView: View {
    var isFaceUp: Bool = false
    var body: some View {
        ZStack {
            if isFaceUp {
                RoundedRectangle(cornerRadius: 12).fill(.white)
                RoundedRectangle(cornerRadius: 12).strokeBorder(lineWidth: 2)
                Text("👻").font(.largeTitle)
            } else {
                RoundedRectangle(cornerRadius: 12).fill()
            }
        }
    }
}

Created local variable called base:

struct CardView: View {
    var isFaceUp: Bool = false
    var body: some View {
        ZStack {
            let base: RoundedRectangle = RoundedRectangle(cornerRadius: 12)
            if isFaceUp {
                base.fill(.white)
                base.strokeBorder(lineWidth: 2)
                Text("👻").font(.largeTitle)
            } else {
                base.fill()
            }
        }
    }
}

IMPORTANT: We use let instead of var, because the variable cannot be changed. (let means constant.)

Type Inference

We can omit the type to let swift decide.

// Without omit the type
let base: RoundedRectangle = RoundedRectangle(cornerRadius: 12)
// Omited the type (using type inference)
let base = RoundedRectangle(cornerRadius: 12)

If we hold the option key and click the base, Swift will show what the variable infered to.

type-inference

Note: We almost never specify the types in real coding.

.onTapGesture

Single tap:

struct CardView: View {
    @State var isFaceUp = true
    var body: some View {
        ZStack {
            // ZStack Code
        }
        .onTapGesture {
            isFaceUp.toggle()
        }
    }
}

double tap:

struct CardView: View {
    @State var isFaceUp = true
    var body: some View {
        ZStack {
            // ZStack Code
        }
        .onTapGesture(count: 2) {
            isFaceUp.toggle()
        }
    }
}

@State

Normally, a var is immutable (cannot be changed once assigned when called). @State allows var has temporary state. How it works? @State actually creates a pointer pointing to a piece of memory, so the pointer itself will never change. The pointer points to can be changed.

@State var isFaceUp = true

Array

There are two ways to create an array in Swift:

// A valid array notation
let emojis: Array<String> = ["👻", "🎃", "🕷️", "😈"]
// Alternate array notation
let emojis: [String] = ["👻", "🎃", "🕷️", "😈"]

Of course, we can use type inference to create an array:

let emojis = ["👻", "🎃", "🕷️", "😈"]

ForEach loop

ForEach not including last number:

// iterate from 0 to 3 (NOT including 4)
ForEach(0..<4, id: \.self) { index in
    CardView(content: emojis[index])
}

ForEach including last number:

// iterate from 0 to 4 (including 4)
ForEach(0...4, id: \.self) { index in
    CardView(content: emojis[index])
}

ForEach iterate an array without hard code the indices:

struct ContentView: View {
    let emojis = ["👻", "🎃", "🕷️", "😈"]
    var body: some View {
        HStack {
            ForEach(emojis.indices, id: \.self) { index in
                CardView(content: emojis[index])
            }
        }
        .foregroundColor(.orange)
        .padding()
    }
}

Button

Text Button

Code Structure:

Button("Remove card") {
    // action
}

Example:

struct ContentView: View {
    let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
    @State var cardCount = 4
    
    var body: some View {
        VStack {
            HStack {
                ForEach(0..<cardCount, id: \.self) { index in
                    CardView(content: emojis[index])
                }
            }
            .foregroundColor(.orange)
            HStack {
                Button("Remove card") {
                    cardCount -= 1
                }
                Spacer()
                Button("Add card") {
                    cardCount += 1
                }
            }
        }
        .padding()
            
    }
}

text-button

Icon Button

Code Structure:

Button(action: {
    // action
}, label: {
    // button icon, images, etc...
})

Example:

struct ContentView: View {
    let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
    @State var cardCount = 4
    
    var body: some View {
        VStack {
            HStack {
                ForEach(0..<cardCount, id: \.self) { index in
                    CardView(content: emojis[index])
                }
            }
            .foregroundColor(.orange)
            HStack {
                Button(action: {
                    cardCount -= 1
                }, label: {
                    Image(systemName: "rectangle.stack.badge.minus.fill")
                })
                Spacer()
                Button(action: {
                    cardCount += 1
                }, label: {
                    Image(systemName: "rectangle.stack.badge.plus.fill")
                })
            }
            .imageScale(.large)
        }
        .padding()
            
    }
}

icon-button

Out of Index Issue

If we add too many cards, the code will crash do to the array out of bound. One way to prevent this, is to add an if statement.

Button(action: {
    if cardCount < emojis.count {
        cardCount += 1
    }
}, label: {
    Image(systemName: "rectangle.stack.badge.plus.fill")
})

The other solution is to use .disabled View modifier,

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)
}
Note: We introduced function later in this class.

Organize Code

Let's take a look our body code right now,

struct ContentView: View {
    let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
    @State var cardCount = 4
    
    var body: some View {
        VStack {
            HStack {
                ForEach(0..<cardCount, id: \.self) { index in
                    CardView(content: emojis[index])
                }
            }
            .foregroundColor(.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.badge.plus.fill")
                })
            }
            .imageScale(.large)
        }
        .padding()
            
    }
}

It looks so unorginzed right now. It turns out we can create some other Views to make the code cleaner and readable.

struct ContentView: View {
    let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
    @State var cardCount = 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])
            }
        }
        .foregroundColor(.orange)
    }
    
    var cardCountAdjusters: some View {
        HStack {
            cardRemover
            Spacer()
            cardAdder
        }
        .imageScale(.large)
    }
    
    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.badge.plus.fill")
        })
    }
}

Now, our body code looks more understandable.

organized-code

Implicit return

If a normal function has only 1 line of code, we can do a implicit return.

var cards: some View {
    HStack {
        ForEach(0..<cardCount, id: \.self) { index in
            CardView(content: emojis[index])
        }
    }
    .foregroundColor(.orange)
}

If we want to write a return, we can do,

var cards: some View {
    return HStack {
        ForEach(0..<cardCount, id: \.self) { index in
            CardView(content: emojis[index])
        }
    }
    .foregroundColor(.orange)
}

Function

Structure:

func <function name>(<para name>: <data type>) -> <return type> {
    // function code  
}

Example:

func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
    Button(action: {
        cardCount += offset
    }, label: {
        Image(systemName: symbol)
    })
}

IMPORTANT: by offset: Int We have two labels sometimes, the first one (aka by) is for callers use, and the second one (aka offset) we use inside the function. The first label is called external parameter name, and the second label is called internal parameter name.

Now, our code looks even better than before,

func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
    Button(action: {
        cardCount += offset
    }, label: {
        Image(systemName: symbol)
    })
}

var cardRemover: some View {
    return cardCountAdjuster(by: -1, symbol: "rectangle.stack.badge.minus.fill")
}

var cardAdder: some View {
    return cardCountAdjuster(by: 1, symbol: "rectangle.stack.badge.plus.fill")
}
Note: We are totally lost out of index protection here, but we talked about how to deal with it in Out of Index Issue section previously.

cardCountAdjuster-function

LazyVGrid

In order to change these tall cards looks normal, we can use LazyVGrid instead of HStack.

var cards: some View {
    LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
        ForEach(0..<cardCount, id: \.self) { index in
            CardView(content: emojis[index])
        }
    }
    .foregroundColor(.orange)
}

We also need to put a spacer between cards and cardCountAdjusters, so it won't squzze together.

var body: some View {
    VStack {
        cards
        Spacer()
        cardCountAdjusters
    }
    .padding()
}

Also, because LazyVGrid uses as little space as possible, the card squeezes together when two cards being filped.

lazyvgrid-issue

.opacity

So, we need to change our CardView logics,

struct CardView: View {
    let content: String
    @State var isFaceUp = true
    var body: some View {
        ZStack {
            let base = RoundedRectangle(cornerRadius: 12)
            Group {
                base.foregroundColor(.white)
                base.strokeBorder(lineWidth: 2)
                Text(content).font(.largeTitle)
            }
            .opacity(isFaceUp ? 1 : 0)
            base.fill().opacity(isFaceUp ? 0 : 1)
        }
        .onTapGesture {
            isFaceUp.toggle()
        }
    }
}

Now, it works great!

solve-lazyvgrid-issue-use-opacity

.aspectRatio

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)
        }
    }
    .foregroundColor(.orange)
}

aspectRatio

ScrollView

var body: some View {
    VStack {
        ScrollView {
            cards
        }
        Spacer()
        cardCountAdjusters
    }
    .padding()
}