00:06 Today we'll start working on a new project: we'll build a library
that lets us easily create slides using simple SwiftUI views like Text
and
Rectangle
. We've already been using a version of this library in some videos
and it turns out to be very handy.
00:21 Basically, we'll be writing various helpers to turn normal SwiftUI
views into slides, to add navigation between slides (and between build steps on
a slide), to support theming, etc.
00:49 SwiftUI lends itself perfectly to doing this. Its declarative
nature and simple constructs like VStack
, HStack
, Text
, and Image
make
it easy to put a slide together. Our goal is to be able to create slides just as
we would write SwiftUI views.
Slide Container
01:16 An empty SwiftUI project — with its plain "Hello, world" — sort of
looks like a slideshow of one slide already. As a first step, we write a slide
container view that can render any SwiftUI view as a slide, i.e. fitted into a
frame with a fixed aspect ratio of 16:9 with a border around it.
01:59 The slide container is generic over the type of slide it contains.
Inside its body view, we give the contained slide view an aspect ratio of 16:9
and a border:
struct SlideContainer<S: View>: View {
var slide: S
var body: some View {
slide
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fit)
.border(Color.black)
}
}
03:36 Now we can wrap the "Hello, World" text view in a slide container:
struct ContentView: View {
var body: some View {
SlideContainer(slide: Text("Hello, World!"))
}
}
04:01 But this isn't quite right yet; the slide container now applies
the aspect ratio modifier to the text view directly and this truncates the text
to make it fit in the aspect ratio. Instead of this, we want the container to
take up as much space as it can, so we set the slide view's maximum frame size
to .infinity
before applying the aspect ratio modifier:
struct SlideContainer<S: View>: View {
var slide: S
var body: some View {
slide
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fit)
.border(Color.black)
}
}

Styling
05:06 Let's do some styling. We can try to set a background on the slide
view, but that only gives the text itself — and not the entire space within the
slide's border — a background:
struct ContentView: View {
var body: some View {
SlideContainer(slide: Text("Hello, World!").background(Color.blue))
}
}
05:30 Ideally, the slide view definition just contains the text and we
have a way to separately apply a theme. We can achieve this by adding a view
modifier to the slide container as another generic parameter:
struct SlideContainer<S: View, Theme: ViewModifier>: View {
var slide: S
var theme: Theme
var body: some View {
slide
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(theme)
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fit)
.border(Color.black)
}
}
06:12 To avoid having to provide a theme for every slide container, we
write an initializer with an EmptyViewModifier
as the default theme:
extension SlideContainer where Theme == EmptyModifier {
init(slide: S) {
self.init(slide: slide, theme: .identity)
}
}
07:05 For our theme, we choose the font, text color, and background
color we want to apply to our slides:
struct MyTheme: ViewModifier {
func body(content: Content) -> some View {
content
.foregroundColor(.white)
.background(Color.blue)
.font(.custom("Avenir", size: 48))
}
}
07:43 And we pass this theme to the slide container:
struct ContentView: View {
var body: some View {
SlideContainer(slide: Text("Hello, World!"), theme: MyTheme())
}
}

