Getting started with SwiftUI (Stanford CS193p)
This struct behaves like a view (the struct conforms to the View
protocol)
The View
protocol requires you to have the body variable (of type ‘some View’)
struct ContentView: View {
var body: some View {
}
}
The block (curly brackets) after the variable declaration represents a function (closure) and the value it returns gets assigned to the variable body
. As long as it is a view (VStack
, Text
, Image
, etc) it is ok to assign it. (note: writing the keyword return
here is optional!)
struct ContentView: View {
var body: some View {
return Text("Hello World!")
}
}
Shapes
RoundedRectangle()
We can return the RoundedRectangle()
view specifying its cornerRadius
and calling modifier functions like .padding()
(can receive an optional argument like .horizontal
) and .stroke()
(contrary to .fill()
) to outline a shape (can receive an optional argument like lineWidth: 3
).
struct ContentView: View {
var body: some View {
RoundedRectangle(cornerRadius: 20)
.stroke()
.padding()
}
}
struct ContentView: View {
var body: some View {
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 3)
.padding(.horizontal)
.foregroundColor(.red)
}
}
Tip: You can write just .red
instead of Color.red
when defining the foreground color.
View Combiners
The ZStack()
The ZStack()
receives a closure as a parameter. (this closure is called a View Builder)
struct ContentView: View {
var body: some View {
ZStack(content: {})
}
}
The View Builder allows us to list all the views we want to combine and turns it into another view. You also don’t need to write return
inside the View Builder.
var body: some View {
ZStack(content: {
RoundedRectangle(cornerRadius: 20)
.stroke()
.padding()
Text("Hello World!")
})
}
Modifying the View Combiner
Applying a modifier function to a view combiner will apply the properties to all the view within it.
var body: some View {
ZStack(content: {
RoundedRectangle(cornerRadius: 20)
.stroke()
Text("Hello World!")
})
.padding()
.foregroundColor(.red)
}
Preview:
Setting the alignment
var body: some View {
ZStack(alignment: .top, content: {
RoundedRectangle(cornerRadius: 20)
.stroke()
Text("Hello World!")
})
.padding()
.foregroundColor(.red)
}
Preview:
Simplifying the code
When the last argument of a function is also a function you can simplify the code like this:
This code:
var body: some View {
ZStack(content: {
})
}
becomes:
var body: some View {
ZStack() {
}
}
You can also remove the parenthesis from the ZStack
var body: some View {
ZStack {
}
}
Encapsulating views
To avoid redundancy, we can get our ZStack
representing a card and turn it into a view itself.
struct CardView: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 20)
.stroke()
Text("👻")
}
}
}
And then call it form the ContentView
in a HStack
(horizontal stack):
struct ContentView: View {
var body: some View {
HStack {
CardView()
CardView()
CardView()
CardView()
}
.padding()
.foregroundColor(.red)
}
}
Creating local variables
Instead of repetitively calling RoundedRectangle()
, we can assign it to a variable (rectangle
in this case) and use it instead to make our code look cleaner.
let rectangle = RoundedRectangle(cornerRadius: 20)
rectangle
.fill()
.foregroundColor(.white)
rectangle
.stroke(lineWidth: 3)
Text("👻")
.font(.largeTitle)
Flipping the card using boolean values
Here, we created a boolean variable called isFaceUp
and initialized it to true
. Then, using the if
control flow structure, we can change what kind of view we would like to display (if it is the card with a white background and text or just a card filled in red.)
var isFaceUp = true
if isFaceUp {
rectangle
.fill()
.foregroundColor(.white)
rectangle
.stroke(lineWidth: 3)
Text("👻")
.font(.largeTitle)
} else {
rectangle
.fill()
}
Passing isFaceUp
as a parameter when calling CardView
:
HStack {
CardView(isFaceUp: true)
CardView(isFaceUp: false)
CardView(isFaceUp: true)
CardView(isFaceUp: false)
}
View Mutability and @State
If we try to add .onTapGesture()
to our ZStack
to change the value of a variable, we will get an error because all views are immutable. In this case, we want to change the value of isFaceUp
, but as it is not possible we can add @State
to it to turn it into a pointer to a boolean to somewhere in memory and whenever it changes the body
view gets rebuilt.
@State var isFaceUp = true
var body: some View {
ZStack {
}
.onTapGesture {
isFaceUp = !isFaceUp
}
}
Using dynamic text
We can create a new variable inside our CardView
called content (representing the content for the Text
) and pass the content dynamically from the ContentView
:
struct CardView: View {
var content: String
var body: some View {
ZStack {
...
Text(content)
...
}
}
}
Then from the ContentView
, let’s create an array of strings for the card contents and indexing from this array when passing the content to the CardView
struct ContentView: View {
var emojis: [String] = ["👻", "⛩️", "🔗", "🌹"]
var body: some View {
HStack {
CardView(content: emojis[0])
CardView(content: emojis[1])
CardView(content: emojis[2])
CardView(content: emojis[3])
}
}
}
But, to write it in a less methodical way we can use a ForEach
statement with emoji
being a variable representing the current item from an iteration.
ForEach(emojis) { emoji in
CardView(content: emoji)
}
But the code above will not work because to use a ForEach
we need to make each item from our array conform to the Identifiable
protocol meaning that each item must be unique and have an ID.
To solve this problem, we can add id
as a parameter to ForEach
and set it to \.self
meaning that the variable itself is going to be it’s own ID.
ForEach(emojis, id: \.self) { emoji in
CardView(content: emoji)
}
Working with ranges
Imagine we have a really long array and only want to display emojis from a certain range. We can achieve this by using the indexing syntax.
Example:
var emojis: [String] = ["🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🚚", "🚛", "🚜", "🚲", "🛴", "🛵", "🏍️", "🛺", "🚔", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🚃", "🚞", "🚝", "🚄", "🚅"]
0..<5 //Will exclude the 5
0...5 //Will include the 5
Now, let’s index emojis
inside out ForEach
to only the first 4 items:
ForEach(emojis[0..<4], id: \.self) { emoji in
CardView(content: emoji)
}
Buttons
Now we are going to add a button to add and remove cards. This is how we use a Button
in Swift:
Button(action: {}, label: {})
We can now create a variable to keep track of how many items we want to display and set it as the range when iterating through the emojis
array:
@State var emojiCount = 4
...
ForEach(emojis[0..<emojiCount], id: \.self) { emoji in
CardView(content: emoji)
}
Now, we can create 2 buttons and increment/decrement this variable (Inserting it into a horizontal stack and adding a Spacer()
in between the 2 buttons.)
HStack {
Button(action: {
emojiCount += 1
}, label: {
Text("+")
})
Spacer()
Button(action: {
emojiCount -= 1
}, label: {
Text("-")
})
}
.padding()
Now, to clean up the code we can turn the block of code for creating the Button
into a new variable of type some View
and use it instead.
var add: some View {
Button(action: {
emojiCount += 1
}, label: {
Text("+")
})
}
var remove: some View {
Button(action: {
emojiCount -= 1
}, label: {
Text("-")
})
}
And use it like this:
HStack {
add
Spacer()
remove
}
Using SFSymbols for the buttons
Syntax:
Image(systemName: "SYMBOLNAME")
Use case:
Button(action: {
emojiCount -= 1
}, label: {
Image(systemName: "minus.circle")
})
Syntactic sugar for simplifying the Button syntax
Button(action: {}, label: {})
Becomes:
Button {} label: {}
Adding limits for our ranges
When incrementing/decrementing emojiCount
, we have to be careful to not let it get to an invalid index inside our array (a value less than zero or upper to the array’s number of elements.)
var add: some View {
Button {
if (emojiCount < emojis.count) {
emojiCount += 1
}
} label: {
...
}
}
var remove: some View {
Button {
if (emojiCount > 1) {
emojiCount -= 1
}
} label: {
...
}
}
Grids
We can use a LazyVGrid
Instead of the HStack
to order our cards into a grid. (When using a LazyVGrid
we only have to specify the number of columns and all the rows are going to be created as necessary contrary to a LazyHGrid
)
LazyVGrid(columns: [GridItem(), GridItem(), GridItem()]) {
ForEach(emojis[0..<emojiCount], id: \.self) { emoji in
CardView(content: emoji)
}
}
where columns is an array of GridItem()
Setting the aspect ratio for a card
We can call .aspectRatio()
on our CardView
to set the aspect ratio like this:
CardView(content: emoji).aspectRatio(2/3, contentMode: .fit)
Sets the aspect ratio to 2 wide / 3 high.
Using a scroll view
We can wrap our LazyVGrid
into a ScrollView
to be able to scroll:
ScrollView {
LazyVGrid(columns: [GridItem(), GridItem(), GridItem()]) {
ForEach(emojis[0..<emojiCount], id: \.self) { emoji in
CardView(content: emoji).aspectRatio(2/3, contentMode: .fit)
}
}
}
Working with an adaptive grid item
We can set our array of grid items to just one GridItem()
and make it adaptive setting the minimum width for the row.
GridItem(.adaptive(minimum: 80))
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) {
ForEach(emojis[0..<emojiCount], id: \.self) { emoji in
CardView(content: emoji).aspectRatio(2/3, contentMode: .fit)
}
}
}