- Lecture 1: Introduction to SwiftUI and View Basics
- Lecture 2: MVVM and SwiftUI Essentials
- Lecture 3: Architecture in Swift (MVVM)
- Lecture 4: Memory Game in Swift
- Lecture 5: Enums and Optionals
- Lecture 6: Layout in SwiftUI
- Lecture 7: Drawing, Animating, and View Modifiers in SwiftUI
- Lecture 8: Property Observers and Animation in Swift
some View
allows for any struct to be returned as long as it conforms to the View protocol.@ViewBuilder
combines multiple views and returns a single View.- Expressions are forbidden.
@ViewBuilder
var myView: some View {
Image(systemName: "globe")
Text("some text")
}
- Trailing closures (last argument is a closure).
ZStack(alignment: .top) {
Text("Hello")
}
- With a
@State
var, SwiftUI will keep note of changes and redraw the UI. - In a
LazyVGrid
views are only created when SwiftUI needs to display them.
- MVVM: Model-View-ViewModel
- Connecting the Model to the UI
- Swift Types
- Struct & Class
- Generics in Swift
- Protocols
- Functions as Types
- Closures
- Memory Management: ARC vs Garbage Collection
- MVVM is a design paradigm used to separate concerns between data (Model), the logic connecting data to the UI (ViewModel), and the UI itself (View).
- The Model is where the logic and data live. It could be a
struct
, an SQL database, machine learning code, a REST API, or any combination. - The UI is a "parametizable" shell that the Model feeds and brings to life. The UI represents the Model visually.
- Swift ensures the UI is updated when the Model changes.
-
There are multiple ways to connect the Model to the UI:
- The Model could be a simple
@State
in a View (minimal separation). - The Model might be accessed via a "ViewModel" class (full separation).
- The Model may still be accessible directly, but through a ViewModel (partial separation).
- The Model could be a simple
-
In practice:
- Simple data models with little logic may use approach 1.
- Complex models combining various elements (e.g., SQL, structs) will likely use approach 2.
- For now, always use approach 2: full separation via a ViewModel.
- Struct
- Class
- Protocol
- Generics ("don't care" types)
- enum
- Functions
- Both have stored variables (
vars
) and constants (lets
), as well as computed properties.let defaultColor = Color.orange CardView().foregroundColor(defaultColor)
- Both can have functions.
func multiply(operand: Int, by: Int) -> Int { return operand * by } multiply(operand: 5, by: 6)
- Both have initializers.
struct RoundedRectangle { init(cornerRadius: CGFloat) { } init(cornerSize: CGSize) { } }
- In the MemoryGame struct:
struct MemoryGame { init(numberOfPairsOfCards: Int) { // Initialize game with that many pairs of cards } }
-
Structs:
- Value types (storage is right where it's used).
- Copied when passed or assigned.
- "Free" initializer initializes all variables.
- No inheritance.
- Stored on the stack.
- Mutability is explicit (
var
orlet
). - The "go-to" data structure in Swift.
-
Classes:
- Reference types (multiple references to the same instance).
- Passed by reference using pointers.
- Support inheritance (single).
- Always mutable (dangerous).
- Stored on the heap.
- Automatic reference counting (ARC) for memory management.
- Used in special cases (e.g., ViewModels).
- Generics allow for type-agnostic programming.
- Even though Swift is a strongly typed language, generics allow for flexibility while maintaining type safety.
struct Array<Element> {
func append(_ element: Element) { }
}
Element
is a placeholder for any type, decided when usingArray
.
var names = Array<String>()
names.append("Molly")
names.append("Jake")
- Functions can also use generics.
func printElement<T>(_ element: T) { print(element) }
- A protocol defines a set of required functions and properties, but no implementation.
- Any type (struct, class, enum) can conform to a protocol by implementing its requirements.
protocol Movable {
func move(by: Int)
var hasMoved: Bool { get }
var distanceFromStart: Int { get }
}
- Types that conform to the protocol must provide an implementation.
struct PortableThing: Movable { var hasMoved: Bool var distanceFromStart: Int func move(by: Int) { /* implementation */ } }
- Protocols can inherit other protocols.
protocol Vehicle: Movable { var passengerCount: Int { get set } }
- Protocols can be used in place of types, especially with
some
andany
keywords.struct ContentView: View { var body: some View { /* implementation */ } }
- Functions in Swift can be treated as types. You can declare variables, parameters, and return types as functions.
(Int, Int) -> Bool // Function takes two Ints and returns a Bool.
(Double) -> Void // Function takes a Double and returns nothing.
() -> Array<String> // Function takes no arguments, returns an Array of Strings.
var operation: (Double) -> Double
func square(operand: Double) -> Double {
return operand * operand
}
operation = square // Assigning the square function to the operation variable.
let result = operation(4) // result is 16
- Closures are inline functions or lambdas, and are commonly used in Swift (e.g.,
@ViewBuilder
). - They allow passing functions around more easily.
performOperation { nums in print(nums) }
- Swift uses Automatic Reference Counting (ARC) for memory management.
- ARC keeps track of how many references point to an object and deallocates it when there are no more references.
- In contrast, Garbage Collection is used in languages like Java, where the system periodically deallocates unused objects based on reference checks.
- Model: MemoryGame Struct
- Initialization in Swift
- Trailing Closure Syntax
- Static Variables
- Creating a Memory Game
- Property Initializers and Self
- Reactive Programming and ObservableObject
- Using @StateObject in SwiftUI
The MemoryGame
struct represents the model of our game.
struct MemoryGame<CardContent> {
private(set) var cards: Array<Card>
init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
cards = [] // Initialize the cards array
// Add pairs of cards
for pairIndex in 0 ..< max(2, numberOfPairsOfCards) {
let content = cardContentFactory(pairIndex)
cards.append(Card(content: content))
cards.append(Card(content: content))
}
}
func choose(_ card: Card) { }
// Shuffle the cards (needs to be marked 'mutating')
mutating func shuffle() {
cards.shuffle()
print(cards)
}
struct Card {
var isFaceUp = true
var isMatched = false
let content: CardContent
}
}
- The
MemoryGame
struct takesCardContent
as a generic type. - It initializes a list of card pairs using a content factory function.
- The
shuffle()
function shuffles the cards in place.
- Swift provides a free initializer for structs, but only if all properties are initialized.
- If a struct contains variables without default values, Swift does not provide a free initializer.
For example:
private var model: MemoryGame<String> = MemoryGame<String>(numberOfPairsOfCards: 4)
Here, we manually initialize the model
with the correct number of card pairs.
When the last argument of a function is a closure, Swift allows the closure to be written outside the parentheses.
Example:
performOperation(numbers: [1, 2, 3]) { nums in
print(nums)
}
You can also use shorthand argument names like $0
, which refers to the first argument.
Static variables allow values to be global within a class or struct but scoped to the class itself. This is useful for values that don't need to change, such as an emoji set.
static let emojis = ["👻", "🐮", "🍓", "🫐", "👀", "🐶", "🐱", "🦊", "🐻", "🦁", "🐸", "🐧", "🐢", "🐙", "🐝", "🐼", "🦄"]
A function can be used to create a memory game.
private static func createMemoryGame() -> MemoryGame<String> {
return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
return emojis[pairIndex]
}
}
private var model = createMemoryGame()
Property initializers run before self
is available, meaning you cannot use instance members in initializers. This can be resolved by using static
functions.
To make the view model reactive, we use the ObservableObject
protocol and the @Published
property wrapper.
class EmojiMemoryGame: ObservableObject {
@Published private var model = createMemoryGame()
}
In a SwiftUI app, views that need to observe state changes use the @StateObject
property wrapper.
import SwiftUI
@main
struct MemorizwiftApp: App {
@StateObject var game = EmojiMemoryGame()
var body: some Scene {
WindowGroup {
ContentView(viewModel: game)
}
}
}
- The
MemoryGame
struct models the game logic. - We use closures for flexibility, static variables for global constants, and reactive programming patterns like
@Published
andObservableObject
for state management in SwiftUI.
- Enums are another variety of data structure in addition to
struct
andclass
. - Enums can only have discrete states.
enum FastFoodMenuItem {
case hamburger
case fries
case drink
case cookie
}
- An enum is a value type (like
struct
), so it is copied and passed around.
- Each state of an enum can (but does not have to) have its own 'associated data'.
enum FastFoodMenuItem {
case hamburger(numberOfPatties: Int)
case fries(size: FryOrderSize)
case drink(String, ounces: Int)
case cookie
}
- The
drink
case has two pieces of associated data, one of them unnamed. - In the example above,
FryOrderSize
would likely also be an enum:
enum FryOrderSize {
case large
case small
}
- Use the type name and the case you want, separated by a dot.
let menuItem: FastFoodMenuItem = FastFoodMenuItem.hamburger(numberOfPatties: 2)
var otherItem: FastFoodMenuItem = FastFoodMenuItem.cookie
var yetAnotherItem = .cookie // Swift can infer this
- Enum states are usually checked with a
switch
statement (anif
statement is unusual, especially if there is associated data).
var menuItem = FastFoodMenuItem.hamburger(numberOfPatties: 2)
switch menuItem {
case .hamburger: print("burger")
case .fries: print("fries")
case .drink: print("drink")
case .cookie: print("cookie")
}
- It's not necessary to fully write out
FastFoodMenuItem.fries
inside theswitch
(since Swift can infer it).
- If you don't want to do anything for a given case, use
break
.
switch menuItem {
case .hamburger: break
case .fries: print("fries")
case .drink: print("drink")
case .cookie: print("cookie")
}
- This code would print nothing to the console.
- A
switch
must handle all possible cases, though you can usedefault
to handle uninteresting cases.
switch menuItem {
case .hamburger: break
case .fries: print("fries")
default: print("other")
}
- If
menuItem
is acookie
, the above would print "other".
- Each case in a
switch
can have multiple lines and does not fall through to the next case unless specified withfallthrough
.
switch menuItem {
case .hamburger: print("burger")
case .fries:
print("yummy")
print("fries")
case .drink: print("drink")
case .cookie: print("cookie")
}
- The above code would print "yummy" and "fries" but not "drink".
- Associated data can be accessed in a
switch
using thelet
syntax.
switch menuItem {
case .hamburger(let pattyCount): print("a burger with \(pattyCount) patties!")
case .fries(let size): print("a \(size) order of fries!")
case .drink(let brand, let ounces): print("a \(ounces)oz \(brand)")
case .cookie: print("a cookie!")
}
- Enums can have methods and computed properties but no stored properties.
enum FastFoodMenuItem {
case hamburger(numberOfPatties: Int)
case fries(size: FryOrderSize)
case drink(String, ounces: Int)
case cookie
func isIncludedInSpecialOrder(number: Int) -> Bool {
switch self {
case .hamburger(let pattyCount): return pattyCount == number
case .fries, .cookie: return true
case .drink(_, let ounces): return ounces == 16
}
}
}
- The above method checks if the item is included in a special order (e.g., a 16oz drink or a burger with a specific number of patties).
- Use the
CaseIterable
protocol to get all cases of an enum.
enum TeslaModel: CaseIterable {
case X
case S
case Three
case Y
}
for model in TeslaModel.allCases {
reportSalesNumbers(for: model)
}
- An
Optional
is just an enum. It essentially looks like this:
enum Optional<T> {
case none
case some(T)
}
- Optionals can have two states:
.none
(nil) or.some(T)
(with associated data of typeT
).
- Declaring an optional can be done with the syntax
T?
.
var hello: String? = "hello"
var goodbye: String? = nil
- You can also use the fully expressed
Optional<T>
form:
var hello: Optional<String> = .some("hello")
var goodbye: Optional<String> = .none
- Access the value of an optional either by force (
!
) or safely usingif let
.
if let safeHello = hello {
print(safeHello)
} else {
print("hello is nil")
}
- The
??
operator provides a default value if the optional is nil.
let x: String? = nil
let y = x ?? "default value"
- In this case,
y
will be assigned "default value" ifx
is nil.
- Basic Layout Principles
- HStack and VStack
- Alignment in Stacks
- Lazy Stacks
- Grids
- ScrollView
- ViewThatFits
- Advanced Stacks
- Custom Layout Protocol
- ZStack
- Modifiers in SwiftUI
- GeometryReader
- Safe Area
- @ViewBuilder
It's amazingly simple ...
- Container Views “offer” some or all of the space offered to them to the Views inside them.
- Views then choose what size they want to be (they are the only ones who can do so).
- Container Views then position the Views inside of them.
This describes the basic flow of layout in SwiftUI where container views manage space distribution, and views decide their own size preferences.
- Stacks divide up the space offered to them and then offer that to the Views inside.
- The stack offers space to its “least flexible” subviews first.
Examples:
- Inflexible View:
Image
(it wants to be a fixed size). - Slightly more flexible View:
Text
(always wants to size exactly to fit its text). - Very flexible View:
RoundedRectangle
(always uses any space offered).
Once a View takes the space it wants, its size is removed from the available space, and the stack moves on to the next “least flexible” Views. Very flexible views share evenly.
-
Spacer(minLength: CGFloat)
- Takes all the space offered.
- Draws nothing.
minLength
defaults to platform-appropriate spacing.
-
Divider()
- Draws a dividing line crosswise to the stack direction.
- Takes the minimum space to fit the line, and all crosswise space.
These views are essential for creating flexible, visually organized layouts in SwiftUI.
This can override which views get space first, regardless of their flexibility. By default, all views have a layout priority of 0.
HStack {
Text("Important").layoutPriority(10) // Higher priority
Image(systemName: "arrow.up") // Default priority (0)
Text("Unimportant")
}
The Text("Important")
will get the space it needs first due to its higher layout priority. Then, the Image
will get its space because it is less flexible than the Text("Unimportant")
. Finally, the Text("Unimportant")
will fit into any remaining space, and if it doesn't get enough space, it may be truncated (e.g., "Swift is..." instead of "Swift is great!").
When stacking views with different widths, alignment determines their positioning (e.g., left-aligned, centered).
VStack(alignment: .leading) {
// Views go here
}
- .leading adjusts automatically for text flow direction (e.g., right-to-left languages like Arabic or Hebrew).
- Text baselines can also be used for alignment:
HStack(alignment: .firstTextBaseline) {
Text("SwiftUI") // Aligned by the first text baseline
Text("Layouts")
}
You can also define your own alignment guides, though this is beyond the scope of this lecture.
- LazyHStack and LazyVStack: These “lazy” stacks only build views that are currently visible.
- They don’t take up all the space offered, even if they contain flexible views.
- Use case: Ideal when the stack is within a
ScrollView
.
- LazyHGrid and LazyVGrid: These grids size their views based on the configuration (e.g., number of columns in a grid).
- The opposite direction (perpendicular to the grid’s axis) can grow or shrink as more views are added.
- Efficiency: The grid does not take up all the space if it doesn’t need it.
- Grid: A general-purpose grid that allocates space to its views both horizontally and vertically (hence no “H” or “V”).
- Offers alignment options for both columns and rows using grid modifiers like
gridColumnAlignment()
andgridRowAlignment()
. - Use case: Often used to create a "spreadsheet" or tabular display of data.
- Offers alignment options for both columns and rows using grid modifiers like
- ScrollView: Takes up all the space offered to it and enables scrolling along a specified axis.
- The views inside a
ScrollView
are sized to fit along the axis of scrolling (e.g., horizontally or vertically).
- The views inside a
- ViewThatFits: Chooses from a list of container views (e.g.,
HStack
,VStack
) and picks the one that best fits the available space.- Useful for handling different layouts in landscape vs. portrait mode, or accommodating dynamic type sizes (like larger fonts).
-
These views act like "smart" VStacks with additional functionality, such as scrolling, selection, and hierarchy.
- Form: Used for building structured input forms.
- List: Displays rows of data in a scrollable container.
- OutlineGroup: Ideal for showing hierarchical data.
- DisclosureGroup: Collapsible container for showing/hiding content.
- You can create custom views by implementing the Layout protocol.
- This allows complete control over the "offer space, let views choose their size, then position them" process using methods like
sizeThatFits
andplaceSubviews
.
- This allows complete control over the "offer space, let views choose their size, then position them" process using methods like
- ZStack: Stacks views on top of one another, with the last view being on top.
- Sizes itself to fit its children, and if one child is flexible, the entire stack will be flexible as well.
Text("hello").background(Rectangle().foregroundColor(.red))
This works like a mini-ZStack, where the Text
controls the layout. The Rectangle
just adds background color without impacting the layout size.
Circle().overlay(Text("Hello"), alignment: .center)
In this example, the Circle is fully flexible and determines the overall size, with the Text
stacked on top and centered.
- Modifiers, such as
.padding
, return a modified view and can adjust how space is distributed.
Text("SwiftUI Layout").padding(10)
This applies 10 points of padding around the text, adjusting its total size.
The .aspectRatio
modifier controls how a view resizes to fit within its available space while maintaining a specified aspect ratio.
Image(systemName: "photo").aspectRatio(contentMode: .fit)
The view returned by .aspectRatio
can choose to either:
- .fit: The content will resize to fit inside the available space while maintaining its aspect ratio.
- .fill: The content will expand to fill the entire available space while maintaining its aspect ratio, which may result in some content being cropped.
The GeometryReader
view allows you to access information about the size and position of its parent container.
var body: some View {
GeometryReader { geometry in
Text("Width: \(geometry.size.width), Height: \(geometry.size.height)")
}
}
The geometry
parameter is a GeometryProxy, which provides:
- size: The total space offered by the parent container (
CGSize
). - frame(in:): The view's frame in a specific coordinate space (
CGRect
). - safeAreaInsets: Insets around the safe area (
EdgeInsets
).
GeometryReader
itself always accepts all the space offered to it, meaning it will expand to fill the available space. It's particularly useful for adjusting the layout based on the view's size or position within the interface.
GeometryReader { geometry in
Text("Available width: \(geometry.size.width), Available height: \(geometry.size.height)")
}
In this example, GeometryReader
provides the width and height of the parent container, allowing the layout to adapt dynamically based on the available space.
The safe area represents portions of the screen where views should not draw content, such as the area around the notch on iPhones or the home indicator.
By default, views are constrained to avoid drawing into the safe area, but this behavior can be overridden using the .edgesIgnoringSafeArea
modifier.
ZStack {
Text("Hello, World!")
}.edgesIgnoringSafeArea([.top])
In this example, the ZStack
content is allowed to extend into the top safe area, overriding the default layout behavior. This can be useful for creating full-screen content or when you want the content to span the entire screen, including areas normally reserved for system elements.
By using .edgesIgnoringSafeArea
, you can selectively allow content to be drawn into areas that are typically protected by the safe area.
@ViewBuilder
is a mechanism in Swift used to enhance a variable to have special functionality.- It simplifies the syntax for creating lists of views.
- Developers can apply
@ViewBuilder
to any function that returns something conforming toView
. - The function still returns a
View
, but it interprets the contents as a list of Views and combines them into one.
@ViewBuilder
func front(of card: Card) -> some View {
let shape = RoundedRectangle(cornerRadius: 20)
shape.fill(.white)
shape.stroke()
Text(card.content)
}
- The above would return a
TupleView
combining multiple views (e.g., aRoundedRectangle
andText
). - It would be valid to include simple conditionals (
if-else
orif let
) to determine which views to include.
- The contents of a
@ViewBuilder
are just a list of views. It does not allow arbitrary code. - You can use if-else, switch, or if let statements to include or exclude views conditionally.
- Local
let
bindings are allowed within theViewBuilder
. - No other types of code are allowed in the function marked with
@ViewBuilder
.
- Developers don't need to worry about how the views are combined, just that
@ViewBuilder
takes care of assembling the views into one.
- Shape in SwiftUI
- Creating Custom Shapes
- ViewModifier
- Animation in SwiftUI
- Cardify ViewModifier Example
- Protocols in SwiftUI
- Generics and Protocols
- The
some
Keyword - The
any
Keyword
- Shape is a protocol that inherits from
View
. - In other words, all shapes are also views in SwiftUI.
- Examples of shapes already in SwiftUI include:
RoundedRectangle
Circle
Capsule
- By default, shapes draw themselves by filling with the current foreground color.
- You can modify this by using
.stroke()
and.fill()
to change the way the shape is drawn. - These modifiers return a
View
that draws the shape in the specified way (by stroking or filling).
- The arguments to
.stroke()
and.fill()
are quite interesting. - Initially, it might seem that the argument to
.fill()
is a color (e.g.,Color.white
), but this isn’t always the case.
func fill<S>(_ whatToFillWith: S) -> View where S: ShapeStyle
- This is a generic function, and
S
is a placeholder for a type that conforms to theShapeStyle
protocol. ShapeStyle
turns aShape
into aView
by applying some styling to it.- Examples of
ShapeStyle
include:Color
ImagePaint
AngularGradient
LinearGradient
- The
Shape
protocol (by extension) implements theView
's body var for you. - However, it introduces its own function that you are required to implement:
func path(in rect: CGRect) -> Path {
return Path()
}
- In this function, you will create and return a
Path
that draws anything you want. - The
Path
struct has many functions to support drawing, such as:- Lines
- Arcs
- Bezier curves
- etc.
- In our demo, we will add a "timer countdown pie" to our
CardView
(currently without animation).
- View modifiers in SwiftUI are essentially functions that modify the appearance or behavior of a view.
- Examples include:
foregroundColor
font
padding
frame
- These modifiers return a modified
View
, allowing you to chain multiple modifiers together in a declarative manner.
- Animations in SwiftUI are built by simply adding the
.animation()
modifier to views. - This tells SwiftUI to animate any changes to the view’s state in a smooth, coordinated fashion.
- Animation is crucial for a mobile UI, and SwiftUI makes it very easy to implement.
- One way to perform animations is by animating a Shape.
- Another way is by animating Views via their ViewModifiers.
- In the upcoming demo, we will show how to animate a pie-shaped countdown timer by animating a Shape.
- You’ve used many functions that modify views (like
aspectRatio
andpadding
). - These functions likely turn around and call the
.modifier()
function in the View.
Example:
.aspectRatio(2/3) is likely something like .modifier(AspectModifier(2/3))
- AspectModifier can be anything that conforms to the ViewModifier protocol.
- The ViewModifier protocol has a single function that creates a new View based on the thing passed to it.
protocol ViewModifier {
func body(content: Content) -> some View {
return some View that likely contains content
}
}
- When we call
.modifier
on a view, thecontent
passed to this function is the view itself.
Example:
aView.modifier(MyViewModifier(arguments: …))
MyViewModifier
implementsViewModifier
, andaView
will be passed to its body function via thecontent
.
- Let’s say we want to create a modifier that "card-ifies" a view.
- This would take the view and put it on a card-like interface, as seen in the Memorize game.
- This modifier should work with any view, not just our
Text("👻")
.
What would such a ViewModifier look like?
// Example ViewModifier code
In this example, we will create a custom modifier to "card-ify" any view, adding functionality and visual customization to the view in SwiftUI.
Text("👻").modifier(Cardify(isFaceUp: true)) // eventually .cardify(isFaceUp: true)
- Here, we apply a custom Cardify modifier to a
Text
view.
struct Cardify: ViewModifier {
var isFaceUp: Bool
func body(content: Content) -> some View {
ZStack {
if isFaceUp {
RoundedRectangle(cornerRadius: 10).fill(Color.white)
RoundedRectangle(cornerRadius: 10).stroke()
content
} else {
RoundedRectangle(cornerRadius: 10)
}
}
}
}
- Cardify is a
ViewModifier
that checks if a card is face-up and then uses aZStack
to display either the card content or just a rounded rectangle for the card's back. - The
ZStack
allows us to layer the views, such as theRoundedRectangle
for the card shape and the actual content (like the emoji or text).
Text("👻").modifier(Cardify(isFaceUp: true))
- This can be shortened to:
Text("👻").cardify(isFaceUp: true)
extension View {
func cardify(isFaceUp: Bool) -> some View {
return self.modifier(Cardify(isFaceUp: isFaceUp))
}
}
- By using an extension on
View
, we can easily add thecardify
function to any view without having to manually call.modifier()
every time.
- One of the most powerful uses of protocols is to facilitate code sharing.
- Implementation can be added to a protocol by creating an extension to it.
extension ProtocolName {
// Default implementation for protocol methods
}
- This is how Views get modifiers like
foregroundColor
andfont
. - Functions like
filter
andfirstIndex(where:)
are implemented using protocol extensions. - Extensions can also add a default implementation for functions or properties in the protocol.
- Adding extensions to protocols is essential for protocol-oriented programming in Swift.
- This is how Swift enables code reuse and sharing across multiple types and modules.
- Protocols facilitate code sharing by allowing extensions to add implementation.
- Examples:
- filter was added to Array, String, and Range as an extension to the Sequence protocol.
filter(_ isIncluded: (Element) -> Bool) -> Array<Element>
- The
filter
function was written once by Apple but works on many types like Array, Range, and more.
- In SwiftUI, there’s a protocol similar to the following:
protocol View {
var body: some View { get }
}
- There’s also an extension that provides various modifiers:
extension View {
func foregroundColor(_ color: Color) -> some View { /* implementation */ }
func font(_ font: Font?) -> some View { /* implementation */ }
func blur(radius: CGFloat, opaque: Bool) -> some View { /* implementation */ }
// ...and many more...
}
- The first part constrains views (e.g., CardView) to provide the required body.
- The second part adds many modifiers as benefits for conforming to the protocol.
protocol Identifiable {
var id: ID { get }
}
- Here,
ID
is a "don't care" type, meaning any type can be used forid
. - Protocols in Swift can be generic and declare associated types.
protocol Identifiable {
associatedtype ID
var id: ID { get }
}
- This allows any conforming type to provide its own specific type for
id
.
- For example,
String
is used as theID
type inMemoryGame.Card
, which conforms toIdentifiable
. - Since
String
is Hashable, we can look upid
s in hash tables. - This is why Hashable is often combined with Identifiable.
protocol Identifiable {
associatedtype ID: Hashable
var id: ID { get }
}
- Consider the
Identifiable
protocol:
protocol Identifiable {
var id: ID { get }
}
- The type
ID
is a "don't care" forIdentifiable
. - We can enforce that
ID
isHashable
:
protocol Identifiable {
associatedtype ID: Hashable
var id: ID { get }
}
- The
some
keyword is used to pass things opaquely in or out of a function or variable. - It means that you know the thing conforms to the protocol, but nothing more.
var body: some View {
if viewModel.rounded {
RoundedRectangle(cornerRadius: 12)
} else {
Rectangle()
}
}
- All paths through the curly braces
{ }
must return something of the same type.
We saw a generic function in Shape
:
func fill<S>(_ whatToFillWith: S) -> View where S: ShapeStyle
This could be simplified as:
func fill(_ whatToFillWith: some ShapeStyle) -> some View
The actual (underlying) type is determined by the caller:
Circle().fill(ImagePaint(image: Image(systemName: "globe")))
func fillAndStroke(shape: some Shape) -> some View {
ZStack {
shape.fill(.white)
shape.stroke()
}
}
For simple protocols, you can use the protocol
keyword like any other type.
Example: Array<Foo>
for a simple protocol like:
protocol Foo {
func bar()
}
If you iterate through an Array<Foo>
, the only thing you can call is bar()
.
However, for protocols that involve generics (like Identifiable
), or are self-referential (like Equatable
), you can’t do this easily.
Swift’s solution to create a heterogeneous array of such things is to use the keyword any
.
let ids = [any Identifiable]()
func printId(of identifiable: some Identifiable) {
print(identifiable.id)
}
Some of you might be feeling overwhelmed.
- "How am I going to design systems using generics/protocols?"
- SwiftUI does a lot of the work for you.
- The more you use it, the more you’ll grasp the concepts.
- Eventually, you’ll master extensions and generics.
You don’t need to be an expert in functional programming to use SwiftUI effectively. Start with the basics, and mastery will come with experience.
- Protocols are a powerful way to enable code sharing in SwiftUI.
ViewModifier
allows for flexible customization of views.- Animations are easy to implement and customize.
- Custom shapes allow you to create unique UI elements.
Swift is able to detect when a struct changes. Property Observers allow us to take action when this happens. Essentially, they "watch" a variable and execute code when it changes.
The syntax can look a lot like a computed variable, but it's completely unrelated to that:
var isFaceUp: Bool {
willSet {
if newValue {
startUsingBonusTime()
} else {
stopUsingBonusTime()
}
}
}
newValue
: This is a special variable representing the value that is going to be set.didSet
: Another property observer that usesoldValue
, representing the previous value.
Instead of using a property observer on an @State
variable, we can use the .onChange(of:)
view modifier. This detects a change to an @State
or ViewModel variable:
@State private var taps = 0
Text("\(taps) taps")
.onChange(of: viewModel.cards) { newCards in
taps += 1
}
newCards
: This represents the value it is going to be set to.
-
Only changes can be animated.
- Changes to ViewModifier arguments (including
GeometryEffect
modifiers). - Changes to shapes.
- Transitioning a view from "existing" to "not existing" in the UI.
- Changes to ViewModifier arguments (including
-
ViewModifiers are the primary "change agents" in the UI.
- Changes to a ViewModifier's arguments must happen after the view is initially in the UI.
- Not all ViewModifier arguments are animatable, but most are.
-
When a view arrives or departs, the entire thing is animated as a unit.
Implicit animation in Swift allows us to automatically animate views. To enable this, we simply add a .animation()
modifier to the view:
Text("💀")
.opacity(card.scary ? 1 : 0)
.rotationEffect(Angle.degrees(card.upsideDown ? 180 : 0))
.animation(Animation.easeInOut, value: card)
- Warning: The
.animation
modifier does not work like a container. It propagates the.animation
modifier to all the views it contains.
The kind of animation curve we use controls how the animation "plays out":
.linear
: Consistent rate throughout..easeInOut
: Starts slow, speeds up, and then slows down again..spring
: Provides a "soft landing" or "bounce" effect at the end of the animation.
Explicit animations allow us to create animation transactions where changes are animated together by executing a block of code:
withAnimation(.linear(duration: 2)) {
// Do something that will cause view to change
}
Explicit animations are often wrapped around calls to ViewModel Intent Functions, like:
- Entering or exiting editing mode.
Note: Explicit animations do not override implicit animations.
Purpose: Transitions specify how to animate the arrival/departure of Views.
- They work for Views already on-screen (containers that are inside CTAOOS - "Containers That Are Already On-Screen").
- Transitions are composed of pairs of ViewModifiers (before and after changes occur).
Example:
- A view fades in on appearance but flies out when it disappears.
.opacity
: Uses.opacity
to fade theView
in/out..scale
: Uses.frame
to expand/shrink theView
..offset
: Moves theView
using an offset..modifier(active:identity:)
: You provide the two ViewModifiers to use.
Use .transition()
to specify which kind of transition to use when a View arrives/departs.
Example:
ZStack {
if isFaceUp {
RoundedRectangle(cornerRadius: 10)
.stroke()
Text("💀")
.transition(AnyTransition.scale)
} else {
RoundedRectangle(cornerRadius: 10)
.transition(AnyTransition.identity)
}
}
In this example:
- If
isFaceUp
changes, the front RoundedRectangle fades in and the text grows in. - Unlike
.animation()
,.transition()
only works for the entire ZStack (or its content). - It does not get redistributed to a container’s content Views.
- Group and ForEach distribute
.transition()
to their child views.
To set an animation (curve/duration/etc.) for a transition, use the .animation
method of AnyTransition
structs.
Example:
.transition(AnyTransition.opacity.animation(.linear(duration: 20)))
-
Only changes can be animated:
- ViewModifier arguments
- Shapes
- View transition from existing to non-existing (or vice versa).
-
Animation shows the user changes that have already happened.
ViewModifiers are the primary "change agents" in the UI.
- Changes to a ViewModifier’s arguments can only happen after the
View
is added to the UI. - Only changes since a View joined the UI can be animated.
-
Sometimes you want a
View
to move from one place on the screen to another, and possibly resize along the way. -
If the
View
is moving to a new place in its same container, this is no problem (like shuffle). -
"Moving" like this is just animating the
.position
ViewModifier
arguments..position
is whatHStack
,LazyVGrid
, etc., use to position the Views inside them.- This kind of thing happens automatically when you explicitly animate.
-
But what if the
View
is "moving" from one container to a different container?- This is not really possible.
-
Instead, you need a
View
in the source position and a different one in the destination position.- Then you must "match" their geometries up as one leaves the UI and the other arrives.
-
So, this is similar to
.transition
in that it is animatingViews
coming and going in the UI.- It's just that it's particular to the case where a pair of
Views
arrivals/departures are synced.
- It's just that it's particular to the case where a pair of
-
A great example of this would be "dealing cards off of a deck".
- The "deck" might well be its own
View
off to the side. - When a card is "dealt" from the deck, it needs to fly from there to the game.
- But the deck and game's main
View
are not in the sameLazyVGrid
or anything.
- The "deck" might well be its own
-
How do we handle this?
- We mark both
Views
using thisViewModifier
:
.matchedGeometryEffect(id: ID, in: Namespace) // ID type is a "don't care": Hashable
- Declare the Namespace as a private var in your
View
like this:
@Namespace private var myNamespace
-
Now write code so that only one of the 2
Views
is ever included in the UI at the same time.- You can do this with
if-else
in aViewBuilder
or maybe viaForEach
.
- You can do this with
-
Now, when one of the pair leaves and the other arrives at the same time, their size and position will be synced up and animated.
-
It's possible to match geometries when both
Views
are on screen too.
- Remember that animations only work on
Views
that are inCTAAOS
(Containers That Are Already On-Screen).
-
How can you kick off an animation as soon as a
View's
Container arrives on-screen?View
has a nice function called.onAppear {}
.- It executes a closure anytime a
View
appears on screen.
- It executes a closure anytime a
-
Since, by definition, a
View
is on-screen when its own.onAppear {}
is happening, it is in aCTAAOS
, so any animations for it or its children that are appearing can fire.
- We'll use
.onAppear {}
to kick off a couple of animations in the demo this week, especially ones that only make sense when a certainView
is visible (e.g., our flying score).
- The communication with the animation system happens (both ways) with a single var.
- This var is the only thing in the
Animatable
protocol.
- This var is the only thing in the
- Shapes and
ViewModifiers
that want to be animatable must implement this protocol:
var animatableData: Type
-
Type
is a "don't care". Well... it's a "care a little bit".Type
has to implement the protocolVectorArithmetic
.- That's because it has to be able to be broken up into little pieces on an animation curve.
-
Type
is very often a floating point number (Float
,Double
,CGFloat
). -
But there's another struct that implements
VectorArithmetic
calledAnimatablePair
.AnimatablePair
combines twoVectorArithmetics
into oneVectorArithmetic
.
-
Of course, you can have
AnimatablePairs
ofAnimatablePairs
, so you can animate all you want.
The communication with the animation system happens (both ways) with a single variable.
This var is the only thing in the Animatable
protocol.
- Shapes and ViewModifiers that want to be animatable must implement this protocol.
var animatableData: Type
Type
is a don’t care. Well… it’s a “care a little bit.”Type
has to implement the protocolVectorArithmetic
because it has to be broken up into little pieces on an animation curve.
- Type is often a floating point number (Float, Double, CGFloat).
- Another struct that implements
VectorArithmetic
isAnimatablePair
. AnimatablePair
combines twoVectorArithmetic
into oneVectorArithmetic
.
With AnimatablePairs
, you can animate as much as you want!
Because it’s communicating both ways, this animatableData
is a read-write variable.
- The setting of this var is the animation system telling the Shape/VM which "piece" to draw.
- The getting of this var is the animation system getting the start/end points of an animation.
This is often a computed var (but doesn’t have to be).
- We might not want to use the name
animatableData
in our Shape/VM code, instead using more descriptive variable names. - The
get/set
often just gets/sets other variables, essentially exposing them to the animation system with a different name.
- Demonstrates shuffling and selecting cards with explicit animations.
- An example of celebrating a match with implicit animations.
- Shows how to create custom view modifiers for flipping cards.
- How to suppress unwanted animations using
.animation(nil)
.
- Demonstrates animating score changes with property observers and tuples.