SwiftUI makes it easy to create beautiful UIs in no time, but it is just as easy to end up with a giant view that mixes view code and business logic. Fortunately, Apple gave us some tools to keep the bloat in check and write maintainable and reusable code.
In this talk, I am going to show you how to
- refactor an existing SwiftUI view to make it more maintainable,
- turn it into a reusable SwiftUI component,
- add event handling,
- make the view configurable,
- use SwiftUI's styling API to apply different designs,
- add it to the Xcode component library,
- turn it into a shareable component that can be consumed via Swift Package Manager,
- and distribute it via GitHub
47. SwiftUI Layout Behaviour
Building layouts with stack views https://apple.co/448CaWL
Created by redemption_art
from the Noun Project
Created by redemption_art
from the Noun Project
Expanding Hugging
50. HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
Text(user.fullName)
.font(.headline)
Text(user.affiliation)
.font(.subheadline)
}
}
Image is an expanding view
Created by redemption_art
from the Noun Project
ā¦ but we explicitly constrained it
53. Move to file
Extract Subview
Extract to local subview (property)
Extract to local subview (function)
54. Extract to local subview (property)
struct AvatarView: View {
var user: User
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
Text(user.fullName)
.font(.headline)
Text(user.affiliation)
.font(.subheadline)
}
Spacer()
}
}
}
ā» Extract to local subview
55. Extract to local subview (property)
struct AvatarView: View {
var user: User
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
Text(user.affiliation)
.font(.subheadline)
}
var titleLabel: some View {
}
Text(user.fullName)
.font(.headline)
56. Extract to local subview (property)
struct AvatarView: View {
var user: User
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
Text(user.affiliation)
.font(.subheadline)
}
var titleLabel: some View {
}
Text(user.fullName)
.font(.headline)
57. Extract to local subview (property)
struct AvatarView: View {
var user: User
var titleLabel: some View {
Text(user.fullName)
.font(.headline)
}
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
Text(user.affiliation)
.font(.subheadline)
}
Spacer()
titleLabel
58. Dear Appleā¦
Make Extract to Subview work all of the time
Extract to Subview: handle dependent
properties
Add Extract to File
Add Extract to local Subview
59. Move to file
Extract Subview
Extract to local subview (property)
Extract to local subview (function)
60. Extract to local subview (function)
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
titleLabel
Text("(Image(systemName: "graduationcap")) (user.jobtitle)")
.font(.subheadline)
Text("(Image(systemName: "building.2")) (user.affiliation)")
.font(.subheadline)
}
Spacer()
}
}
struct AvatarView: View {
var user: User
var titleLabel: some View {!!#}
61. Extract to local subview (function)
struct AvatarView: View {
var user: User
var titleLabel: some View {!!#}
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
titleLabel
Text("(Image(systemName: "graduationcap")) (user.jobtitle)")
.font(.subheadline)
Text("(Image(systemName: "building.2")) (user.affiliation)")
func detailsLabel(_ text: String, systemName: String) !$ some View {
}
62. Extract to local subview (function)
struct AvatarView: View {
var user: User
var titleLabel: some View {!!#}
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
titleLabel
Text("(Image(systemName: "building.2")) (user.affiliation)")
func detailsLabel(_ text: String, systemName: String) !$ some View {
}
Text("(Image(systemName: "graduationcap")) (user.jobtitle)")
.font(.subheadline)
63. Extract to local subview (function)
struct AvatarView: View {
var user: User
var titleLabel: some View {!!#}
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
titleLabel
Text("(Image(systemName: "building.2")) (user.affiliation)")
func detailsLabel(_ text: String, systemName: String) !$ some View {
}
Text("(Image(systemName: "graduationcap")) (user.jobtitle)")
.font(.subheadline)
64. Extract to local subview (function)
struct AvatarView: View {
var user: User
var titleLabel: some View {!!#}
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
titleLabel
Text("(Im
func detailsLabel(_ text: String, systemName: String) !$ some View {
}
Text("(Image(systemName:
.font(.subheadline)
"graduationcap")) (user.jobtitle)")
"(systemName)")) (text)")
Text("(Image(systemName: "building.2")) (user.affiliation)")
65. Extract to local subview (function)
struct AvatarView: View {
var user: User
var titleLabel: some View {!!#}
func detailsLabel(_ text: String, systemName: String) !$ some View {
Text("(Image(systemName: "(systemName)")) (text)")
.font(.subheadline)
}
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
titleLabel
Text("(Image(systemName: "building.2")) (user.affiliation)")
.font(.subheadline)
detailsLabel(user.jobtitle, systemName: "graduationcap")
detailsLabel(user.affiliation, systemName: "building.2")
66. Extract to local subview (function)
struct AvatarView: View {
var user: User
var titleLabel: some View {!!#}
func detailsLabel(_ text: String, systemName: String) !$ some View {
Text("(Image(systemName: "(systemName)")) (text)")
.font(.subheadline)
}
var body: some View {
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
titleLabel
detailsLabel(user.jobtitle, systemName: "graduationcap")
detailsLabel(user.affiliation, systemName: "building.2")
}
67. Techniques for composing views
Building a simple component
Configuring views
Styling views
Distributing views
69. View body
import SwiftUI
struct ContentView: View {
@State var users = User.samples
var body: some View {
List(users) { user in
HStack(alignment: .top) {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
VStack(alignment: .leading) {
Text(user.fullName)
.font(.headline)
Text(user.affiliation)
.font(.subheadline)
}
}
}
}
}
70. Local properties
View body
struct AvatarView: View {
var user: User
var titleLabel: some View {
Text(user.fullName)
.font(.headline)
}
var body: some View {
!!#
titleLabel
!!#
}
}
71. Local properties
View body
Local functions
struct AvatarView: View {
var user: User
func detailsLabel(_ text: String,
systemName: String) !$ some View {
Text("(Image(systemName: "(systemName)")) (text)")
.font(.subheadline)
}
var body: some View {
!!#
detailsLabel(user.jobtitle, systemName: "graduationcap")
detailsLabel(user.affiliation, systemName: ābuilding.2")
!!#
}
}
73. View Builders
@ViewBuilder usage explained with code examples (SwiftLee) https://bit.ly/44bp37t
The @ViewBuilder attribute is one of the few result builders
available for you to use in SwiftUI.
You typically use it to create child views for a specific
SwiftUI view in a readable way without having to use any
return keywords.
ā
74. View Builders
struct AvatarView: View {
var user: User
var titleLabel: some View {
Text(user.fullName)
.font(.headline)
}
func detailsLabel(_ text: String,
systemName: String) !$ some View {
Text("(Image(systemName: "(systemName)")) (text)")
.font(.subheadline)
}
var body: some View {
!!#
detailsLabel(user.jobtitle, systemName: "graduationcap")
detailsLabel(user.affiliation, systemName: ābuilding.2")
!!#
}
Why no @ViewBuilder?
75. View Builders
struct AvatarView: View {
var user: User
var hero: some View {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
var body: some View {
HStack(alignment: .top) {
hero
VStack(alignment: .leading) {!!# }
}
}
}
76. View Builders
struct AvatarView: View {
var isRound = true
var user: User
var hero: some View {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
var body: some View {
HStack(alignment: .top) {
hero
VStack(alignment: .leading) {!!# }
}
}
}
77. ā Branches have mismatching types 'some View'
View Builders
struct AvatarView: View {
var isRound = true
var user: User
var hero: some View {
if isRound {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
else {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
}
}
var body: some View {
Self.clipShape(_:style:)
Self.frame(width:height:alignment:)
78. View Builders
struct AvatarView: View {
var isRound = true
var user: User
var hero: some View {
if isRound {
AnyView {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
}
else {
AnyView {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
}
AnyView erases the
viewās type information
79. View Builders
struct AvatarView: View {
var isRound = true
var user: User
var hero: some View {
if isRound {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
else {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
}
}
@ViewBuilder @ViewBuilder keeps the
type information
80. View Builders
How to avoid using AnyView in SwiftUI (Tanaschita.com)
ViewBuilder vs. AnyView (Alexito's World)
https://bit.ly/45kVOQI
https://bit.ly/3OUbViE
Use @ViewBuilder if you want to return structurally
different views from a property / function.
ā
81. Techniques for composing views
Building a simple component
Configuring views
Styling views
Distributing views
83. Configuring views
struct ContentView: View {
@State var users = User.samples
var body: some View {
List(users) { user in
AvatarView(isRound: true, user: user)
}
}
}
Letās turn this into a view modifier
: properties
84. Enum for the shape
enum AvatarImageShape {
case round
case rectangle
}
85. Environment Key
Enum for the shape struct AvatarImageShapeKey: EnvironmentKey {
static var defaultValue: AvatarImageShape = .round
}
enum AvatarImageShape {
case round
case rectangle
}
86. Environment Key
Enum for the shape
Extend Environment
struct AvatarImageShapeKey: EnvironmentKey {
static var defaultValue: AvatarImageShape = .round
}
enum AvatarImageShape {
case round
case rectangle
}
extension EnvironmentValues {
var avatarImageShape: AvatarImageShape {
get { self[AvatarImageShapeKey.self] }
set { self[AvatarImageShapeKey.self] = newValue }
}
}
87. Environment Key
Enum for the shape
Extend Environment
View modifier
struct AvatarImageShapeKey: EnvironmentKey {
static var defaultValue: AvatarImageShape = .round
}
enum AvatarImageShape {
case round
case rectangle
}
extension EnvironmentValues {
var avatarImageShape: AvatarImageShape {
get { self[AvatarImageShapeKey.self] }
set { self[AvatarImageShapeKey.self] = newValue }
}
}
extension View {
func avatarImageShape(_ imageShape:
AvatarImageShape) !$ some View {
environment(.avatarImageShape, imageShape)
}
}
88. Environment Key
Enum for the shape
Extend Environment
View modifier
Update view code
struct AvatarView: View {
var isRound = true
var user: User
@ViewBuilder
var hero: some View {
if isRound {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
else {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
}
}
!!#
}
var isRound = true
89. Environment Key
Enum for the shape
Extend Environment
View modifier
Update view code
struct AvatarView: View {
var user: User
@ViewBuilder
var hero: some View {
if isRound {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
else {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
}
}
!!#
}
var isRound = true
@Environment(.avatarImageShape) var imageShape
90. Environment Key
Enum for the shape
Extend Environment
View modifier
Update view code
struct AvatarView: View {
var user: User
@ViewBuilder
var hero: some View {
if {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
.clipShape(Circle(), style: FillStyle())
}
else {
Image(user.profileImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 75)
}
}
!!#
}
@Environment(.avatarImageShape) var imageShape
imageShape !% .round
91. Environment Key
Enum for the shape
Extend Environment
View modifier
Update view code
Usage at the call site
struct ContentView: View {
@State var users = User.samples
var body: some View {
List(users) { user in
AvatarView(user: user)
.avatarImageShape(.rectangle)
}
}
}
92. Environment Key
Enum for the shape
Extend Environment
View modifier
Update view code
Usage at the call site
struct ContentView: View {
@State var users = User.samples
var body: some View {
List(users) { user in
AvatarView(user: user)
.avatarImageShape(.rectangle)
}
}
}
struct ContentView: View {
@State var users = User.samples
var body: some View {
List(users) { user in
AvatarView(user: user)
}
.avatarImageShape(.rectangle)
}
}
93. Environment Key
Enum for the shape
Extend Environment
View modifier
Update view code
Usage at the call site
struct ContentView: View {
@State var users = User.samples
var body: some View {
List(users) { user in
AvatarView(user: user)
}
.avatarImageShape(.rectangle)
}
}
struct ContentView: View {
@State var users = User.samples
var body: some View {
List(users) { user in
AvatarView(user: user)
.avatarImageShape(
user.isTalking
? .round
: .rectangle)
}
.avatarImageShape(.rectangle)
}
}
94. Configuring views
struct ContentView: View {
var body: some View {
AvatarView(user: User.sample)
.onEditProfile {
print("onEditProfile triggered")
}
}
}
Letās register an action handler
: action handlers
104. Styling views
View styles (Apple docs)
Styling SwiftUI Views (peterfriese.dev)
https://bit.ly/3DYKmyu
https://bit.ly/3qFbJKC
SwiftUI defines built-in styles for certain kinds of views and
automatically selects the appropriate style for a particular
presentation context. [ā¦]
You can override the automatic style by using one of the
style view modifiers. These modifiers typically propagate
throughout a container view, so that you can wrap a view
hierarchy in a style modifier to affect all the views of the
given type within the hierarchy.
ā
114. Styling views
View styles (Apple docs)
Styling SwiftUI Views (peterfriese.dev)
https://bit.ly/3DYKmyu
https://bit.ly/3qFbJKC
SwiftUI defines built-in styles for certain kinds of views and automatically
selects the appropriate style for a particular presentation context. [ā¦]
You can override the automatic style by using one of the style view
modifiers. These modifiers typically propagate throughout a container view,
so that you can wrap a view hierarchy in a style modifier to affect all the
views of the given type within the hierarchy.
ā Context:
* Platform
* Container
* Use case
Propagation:
* Design system
115.
116. Create a configuration
struct AvatarStyleConfiguration {
let title: Title
struct Title: View {
let underlyingTitle: AnyView
init(_ title: some View) {
self.underlyingTitle = AnyView(title)
}
var body: some View {
underlyingTitle
}
}
117. Create a configuration
struct Title: View {
let underlyingTitle: AnyView
init(_ title: some View) {
self.underlyingTitle = AnyView(title)
}
var body: some View {
underlyingTitle
}
}
let subTitle: SubTitle
struct SubTitle: View {
let underlyingSubTitle: AnyView
init(_ subTitle: some View) {
self.underlyingSubTitle = AnyView(subTitle)
}
var body: some View {
underlyingSubTitle
}
}
118. Create a configuration
struct SubTitle: View {
let underlyingSubTitle: AnyView
init(_ subTitle: some View) {
self.underlyingSubTitle = AnyView(subTitle)
}
var body: some View {
underlyingSubTitle
}
}
let image: Image
init(title: Title,
subTitle: SubTitle,
image: Image) {
self.title = title
self.subTitle = subTitle
self.image = image
}
}
119. Define a style protocol
Create a configuration
let image: Image
init(title: Title,
subTitle: SubTitle,
image: Image) {
self.title = title
self.subTitle = subTitle
self.image = image
}
}
protocol AvatarStyle {
associatedtype Body: View
@ViewBuilder
func makeBody(configuration: Configuration)
!$ Body
typealias Configuration = AvatarStyleConfiguration
}
120. Define a style protocol
Create a configuration
Set up environment
protocol AvatarStyle {
associatedtype Body: View
@ViewBuilder
func makeBody(configuration: Configuration)
!$ Body
typealias Configuration = AvatarStyleConfiguration
}
struct AvatarStyleKey: EnvironmentKey {
static var defaultValue:
any AvatarStyle = DefaultAvatarStyle()
}
extension EnvironmentValues {
var avatarStyle: any AvatarStyle {
get { self[AvatarStyleKey.self] }
set { self[AvatarStyleKey.self] = newValue }
}
}
121. Define a style protocol
Create a configuration
Set up environment
struct AvatarStyleKey: EnvironmentKey {
static var defaultValue:
any AvatarStyle = DefaultAvatarStyle()
}
extension EnvironmentValues {
var avatarStyle: any AvatarStyle {
get { self[AvatarStyleKey.self] }
set { self[AvatarStyleKey.self] = newValue }
}
}
extension View {
func avatarStyle(_ style: some AvatarStyle)
!$ some View {
environment(.avatarStyle, style)
}
}
123. Define a style protocol
Create a configuration
Set up environment
Implement style
Update view code
struct AvatarView: View {
@Environment(.avatarImageShape) var imageShape
@Environment(.editProfileHandler)
var editProfileHandler
@Environment(.avatarStyle) var style
var title: String
var subTitle: String
var imageName: String
init(_ title: String, subTitle: String,
image name: String) {
self.title = title
self.subTitle = subTitle
self.imageName = name
}
124. Define a style protocol
Create a configuration
Set up environment
Implement style
Update view code
var title: String
var subTitle: String
var imageName: String
init(_ title: String, subTitle: String,
image name: String) {
self.title = title
self.subTitle = subTitle
self.imageName = name
}
var body: some View {
let configuration = AvatarStyleConfiguration(
title: .init(Text(title)),
subTitle: .init(Text(subTitle)),
image: .init(imageName))
AnyView(
style.makeBody(configuration: configuration)
)
}
}
125. Define a style protocol
Create a configuration
Set up environment
Implement style
Update view code
var title: String
var subTitle: String
var imageName: String
init(_ title: String, subTitle: String,
image name: String) {
self.title = title
self.subTitle = subTitle
self.imageName = name
}
var body: some View {
let configuration = AvatarStyleConfiguration(
title: .init(Text(title)),
subTitle: .init(Text(subTitle)),
image: .init(imageName))
AnyView(
style.makeBody(configuration: configuration)
)
}
}
126. Define a style protocol
Create a configuration
Set up environment
Implement style
Update view code
Usage at the call site
struct ContentView: View {
@State var users = User.samples
@State var isProfileShowing = false
var body: some View {
List(users) { user in
AvatarView(user.fullName,
subTitle: user.affiliation,
image: user.profileImageName)
.avatarStyle(.automatic)
}
}
}
127. struct ContentView: View {
@State var users = User.samples
@State var isProfileShowing = false
var body: some View {
List(users) { user in
AvatarView(user.fullName,
subTitle: user.affiliation,
image: user.profileImageName)
.avatarStyle(.automatic)
}
}
}
138. Dear Appleā¦
Make Extract to Subview work all of the time
Extract to Subview: handle dependent
properties
Add Extract to File
Add Extract to local Subview
Add Extract to Package
143. Dear Appleā¦
Make Extract to Subview work all of the time
Extract to Subview: handle dependent
properties
Add Extract to File
Add Extract to local Subview
Add Extract to Package
Rich previews for the Xcode Component Library