MacKuba

Kuba Suder's blog on Mac & iOS development

Building Custom Views with SwiftUI

Categories: SwiftUI, WWDC 19 0 comments Watch the video

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. 1. The parent proposes a size to the child (the maximum area that it can offer)
  2. 2. The child chooses its own preferred size
  3. 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