00:06 Today we want to take a Mac settings screen and try to re-implement
its layout. There are many alignment details we need to pay attention to, and we
want to see how we can translate those ideas into SwiftUI. Ideally, we also
abstract this a bit, so that we can just add sections and controls, and
automatically get the correct layout.
00:30 This project is inspired by an article by Mario
Guzman about these
layouts and alignment rules. The content needs to be centered in the window,
which is very easy to do in SwiftUI. The section labels are right-aligned, the
section content is left-aligned, and the window always ends up exactly as wide
as the total content. None of this is completely straightforward. We've been
wanting to build this view for a long time, and it turns out to be trickier than
expected.
01:18 The article also covers a lot of spacing details, but we won't
focus too much on that because that part is mostly trivial to implement. What
we're really interested in is the overall alignment pattern.
Building an Initial Form
01:30 Let's start by adding a few elements to our view. We can start with
a regular Form, add a section, and give it a header. Inside the section, we
add radio controls, which we can model as a Picker. The picker expects a
title, but we can provide an empty one. For the selection we can use a constant
value for now. We apply the .radioGroup picker style:
struct ContentView: View {
var body: some View {
Form {
Section("General Editing") {
Picker("", selection: .constant(1)) {
Text("Select existing image")
.tag(1)
Text("Add a margin around image")
.tag(0)
}
.pickerStyle(.radioGroup)
}
}
}
}
02:33 We also add a checkbox using a Toggle and a second section:
struct ContentView: View {
var body: some View {
Form {
Section("General Editing") {
Picker("", selection: .constant(1)) {
Text("Select existing image")
.tag(1)
Text("Add a margin around image")
.tag(0)
}
.pickerStyle(.radioGroup)
Toggle("Remember recent items", isOn: .constant(true))
}
Section("Clipboard Settings") {
Toggle("Dither content of clipboard", isOn: .constant(false))
}
}
}
}
03:39 Now we can take a look at the different form styles that are
available. The .automatic style is effectively what we already have. The
.grouped style looks modern but also isn't what we want. There is also a
.columns style, which we hoped was the layout we're looking for, but it seems
to just place the title and content vertically in a VStack.
04:11 What we want instead is to have all section headers in a separate
column to the left, and all content to the right. Achieving that across multiple
HStacks requires a custom alignment guide. Since we are using sections inside
a Form, the correct way forward is to define our own FormStyle.
A Custom FormStyle
04:41 We start writing SettingsFormStyle, conforming to FormStyle.
That requires implementing a makeBody method that uses the provided
configuration:
05:03 The only thing this configuarion struct exposes is a content
property. We don't have many other options than using the Group API that can
iterate over the sections of this content. Thus, we can extract the sections
from configuration.content and iterate over them with ForEach:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
}
}
}
}
06:14 Each section exposes useful properties, such as header and
content. That allows us to start with an HStack where we place the header
first and the content second:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack {
section.header
section.content
}
}
}
}
}
06:33 To see the result, we have to apply our custom form style:
struct ContentView: View {
var body: some View {
Form {
Section("General Editing") {
Picker("", selection: .constant(1)) {
Text("Select existing image")
.tag(1)
Text("Add a margin around image")
.tag(0)
}
.pickerStyle(.radioGroup)
Toggle("Remember recent items", isOn: .constant(true))
}
Section("Clipboard Settings") {
Toggle("Dither content of clipboard", isOn: .constant(false))
}
}
.formStyle(SettingsFormStyle())
}
}
06:46 The result is not quite right. What is happening is that all
children of the header and of the content are placed side by side in a single
HStack. To fix this, we need to wrap the content group in a VStack so that
the controls stack vertically:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack {
section.header
VStack {
section.content
}
}
}
}
}
}
07:21 This already looks better, but the alignment is still off. The
VStack needs to be leading-aligned:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack {
section.header
VStack(alignment: .leading) {
section.content
}
}
}
}
}
}
07:29 The header label also needs to align to the top of the content.
Using a top alignment, or potentially a first text baseline alignment, is
better:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
VStack(alignment: .leading) {
section.content
}
}
}
}
}
}
07:47 The picker still looks slightly odd because of the empty title.
The indentation comes from the hidden label space. By explicitly hiding the
labels on the picker, the layout straightens out:
struct ContentView: View {
var body: some View {
Form {
Section("General Editing") {
Picker("", selection: .constant(1)) {
Text("Select existing image")
.tag(1)
Text("Add a margin around image")
.tag(0)
}
.pickerStyle(.radioGroup)
.labelsHidden()
Toggle("Remember recent items", isOn: .constant(true))
}
Section("Clipboard Settings") {
Toggle("Dither content of clipboard", isOn: .constant(false))
}
}
.formStyle(SettingsFormStyle())
}
}
Aligning the Sections
08:09 The last toggle is still not aligned with the content above it.
The reason is that each section is laid out in its own HStack, and there is no
shared alignment between them. To fix this, we have to define a custom alignment
guide. This guide will represent the boundary between the title column and the
content column.
08:48 We create a custom AlignmentID, named SettingsAlignment, and
then we extend HorizontalAlignment with a new static value to easily access
our custom alignment guide. Inside the guide, we implement defaultValue and
return zero. The specific value does not matter much, since we override it
everywhere, but zero is a sensible default for sections without titles:
struct SettingsAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
0
}
}
extension HorizontalAlignment {
static let settings = HorizontalAlignment(SettingsAlignment.self)
}
10:07 With the custom alignment defined, we can apply it to our layout.
We wrap the entire group of sections in an explicit VStack and we set its
alignment to .settings:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .settings) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
VStack(alignment: .leading) {
section.content
}
}
}
}
}
}
}
10:40 On the section header, we apply the alignment guide, making it use
the trailing edge of the header. This effectively aligns the form sections by
the trailing edges of their titles:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .settings) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
.alignmentGuide(.settings) {
$0[.trailing]
}
VStack(alignment: .leading) {
section.content
}
}
}
}
}
.border(.red)
}
}
10:53 By adding a red border, we can see that the surrounding VStack
becomes exactly as wide to fit its content. If one title is longer, the entire
window grows accordingly. This matches the behavior of the system settings
layout:
TODO screenshot at 11:24
11:36 Next, we want to add dividers between sections. Conceptually, a
divider spans across both the title and content columns. We can try placing a
Divider below each HStack:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .settings) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
.alignmentGuide(.settings) {
$0[.trailing]
}
VStack(alignment: .leading) {
section.content
}
}
Divider()
}
}
}
}
}
12:06 This does not work as expected. The divider accepts the full width
proposal it receives and ends up constrained to the content column instead of
spanning the full width. In this context, the divider behaves like a flexible
rectangle that fills the proposed space:
TODO screenshot at 12:21
12:52 One possible workaround is to place the divider in an overlay
aligned to the bottom of the section, combined with some bottom padding:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .settings) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
.alignmentGuide(.settings) {
$0[.trailing]
}
VStack(alignment: .leading) {
section.content
}
}
.padding(.bottom, 12)
.overlay(alignment: .bottom) {
Divider()
}
}
}
}
}
}
This gets closer, but it is still not correct. The bottom divider only looks
right because it happens to be the widest element. If the content were narrower,
the issue would still be visible.
TODO screenshot at 13:25
13:25 The core problem is that we do not want the divider to accept the
width proposal. Using an overlay avoids that, but then the divider width is tied
to each individual section, which is also not ideal. Another option would be to
offset the divider using an alignment guide by exactly the width of the title
column. That would require knowing that width. A more robust solution is to
measure the title widths directly and base the layout on that information.
14:30 We can switch back to a leading-aligned layout and explicitly
measure the widest title. To do this, we define a preference key called
MaxTitleWidthKey with a default value of zero. In the reduce function, we
take the maximum value:
struct MaxTitleWidthKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
15:08 To make this easier to use, we add a View extension that
measures the width using an overlay with a GeometryReader:
extension View {
func measureMaxTitleWidth() -> some View {
overlay {
GeometryReader { proxy in
Color.clear.preference(key: MaxTitleWidthKey.self, value: proxy.size.width)
}
}
}
}
15:44 On the header view, we no longer need the custom alignment guide.
Instead, we measure the header width and then apply a fixed frame using the
computed maximum title width:
struct SettingsFormStyle: FormStyle {
@State private var maxTitleWidth: CGFloat = 0
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 12) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
.measureMaxTitleWidth()
.fixedSize()
.frame(width: maxTitleWidth)
VStack(alignment: .leading) {
section.content
}
}
.padding(.bottom, 12)
.overlay(alignment: .bottom) {
Divider()
}
}
}
}
.onPreferenceChange(MaxTitleWidthKey.self) {
maxTitleWidth = $0
}
}
}
16:45 This produces the same alignment effect as before. It also
partially fixes the divider behavior, but the bottom divider is still incorrect.
Hiding the last divider would mask the issue, but adding a third section makes
the problem visible again. At this point, the overlay-based divider is no longer
ideal.
Measuring Section Widths
18:00 Let's apply the same measurement approach to the entire section
width. The divider should span the full width of the widest section. We
introduce another preference key, MaxSectionWidthKey, duplicating the
structure of the title width key. We also duplicate the corresponding
measurement helper for now:
struct MaxTitleWidthKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct MaxSectionWidthKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
extension View {
func measureMaxTitleWidth() -> some View {
overlay {
GeometryReader { proxy in
Color.clear.preference(key: MaxTitleWidthKey.self, value: proxy.size.width)
}
}
}
func measureMaxSectionWidth() -> some View {
overlay {
GeometryReader { proxy in
Color.clear.preference(key: MaxSectionWidthKey.self, value: proxy.size.width)
}
}
}
}
19:09 We apply this measurement to each section HStack. We add
another onPreferenceChange modifier to capture the maximum section width and
store it in a second state property, maxSectionWidth. With that value, we can
size the divider explicitly:
struct SettingsFormStyle: FormStyle {
@State private var maxTitleWidth: CGFloat = 0
@State private var maxSectionWidth: CGFloat = 0
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 12) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
.measureMaxTitleWidth()
.fixedSize()
.frame(width: maxTitleWidth)
VStack(alignment: .leading) {
section.content
}
}
.padding(.bottom, 12)
.measureMaxSectionWidth()
.overlay(alignment: .bottom) {
Divider()
.frame(width: maxSectionWidth)
}
}
}
}
.onPreferenceChange(MaxTitleWidthKey.self) {
maxTitleWidth = $0
}
.onPreferenceChange(MaxSectionWidthKey.self) {
maxSectionWidth = $0
}
}
}
20:03 The divider overlay needs to be aligned bottom-leading. Without
that alignment, it is offset incorrectly and even runs off-screen. With
bottom-leading alignment, it moves into the correct position:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 12) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
}
.padding(.bottom, 12)
.measureMaxSectionWidth()
.overlay(alignment: .bottomLeading) {
Divider()
.frame(width: maxSectionWidth)
}
}
}
}
}
}
20:24 At this point, the divider no longer needs to live in an overlay.
Since we are giving it a fixed frame, we can remove the overlay entirely. This
also means the VStack spacing naturally becomes the spacing between dividers,
and we no longer need extra bottom padding:
struct SettingsFormStyle: FormStyle {
@State private var maxTitleWidth: CGFloat = 0
@State private var maxSectionWidth: CGFloat = 0
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 12) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
section.header
.measureMaxTitleWidth()
.fixedSize()
.frame(width: maxTitleWidth)
VStack(alignment: .leading) {
section.content
}
}
.measureMaxSectionWidth()
Divider()
.frame(width: maxSectionWidth)
}
}
}
.onPreferenceChange(MaxTitleWidthKey.self) {
maxTitleWidth = $0
}
.onPreferenceChange(MaxSectionWidthKey.self) {
maxSectionWidth = $0
}
}
}
20:57 The only remaining issue is the last divider. We can resolve that
by conditionally rendering the divider only if the current section is not the
last one:
struct SettingsFormStyle: FormStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 12) {
Group(sections: configuration.content) { sections in
ForEach(sections) { section in
HStack(alignment: .top) {
}
.measureMaxSectionWidth()
if section.id != sections.last?.id {
Divider()
.frame(width: maxSectionWidth)
}
}
}
}
}
}
Next Steps
21:24 This ends up being a fair amount of work for what looks like a
simple layout. There is also a lot of duplication in the preference keys and
measurement helpers, which clearly needs cleanup. Another question is whether
composing everything from VStacks and HStacks is the best approach; perhaps
we could explore using a Grid-based layout.