$30 off During Our Annual Pro Sale. View Details »

Building Reusable SwiftUI Components

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

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