Stanford cs193p More SwiftUI
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.
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()
}
}
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()
}
}
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.
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.
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.
.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!
.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)
}
ScrollView
var body: some View {
VStack {
ScrollView {
cards
}
Spacer()
cardCountAdjusters
}
.padding()
}
Comments are disabled.