Swift Talk # 225

SwiftUI Layout Explained: View Protocols and Shapes

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 reimplement parts of SwiftUI's layout system — starting with view protocols and shapes — to understand it better.

00:06 Today we're starting a new series in which we take an in-depth look at SwiftUI's layout system by rebuilding it ourselves, mimicking some of its building blocks.

00:40 We came up with this idea because we wanted to find a way of rendering a SwiftUI view to a vector-based PDF. SwiftUI currently supports rendering to a PDF, but unlike UIKit and AppKit, the resulting PDF is not vector based. In figuring out how to mimic SwiftUI's layout system, we'll get a thorough understanding of how it works.

View_

01:29 Today we'll just set up the protocols needed to render a rectangle or an ellipse, starting with the most basic building block: View. We'll suffix the names of our protocols and types with an underscore so as not to clash with the public and private symbols imported from SwiftUI:

protocol View_ {
    associatedtype Body: View_
    var body: Body { get }
}

03:15 This is an exact copy of SwiftUI's View protocol. The question is, how can we render this? We'll need to add a private rendering method, and this method takes both a graphical context to render into and a size to know how large the content may be rendered.

04:15 The size parameter hints at how the layout system works in SwiftUI: it proposes a size to a view, and the view renders itself within that size. A rectangle will take on any size that is proposed to it. And if we have an HStack with four rectangles, the stack view divides the proposed width by four and then proposes this smaller size to each of the rectangles.

04:51 In the end, this will be a two-step process. We'll first do a layout pass to compute the frames of views, and as a second step, we'll render the views. But we don't need to do that just yet; we just call _render for now:

extension View_ {
    func _render(context: RenderingContext, size: ProposedSize) {
        
    }
}

05:12 We introduce a type alias for the rendering context. Under the hood, it'll just be a CGContext. And in order to model the future two-step layout process, we can distinguish between a proposed size and a concrete CGSize using another type alias:

typealias RenderingContext = CGContext
typealias ProposedSize = CGSize

05:57 In the View_'s _render method, the only thing we can do is call _render recursively on the body view:

extension View_ {
    func _render(context: RenderingContext, size: ProposedSize) {
        body._render(context: context, size: size)
    }
}

06:22 But at some point, we have to break out of the recursive loop. We do so with built-in views that actually render something. These views get their own protocol so that we can distinguish between View_s and BuiltinViews when we're rendering a view tree:

protocol BuiltinView {
    func render(context: RenderingContext, size: ProposedSize)
}

extension View_ {
    func _render(context: RenderingContext, size: ProposedSize) {
        if let builtin = self as? BuiltinView {
            builtin.render(context: context, size: size)
        } else {
            body._render(context: context, size: size)
        }
    }
}

07:47 The type casting to a BuiltinView only works if we keep the protocols simple. In other words, in order to use BuiltinView as a type, we can't let BuiltinView inherit from View_.

ShapeView

08:10 Now we can create our first built-in view. Let's start with a shape view that uses our own version of Shape_, which we still have to write. The view is both a BuiltinView and a View_:

struct ShapeView<S: Shape_>: BuiltinView, View_ {
    
}

08:41 If a view is a BuiltinView, we'll never read its body property. So we assign Never to the BuiltinView.Body type, and we provide a default implementation of View_s whose Body type is Never:

protocol BuiltinView {
    func render(context: RenderingContext, size: ProposedSize)
    typealias Body = Never
}

extension View_ where Body == Never {
    var body: Never { fatalError("This should never be called.") }
}

09:31 In order for this to work, Never needs to conform to View_:

extension Never: View_ {
    typealias Body = Never
}

09:59 By writing this extension, we avoid having to repeat the same body implementation for every built-in view.

Shape_

10:24 For the ShapeView to work, we still need to write our own Shape_ protocol:

protocol Shape_ {
    func path(in rect: CGRect) -> CGPath
}

10:57 Now ShapeView can render its shape in the provided CGContext, using the proposed size. For now, we hardcode a red color to fill the shape:

struct ShapeView<S: Shape_>: BuiltinView, View_ {
    var shape: S
    
    func render(context: RenderingContext, size: ProposedSize) {
        context.saveGState()
        context.setFillColor(NSColor.red.cgColor)
        context.addPath(shape.path(in: CGRect(origin: .zero, size: size)))
        context.fillPath()
        context.restoreGState()
    }
}

And we create our first shape, Rectangle_:

struct Rectangle_: Shape_ {
    func path(in rect: CGRect) -> CGPath {
        CGPath(rect: rect, transform: nil)
    }
}

Rendering

12:44 We now have a built-in view that can render into a rendering context, but we still need to call the rendering function from somewhere. So we create a sample shape view:

let sample = ShapeView(shape: Rectangle_())

14:17 And we write a top-level render function that returns some PDF data, which can be displayed in an image view. We use a small helper method on CGContext that outputs a context's contents as PDF data. Its nine lines of code can be found in the sample code:

func render<V: View_>(view: V) -> Data {
    let size = CGSize(width: 600, height: 400)
    return CGContext.pdf(size: size) { context in
        view._render(context: context, size: size)
    }
}

16:10 Finally, we create an image from the PDF data — this is an inefficient operation, but it's an easy way for us to output the result in this demo — and we display the image in a SwiftUI view:

struct ContentView: View {
    var body: some View {
        Image(nsImage: NSImage(data: render(view: sample))!)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

17:09 That's a lot of work to draw a red rectangle. But we now have the basic infrastructure for doing more complex things. For starters, we can create a second shape:

struct Ellipse_: Shape_ {
    func path(in rect: CGRect) -> CGPath {
        CGPath(ellipseIn: rect, transform: nil)
    }
}

let sample = ShapeView(shape: Ellipse_())

Shape and Color as a View

17:51 In SwiftUI, we can directly use a shape as a view without wrapping it in a view. We can also support this by making Shape_ conform to View_:

protocol Shape_: View_ {
    func path(in rect: CGRect) -> CGPath
}

extension Shape_ {
    var body: some View_ {
        ShapeView(shape: self)
    }
}

let sample = Ellipse_()

18:40 Another type we can easily conform to View_ is NSColor. First, we change ShapeView's hardcoded red color to a property with a default value:

struct ShapeView<S: Shape_>: BuiltinView, View_ {
    var shape: S
    var color: NSColor =  .red
    
    func render(context: RenderingContext, size: ProposedSize) {
        context.saveGState()
        context.setFillColor(color.cgColor)
        context.addPath(shape.path(in: CGRect(origin: .zero, size: size)))
        context.fillPath()
        context.restoreGState()
    }
}

19:05 Then we conform NSColor to View_ using a shape view with a rectangle:

extension NSColor: View_ {
    var body: some View_ {
        ShapeView(shape: Rectangle_(), color: self)
    }
}

19:47 Now any NSColor can be used as a view:

let sample = Color.blue

Next Week

20:22 We haven't really done any layout yet, but we've prepared a lot of the infrastructure we need to get started with it. Next up, we'll add some constraints for frame sizes so that we can control a view's frame instead of letting it fill up the entire space. And once we've defined a view's frame, we can try to align it to the edges of its parent view.

Resources

  • Sample Code

    Written in Swift 5.3

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

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