WWDC 19
Building Custom Views with SwiftUI
Layout for a simple view with just text:
- text sizes to fit the contents
- the view's content view contains everything inside, i.e. the text, and also has the size needed to fit all the subviews with their preferred size
- the root view of the screen takes the whole frame of the screen and centers the small content view inside itself
By default the root view does not fill the safe area at the top – to make it really fill the whole screen, use .edgesIgnoringSafeArea(.all)
The layout process in general goes like this:
- 1. The parent proposes a size to the child (the maximum area that it can offer)
- 2. The child chooses its own preferred size
- 3. Parent places the child inside its coordinate space, by default in the middle
This means that subviews have their own sizing behaviors, the child decides what size it needs for itself
If a view uses a modifer like .aspectRatio(1)
or .frame(width: 50, height 10)
, the parent has to obey this
Coordinates are always rounded to the nearest pixel, so there are crisp lines and no antialiasing
.background()
inserts a view wrapping directly the view it’s called on, and it always has the same bounds as that view – so it can be useful for debugging to see the actual sizes of each view
A background view is “layout neutral”, so it doesn’t affect the layout at all
.padding()
adds padding around the view – if not specified, SwiftUI chooses the default amount of padding appropriate for the given element, platform and environment; it offers its subview slightly smaller area than it was offered, inset by the specified padding
A Color
view fills whatever space it’s given
Images:
Images are by default fixed size (equal to the image dimensions), unless you mark them as resizable – so just applying .frame(…)
to an image won’t change its size, it just wraps it in a larger frame (empty on the sides)
A .frame()
is *not* a constraint like in AutoLayout – it’s just a wrapping view that proposes a specified size to its child – which the child can take into account or not, depending on the type of view
“There is no such thing as an incorrect layout… unless you don’t like the result you’re getting” :D
SwiftUI automatically applies spacing between elements depending on their kind
You can override the spacings, but the defaults should usually be the right values
When you view the layout in a right-to-left language, SwiftUI automatically switches all subviews in the correct direction
Stacks:
A stack takes the space it was given from the parent, deducts the spacing and divides it equally into children, then proposes that space to children starting with the least flexible ones (e.g. a fixed size image)
If they don’t take all available space, they’re aligned inside the stack according to specified alignment, and then the stack sets its size to enclose all the children
To define which children in a stack take more available space if there isn’t enough, use .layoutPriority(x)
to specify their priority (default is 0)
Vertical alignment:
Don’t align labels in an HStack
to .bottom
– align them to the baseline instead (.lastTextBaseline
)
If there are images too, by default the image’s baseline is the bottom edge, but you can override it this way:
.alignmentGuide(.lastTextBaseline) { d in d[.bottom] * 0.927 }
Aligning views in separate branches of the view tree:
To align views that are in separate stacks to each other, you need to specify a custom named alignment guide:
extension VerticalAlignment { private enum MidStarAndTitle: AlignmentID { static func defaultValue(in d: ViewDimensions) -> Length { return d[.bottom] // not important } } static let midStarAndTitle = VerticalAlignment(MidStarAndTitle.self) }
Now, any view with an added modifier .alignmentGuide(.midStarAndTitle) { … }
will be aligned to the same line
In the block, specify how to calculate the position of the guide in the view:
.alignmentGuide(.midStarAndTitle) { d in d[.bottom] / 2 }
Drawing graphics:
Graphics in SwiftUI is done with special kinds of views representing shapes, but they’re views just like the buttons and labels, so everything about buttons and labels applies to drawing, and all the effects done for drawing can also be applied to controls
Circle(), Capsule(), Ellipse() Circle().fill(Color.red) Capsule().stroke(red, lineWidth: 20) Ellipse().strokeBorder(red, style: …) Gradient(colors: [.red, .yellow, …]) AngularGradient(gradient: spectrum, center: .center, angle: .degrees(-90))
Views like Color
or Gradient
act as independent views themselves, or can be used as a fill for shapes:
Circle().fill(angularGradient) Circle().strokeBorder(gradient, lineWidth: 50)
Custom shapes:
To define a custom shape, build a struct conforming to the Shape
protocol that defines a path:
struct WedgeShape: Shape { var wedge: Ring.Wedge func path(in rect: CGRect) -> Path { var p = Path() p.addArc(…) p.addLine(…) p.closeSubpath() return p } }
Custom transition:
struct ScaleAndFade: ViewModifier { var isActive: Bool func body(content: Content) -> some view { return content .scaleEffect(isActive ? 0.1 : 1) .opacity(isActive ? 0 : 1) } } let scaleAndFade = AnyTransition.modifier( active: ScaleAndFade(isActive: true), identity: ScaleAndFade(isActive: false) ) .transition(scaleAndFade)
When drawing with a lot of elements, mark all shapes inside a container as a “drawing group” (.drawingGroup()
) to give a hint to the rendering engine to flatten them all into one native view or layer and render the contents using Metal