Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building Reusable SwiftUI Components

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

Building Reusable SwiftUI Components

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

Avatar for Peter Friese

Peter Friese

August 17, 2023
Tweet

More Decks by Peter Friese

Other Decks in Technology

Transcript

  1. import SwiftUI struct ContentView: View { var body: some View

    { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() } }
  2. import SwiftUI struct ContentView: View { var body: some View

    { HStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() } }
  3. import SwiftUI struct ContentView: View { var body: some View

    { List(0 !!" 5) { item in HStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() } } }
  4. import SwiftUI struct ContentView: View { var body: some View

    { List(0 !!" 5) { item in HStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } } } }
  5. import SwiftUI struct ContentView: View { @State var users =

    User.samples var body: some View { List(users) { user in HStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text(user.fullName) } } } }
  6. import SwiftUI struct ContentView: View { @State var users =

    User.samples var body: some View { List(users) { user in HStack { Image(user.profileImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 75) .clipShape(Circle(), style: FillStyle()) Text(user.fullName) } } } }
  7. import SwiftUI struct ContentView: View { @State var users =

    User.samples var body: some View { List(users) { user in HStack { Image(user.profileImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 75) .clipShape(Circle(), style: FillStyle()) VStack { Text(user.fullName) Text(user.affiliation) } } } } }
  8. 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) Text(user.affiliation) } } } } }
  9. 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) } } } } }
  10. Extract Subview 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) } } } } } ♻ Extract Subview
  11. Extract Subview struct ContentView: View { @State var users =

    User.samples var body: some View { List(users) { user in ExtractedView() } } } struct ExtractedView: 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) { Text(user.fullName) .font(.headline) Text(user.affiliation)
  12. Extract Subview struct ContentView: View { @State var users =

    User.samples var body: some View { List(users) { user in ExtractedView() } } } struct ExtractedView: 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) { Text(user.fullName) .font(.headline) Text(user.affiliation)
  13. Extract Subview struct ContentView: View { @State var users =

    User.samples var body: some View { List(users) { user in ExtractedView() } } } struct ExtractedView: 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) { Text(user.fullName) .font(.headline) Text(user.affiliation) ♻ Rename
  14. Extract Subview struct ContentView: View { @State var users =

    User.samples var body: some View { List(users) { user in AvatarView() } } } struct AvatarView: 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) { Text(user.fullName) .font(.headline) Text(user.affiliation)
  15. ❌ Cannot find ‘user’ in scope ❌ Cannot find ‘user’

    in scope ❌ Cannot find ‘user’ in scope Extract Subview struct ContentView: View { @State var users = User.samples var body: some View { List(users) { user in AvatarView() } } } struct AvatarView: 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) { Text(user.fullName) .font(.headline) Text(user.affiliation)
  16. ❌ Cannot find ‘user’ in scope ❌ Cannot find ‘user’

    in scope Extract Subview struct ContentView: View { @State var users = User.samples var body: some View { List(users) { user in AvatarView() } } } struct AvatarView: 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) { Text(user.fullName) .font(.headline) var user: User
  17. ❌ Cannot find ‘user’ in scope ❌ Cannot find ‘user’

    in scope Extract Subview struct ContentView: View { @State var users = User.samples var body: some View { List(users) { user in AvatarView(user: user) } } } 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)
  18. Dear Apple… Make Extract to Subview work all of the

    time Extract to Subview: handle dependent properties
  19. Move to file struct ContentView: View { @State var users

    = User.samples var body: some View { List(users) { user in AvatarView(user: user) } } } 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) ♻ Move to file
  20. Move to file import SwiftUI struct AvatarView: View { var

    body: some View { Text("Hello, World!") } } #Preview { AvatarView() }
  21. ❌ Missing argument for parameter 'user' in call Move to

    file 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) } } } } #Preview { AvatarView() Need to fix the preview
  22. ❌ Missing argument for parameter 'user' in call Move to

    file 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) } } } } #Preview { AvatarView(user: User.sample)
  23. Dear Apple… Make Extract to Subview work all of the

    time Extract to Subview: handle dependent properties Add Extract to File
  24. 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) } } } } #Preview { AvatarView(user: User.sample) } What’s this?!
  25. 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) } } } } #Preview { AvatarView(user: User.sample) }
  26. 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
  27. 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) } } Created by redemption_art from the Noun Project Text is a content-hugging view
  28. 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) } } Created by redemption_art from the Noun Project VStack is a content-hugging view
  29. 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
  30. 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) } } HStack is a content-hugging view Created by redemption_art from the Noun Project
  31. 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() } Spacer is an expanding view Created by redemption_art from the Noun Project
  32. 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
  33. 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)
  34. 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)
  35. 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
  36. 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
  37. 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 {!!#}
  38. 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 { }
  39. 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)
  40. 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)
  41. 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)")
  42. 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")
  43. 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") }
  44. 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) } } } } }
  45. Local properties View body struct AvatarView: View { var user:

    User var titleLabel: some View { Text(user.fullName) .font(.headline) } var body: some View { !!# titleLabel !!# } }
  46. 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") !!# } }
  47. 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. “
  48. 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?
  49. 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) {!!# } } } }
  50. 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) {!!# } } } }
  51. ❌ 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:)
  52. 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
  53. 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
  54. 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. “
  55. 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
  56. Environment Key Enum for the shape struct AvatarImageShapeKey: EnvironmentKey {

    static var defaultValue: AvatarImageShape = .round } enum AvatarImageShape { case round case rectangle }
  57. 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 } } }
  58. 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) } }
  59. 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
  60. 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
  61. 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
  62. 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) } } }
  63. 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) } }
  64. 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) } }
  65. 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
  66. Extend Environment Environment Key struct AvatarEditProfileHandler: EnvironmentKey { static var

    defaultValue: (() !$ Void)? } extension EnvironmentValues { var editProfileHandler: (() !$ Void)? { get { self[AvatarEditProfileHandler.self] } set { self[AvatarEditProfileHandler.self] = newValue } } }
  67. Extend Environment Environment Key View modifier struct AvatarEditProfileHandler: EnvironmentKey {

    static var defaultValue: (() !$ Void)? } extension EnvironmentValues { var editProfileHandler: (() !$ Void)? { get { self[AvatarEditProfileHandler.self] } set { self[AvatarEditProfileHandler.self] = newValue } } } extension View { public func onEditProfile(editProfileHandler: @escaping () !$ Void) !$ some View { environment(\.editProfileHandler, editProfileHandler) } }
  68. Extend Environment Environment Key View modifier Update view code struct

    AvatarView: View { @Environment(\.avatarImageShape) var imageShape !!# } } } var user: User @ViewBuilder var hero: some View { !!# } var body: some View { HStack(alignment: .top) { hero
  69. Extend Environment Environment Key View modifier Update view code struct

    AvatarView: View { @Environment(\.avatarImageShape) var imageShape !!# } } } var user: User @ViewBuilder var hero: some View { !!# } var body: some View { HStack(alignment: .top) { hero @Environment(\.editProfileHandler) var editProfileHandler
  70. Extend Environment Environment Key View modifier Update view code struct

    AvatarView: View { @Environment(\.avatarImageShape) var imageShape var user: User @ViewBuilder var hero: some View { !!# } var body: some View { HStack(alignment: .top) { hero @Environment(\.editProfileHandler) var editProfileHandler !!# } } } .onTapGesture { if let editProfileHandler { editProfileHandler() } }
  71. Extend Environment Environment Key View modifier Update view code Usage

    at the call site struct ContentView: View { @State var isEditing = false var body: some View { AvatarView(user: User.sample) .padding() .onEditProfile { isEditingProfile.toggle() } .sheet(isPresented: $isEditing) { !!# } } }
  72. 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. “
  73. Styling views struct StylingExamples: View { var body: some View

    { Button("Unstyled button") { } Button("Bordered button") { } .buttonStyle(.bordered) Button("Bordered prominent button") { } .buttonStyle(.borderedProminent) Button("Borderless button") { } .buttonStyle(.borderless) Button("Plain button") { } .buttonStyle(.plain) Button("Automatic button") { } .buttonStyle(.automatic) } } : Buttons
  74. Styling views struct ToggleStyleDemoView: View { @State var isOn =

    true var body: some View { VStack { Toggle(isOn: $isOn) { Text("Custom toggle style") } .toggleStyle(.reminder) Toggle(isOn: $isOn) { Text("Default toggle style") } } } } : a custom Toggle style
  75. Style protocol struct ReminderToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) !$

    some View { HStack { Image(systemName: configuration.isOn ? "largecircle.fill.circle" : "circle") .resizable() .frame(width: 24, height: 24) .foregroundColor(configuration.isOn ? .accentColor : .gray) .onTapGesture { configuration.isOn.toggle() } configuration.label } } }
  76. Style protocol struct ReminderToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) !$

    some View { HStack { Image(systemName: configuration.isOn ? "largecircle.fill.circle" : "circle") .resizable() .frame(width: 24, height: 24) .foregroundColor(configuration.isOn ? .accentColor : .gray) .onTapGesture { configuration.isOn.toggle() } configuration.label } } }
  77. Style protocol struct ReminderToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) !$

    some View { HStack { Image(systemName: configuration.isOn ? "largecircle.fill.circle" : "circle") .resizable() .frame(width: 24, height: 24) .foregroundColor(configuration.isOn ? .accentColor : .gray) .onTapGesture { configuration.isOn.toggle() } configuration.label } } }
  78. Style protocol struct ReminderToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) !$

    some View { HStack { Image(systemName: configuration.isOn ? "largecircle.fill.circle" : "circle") .resizable() .frame(width: 24, height: 24) .foregroundColor(configuration.isOn ? .accentColor : .gray) .onTapGesture { configuration.isOn.toggle() } configuration.label } } }
  79. Define style shortcut Style protocol ? .accentColor : .gray) .onTapGesture

    { configuration.isOn.toggle() } configuration.label } } } extension ToggleStyle where Self !% ReminderToggleStyle { static var reminder: ReminderToggleStyle { ReminderToggleStyle() } }
  80. Define style shortcut Style protocol Apply the style extension ToggleStyle

    where Self !% ReminderToggleStyle { static var reminder: ReminderToggleStyle { ReminderToggleStyle() } } struct ToggleStyleDemoView: View { @State var isOn = true var body: some View { VStack { Toggle(isOn: $isOn) { Text("Custom toggle style") } .toggleStyle(.reminder) } } }
  81. 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
  82. 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 } }
  83. 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 } }
  84. 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 } }
  85. 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 }
  86. 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 } } }
  87. 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) } }
  88. Define a style protocol Create a configuration Set up environment

    Implement style struct DefaultAvatarStyle: AvatarStyle { func makeBody(configuration: Configuration) !$ some View { HStack(alignment: .top) { configuration.image .resizable() .aspectRatio(contentMode: .fit) .frame(width: 50, height: 50, alignment: .center) .clipShape(Circle(), style: FillStyle()) VStack(alignment: .leading) { configuration.title .font(.headline) configuration.subTitle .font(.subheadline) } Spacer() } } }
  89. 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 }
  90. 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) ) } }
  91. 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) ) } }
  92. 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) } } }
  93. 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) } } }
  94. Implement style struct ProfileAvatarStyle: AvatarStyle { func makeBody(configuration: Configuration) !$

    some View { VStack(alignment: .center) { configuration.image .resizable() .aspectRatio(contentMode: .fill) .frame(width: 50, height: 50, alignment: .center) .clipShape(Circle(), style: FillStyle()) configuration.title .font(.headline) configuration.subTitle .font(.subheadline) } } }
  95. Define style shortcut Implement style .resizable() .aspectRatio(contentMode: .fill) .frame(width: 50,

    height: 50, alignment: .center) .clipShape(Circle(), style: FillStyle()) configuration.title .font(.headline) configuration.subTitle .font(.subheadline) } } } extension AvatarStyle where Self !% ProfileAvatarStyle { static var profile: Self { .init() } }
  96. Define style shortcut Implement style Usage at the call site

    AvatarView(user.fullName, subTitle: user.affiliation, image: user.profileImageName) .avatarStyle(.profile)
  97. 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
  98. Register views, view modifiers, and styles public struct AvatarView_Library: LibraryContentProvider

    { public var views: [LibraryItem] { [ LibraryItem(AvatarView("Peter", subTitle: "Google", image: ""), title: "AvatarView", category: .control) ] } public func modifiers(base: AvatarView) !$ [LibraryItem] { [ LibraryItem(base.avatarStyle(.profile), title: "Profile", category: .control) ] } }
  99. #

  100. 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
  101. Resources Creating a Styleable Toggle SwiftUI Components Tutorial @ iOSDevUK

    https://bit.ly/3siupAk September 4th - 7th, 2023