Multiple Slides
08:02 To have more than one slide, we have to initialize the slide
container with not just one view, but an array of views:
struct SlideContainer<S: View, Theme: ViewModifier>: View {
var slides: [S]
var theme: Theme
var body: some View {
slides[0]
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(theme)
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fit)
.border(Color.black)
}
}
}
extension SlideContainer where Theme == EmptyModifier {
init(slides: [S]) {
self.init(slides: slides, theme: .identity)
}
}
08:59 That lets us add a second slide:
struct ContentView: View {
var body: some View {
SlideContainer(slides: [
Text("Hello, World!"),
Text("Slide 2")
], theme: MyTheme())
}
}
09:56 But this only works as long as all slides have the same type of
view. When we try to use a Text
view for one slide and an Image
of a
tortoise for the second slide, the compiler throws an error because we can't
have an array with differently typed elements:
struct ContentView: View {
var body: some View {
SlideContainer(slides: [ Text("Hello, World!"),
Image(systemName: "tortoise")
], theme: MyTheme())
}
}
10:27 For the time being, we can work around this by wrapping our slides
in AnyView
s:
struct ContentView: View {
var body: some View {
SlideContainer(slides: [ AnyView(Text("Hello, World!")),
AnyView(Image(systemName: "tortoise"))
], theme: MyTheme())
}
}
10:53 We've now defined multiple slides, but we still need a way to show
the second slide. So we add a state variable for the index of the current slide,
and we update the UI to include next and previous buttons and a label with the
current slide number:
struct SlideContainer<S: View, Theme: ViewModifier>: View {
var slides: [S]
var theme: Theme
@State var currentSlide = 0
var body: some View {
VStack(spacing: 30) {
HStack {
Button("Previous") { }
Text("Slide \(currentSlide + 1) of \(slides.count)")
Button("Next") { }
}
slides[currentSlide]
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(theme)
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fit)
.border(Color.black)
}
}
}
12:20 In the respective button actions, we increment and decrement
currentSlide
, making sure it doesn't go out of bounds:
struct SlideContainer<S: View, Theme: ViewModifier>: View {
var slides: [S]
var theme: Theme
@State var currentSlide = 0
func previous() {
if currentSlide > 0 {
currentSlide -= 1
}
}
func next() {
if currentSlide + 1 < slides.count {
currentSlide += 1
}
}
var body: some View {
VStack(spacing: 30) {
HStack {
Button("Previous") { self.previous() }
Text("Slide \(currentSlide + 1) of \(slides.count)")
Button("Next") { self.next() }
}
}
}
}
13:24 Now we can jump back and forth between slides and we see the
tortoise image on the second slide.
Build Steps
13:31 We also want the ability to have multiple animation steps within
the same slide — for example, to move the tortoise from left to right.
13:52 We wrap our slides in AnyView
s, and we could now replace this
with a custom wrapper that has the step behavior built into it. But rather than
requiring this wrapper to be used for every slide, we want to preserve the
flexibility that allows us to use any type of view as a slide.
14:34 By using preferences and environment values, we can keep a loose
coupling between the slide container and the slide views. This gives each slide
view the responsibility of telling the slide container about its number of steps
via a preference and to read the current step from the environment.
14:53 Here's what that looks like. We create a preference key to store a
number of steps in the preference, with a default value of one step. What we do
in the reduce
function, which exists to combine multiple values of the same
preference, doesn't matter because the slide container's view tree contains only
one slide view at a time:
struct StepsKey: PreferenceKey {
static let defaultValue: Int = 1
static func reduce(value: inout Int, nextValue: () -> Int) {
value = nextValue()
}
}
We also pull the image slide out to a separate struct and we make it set a
preference of two steps:
struct ImageSlide: View {
var body: some View {
Image(systemName: "tortoise")
.preference(key: StepsKey.self, value: 2)
}
}
struct ContentView: View {
var body: some View {
SlideContainer(slides: [
AnyView(Text("Hello, World!")),
AnyView(ImageSlide())
], theme: MyTheme())
}
}
16:13 The slide container captures the step preference propagating up
from the slide view, and it assigns this number to a state variable,
numberOfSteps
. To test that it works, we add a description of the number of
steps to the label in the controls:
struct SlideContainer<S: View, Theme: ViewModifier>: View {
var slides: [S]
var theme: Theme
@State var currentSlide = 0
@State var numberOfSteps = 1
var body: some View {
VStack(spacing: 30) {
HStack {
Button("Previous") { self.previous() }
Text("Slide \(currentSlide + 1) of \(slides.count) — Step 0 of \(numberOfSteps)")
Button("Next") { self.next() }
}
slides[currentSlide]
.onPreferenceChange(StepsKey.self, perform: {
self.numberOfSteps = $0
})
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(theme)
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fit)
.border(Color.black)
}
}
}
17:10 When we run the app, the label confirms that the first slide has
a single step and that the second slide has two.
17:14 We define another state variable to hold the current step. In the
previous
method, we reset the current step to zero, and in the next
method,
we check if there's another step in the current slide before going to the next
slide:
struct SlideContainer<S: View, Theme: ViewModifier>: View {
var slides: [S]
var theme: Theme
@State var currentSlide = 0
@State var numberOfSteps = 1
@State var currentStep = 0
func previous() {
if currentSlide > 0 {
currentSlide -= 1
currentStep = 0
}
}
func next() {
if currentStep + 1 < numberOfSteps {
withAnimation(.default) {
currentStep += 1
}
} else if currentSlide + 1 < slides.count {
currentSlide += 1
currentStep = 0
}
}
var body: some View {
}
}
18:56 We want to communicate the current step back down to the slides
by passing it to the environment. For this, we need to create both a property on
EnvironmentValues
(which gives us a mutable key path) and an EnvironmentKey
(which defines a default value for the property):
struct CurrentStepKey: EnvironmentKey {
static let defaultValue = 1
}
extension EnvironmentValues {
var currentStep: Int {
get { self[CurrentStepKey.self] }
set { self[CurrentStepKey.self] = newValue }
}
}
20:12 In the slide container, we can now write the current step to the
environment to propagate the value down the view tree:
struct SlideContainer<S: View, Theme: ViewModifier>: View {
@State var currentStep = 0
var body: some View {
VStack(spacing: 30) {
slides[currentSlide]
.onPreferenceChange(StepsKey.self, perform: {
self.numberOfSteps = $0
})
.environment(\.currentStep, currentStep)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(theme)
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fit)
.border(Color.black)
}
}
}
21:41 And each slide view can read the current step to change its
contents accordingly. In this case, the image slide places the tortoise image on
the left or right side of the view:
struct ImageSlide: View {
@Environment(\.currentStep) var step: Int
var body: some View {
Image(systemName: "tortoise")
.frame(maxWidth: .infinity, alignment: step > 0 ? .trailing : .leading)
.padding(50)
.preference(key: StepsKey.self, value: 2)
}
}
23:04 Animating this change is as easy as putting the mutation of the
current step inside a withAnimation
block:
func next() {
if currentStep + 1 < numberOfSteps {
withAnimation(.default) {
currentStep += 1
}
} else if currentSlide + 1 < slides.count {
currentSlide += 1
currentStep = 0
}
}
Slide
23:43 We can pull the step logic out to a helper view, Slide
, that
takes both a number of steps and a function that produces a content view for a
given step:
struct Slide<Content: View>: View {
var steps: Int = 1
let content: (Int) -> Content
@Environment(\.currentStep) var step: Int
var body: some View {
content(step)
.preference(key: StepsKey.self, value: steps)
}
}
struct ImageSlide: View {
var body: some View {
Slide(steps: 2) { step in
Image(systemName: "tortoise")
.frame(maxWidth: .infinity, alignment: step > 0 ? .trailing : .leading)
.padding(50)
}
}
}
25:41 Now we can easily create slides with build steps without having
to do the preference dance again.
25:58 That's it for the first round. We'll build many more features and
things like theming and different slide layouts, but we first want to do
something about the AnyView
wrapper that's currently needed for all our
slides. We'll take care of that in the next episode.