This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start building a SwiftUI library for creating presentation slides.

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: [ // error
            Text("Hello, World!"),
            Image(systemName: "tortoise")
        ], theme: MyTheme())
    }
}

10:27 For the time being, we can work around this by wrapping our slides in AnyViews:

struct ContentView: View {
    var body: some View {
        SlideContainer(slides: [ // error
            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 AnyViews, 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.

Resources

  • Sample Code

    Written in Swift 5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes