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 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 be a CGContext
. And 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 BuiltinView
s 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, 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 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.