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

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,
- 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

June 25, 2022
Tweet

More Decks by Peter Friese

Other Decks in Technology

Transcript

  1. Hello World! import SwiftUI struct ContentView: View { var body:

    some View { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) Text("Hello, world!") } } } Add some state
  2. Hello World! import SwiftUI struct ContentView: View { var body:

    some View { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) Text("Hello, world!") } } } @State var books = Book.samples Add some state
  3. Hello World! import SwiftUI struct ContentView: View { @State var

    books = Book.samples var body: some View { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) Text("Hello, world!") } } } ♻ Embed in List
  4. Hello World! import SwiftUI struct ContentView: View { @State var

    books = Book.samples var body: some View { List(0 ..< 5) { item in VStack { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) Text("Hello, world!") } } } } ♻ Embed in List
  5. Hello World! import SwiftUI struct ContentView: View { @State var

    books = Book.samples var body: some View { List(0 ..< 5) { item in VStack { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) Text("Hello, world!") } } } } Change to HStack
  6. Hello World! import SwiftUI struct ContentView: View { @State var

    books = Book.samples var body: some View { List(0 ..< 5) { item in HStack { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) Text("Hello, world!") } } } } Embed in List Bind state Change to HStack
  7. Hello World! import SwiftUI struct ContentView: View { @State var

    books = Book.samples var body: some View { List(books) { book in HStack { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) Text(book.title) } } } } Embed in List Bind state Change to HStack Use book image
  8. Hello World! import SwiftUI struct ContentView: View { @State var

    books = Book.samples var body: some View { List(books) { book in HStack { Text(book.title) } } } } Use book image Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90)
  9. Hello World! import SwiftUI struct ContentView: View { @State var

    books = Book.samples var body: some View { List(books) { book in HStack { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) Text(book.title) } } } } ♻ Embed in VStack Use book image
  10. import SwiftUI struct ContentView: View { @State var books =

    Book.samples var body: some View { List(books) { book in HStack { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack { Text(book.title) } } } } Hello World! Use book image Add more details
  11. import SwiftUI struct ContentView: View { @State var books =

    Book.samples var body: some View { List(books) { book in HStack { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack { Text(book.title) } Hello World! Use book image Add more details Fix alignments .font(.headline) Text("by \book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline)
  12. import SwiftUI struct ContentView: View { @State var books =

    Book.samples var body: some View { List(books) { book in HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } Hello World! Use book image Add more details Fix alignments
  13. import SwiftUI struct ContentView: View { @State var books =

    Book.samples var body: some View { List(books) { book in HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } Hello World! Use book image Add more details Fix alignments ⚠
  14. struct ContentView: View { @State var books = Book.samples var

    body: some View { List(books) { book in HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } } } } } ♻ Extract Subview
  15. struct ContentView: View { @State var books = Book.samples var

    body: some View { List(books) { book in BookRowView() } } } struct BookRowView: View { var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") ♻ Extract Subview
  16. struct ContentView: View { @State var books = Book.samples var

    body: some View { List(books) { book in BookRowView() } } } struct BookRowView: View { var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") ♻ Extract Subview
  17. struct ContentView: View { @State var books = Book.samples var

    body: some View { List(books) { book in BookRowView() } } } struct BookRowView: View { var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") ♻ Extract Subview Refactorings Extract to Subview for reusable parts of the UI (and for a cleaner structure)
  18. ❌ Cannot find ‘book’ in scope ❌ Cannot find ‘book’

    in scope ❌ Cannot find ‘book’ in scope struct ContentView: View { @State var books = Book.samples var body: some View { List(books) { book in BookRowView() } } } struct BookRowView: View { var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") ♻ Extract Subview
  19. ❌ Cannot find ‘book’ in scope ❌ Cannot find ‘book’

    in scope struct ContentView: View { @State var books = Book.samples var body: some View { List(books) { book in BookRowView() } } } struct BookRowView: View { var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) ♻ Extract Subview var book: Book
  20. ❌ Cannot find ‘book’ in scope ❌ Cannot find ‘book’

    in scope struct ContentView: View { @State var books = Book.samples var body: some View { List(books) { book in BookRowView(book: book) } } } struct BookRowView: View { var book: Book var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) ♻ Extract Subview
  21. Peter’s Wishlist Make Extract to Subview work all of the

    time Extract to Subview: handle dependent properties Add Extract to File
  22. struct ContentView: View { @State var books = Book.samples var

    body: some View { List(books) { book in BookRowView(book: book) } } } struct BookRowView: View { var book: Book var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline)
  23. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } } } } ?
  24. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } } } } Spacer()
  25. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } } } } Spacer()
  26. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } } } } Spacer() ♻ Extract local Subview
  27. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text(book.title) .font(.headline) Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) var titleLabel: some View { } ♻ Extract local Subview Text(book.title) .font(.headline)
  28. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) var titleLabel: some View { Text(book.title) .font(.headline) } ♻ Extract local Subview Text(book.title) .font(.headline)
  29. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) var titleLabel: some View { Text(book.title) .font(.headline) } ♻ Extract local Subview Text(book.title) .font(.headline) titleLabel
  30. struct BookRowView: View { var book: Book var body: some

    View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { titleLabel Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } var titleLabel: some View { Text(book.title) .font(.headline) } ♻ Extract local Subview Text(book.title) .font(.headline)
  31. Peter’s Wishlist Make Extract to Subview work all of the

    time Extract to Subview: handle dependent properties Add Extract to File Add Extract to local Subview
  32. struct BookRowView: View { var book: Book var titleLabel: some

    View { Text(book.title) .font(.headline) } var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { titleLabel Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } ♻ Extract local function
  33. Text(book.title) .font(.headline) } var body: some View { HStack(alignment: .top)

    { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { titleLabel Text("by \(book.author)") .font(.subheadline) Text("\(book.pages) pages") .font(.subheadline) } ♻ Extract local function func detailsLabel(_ text: String) -> Text { Text(text) .font(.subheadline) }
  34. Text(book.title) .font(.headline) } var body: some View { HStack(alignment: .top)

    { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { titleLabel (“by \(book.author)") .font(.subheadline) (“\(book.pages) pages") .font(.subheadline) } ♻ Extract local function func detailsLabel(_ text: String) -> Text { Text(text) .font(.subheadline) } Text Text
  35. Text(book.title) .font(.headline) } var body: some View { HStack(alignment: .top)

    { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { titleLabel (“by \(book.author)") .font(.subheadline) (“\(book.pages) pages") .font(.subheadline) } ♻ Extract local function func detailsLabel(_ text: String) -> Text { Text(text) .font(.subheadline) } detailsLabel detailsLabel
  36. Text(book.title) .font(.headline) } func detailsLabel(_ text: String) -> Text {

    Text(text) .font(.subheadline) } var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { titleLabel detailsLabel(“by \(book.author)") detailsLabel(“\(book.pages) pages") } Spacer() } ♻ Extract local function
  37. var titleLabel: some View { Text(book.title) .font(.headline) } func detailsLabel(_

    text: String) -> Text { Text(text) .font(.subheadline) } var body: some View { HStack(alignment: .top) { Image(book.mediumCoverImageName) .resizable() .aspectRatio(contentMode: .fit) .frame(height: 90) VStack(alignment: .leading) { titleLabel detailsLabel(“by \(book.author)") detailsLabel(“\(book.pages) pages") } Spacer() Refactorings Extract to Subview for reusable parts of the UI (and for a cleaner structure) Extract to local subview when you need to access properties of the parent view Extract to local function when you want to pass in values from the local scope
  38. TextInputField ✨ Drop-in replacement for TextField ✨ Mandatory fields ✨

    Custom validation ✨ Floating label ✨ Styling options ✨ Focus handling ✨ Clear button
  39. Drop-in replacement for TextField TextField("First Name", text: $shippingAddress.firstName) Original (TextField)

    Drop-in (TextInputField) TextInputField("First Name", text: $shippingAddress.firstName)
  40. Drop-in replacement for TextField / // Creates a text field

    with a text label generated from a title string. / // / // - Parameters: / // - title: The title of the text view, describing its purpose. / // - text: The text to display and edit. @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public init<S>(_ title: S, text: Binding<String>) where S : StringProtocol Original (TextField) Drop-in (TextInputField) TextInputField("First Name", text: $shippingAddress.firstName)
  41. / // / // - Parameters: / // - title:

    The title of the text view, describing its purpose. / // - text: The text to display and edit. @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public init<S>(_ title: S, text: Binding<String>) where S : StringProtocol Original (TextField) struct TextInputField: View { private var title: String @Binding private var text: String init(_ title: String, text: Binding<String>) { self.title = title self._text = text } var body: some View { ZStack(alignment: .leading) { Text(title) TextField("", text: $text) } Drop-in (TextInputField)
  42. Floating Label struct TextInputField: View { private var title: String

    @Binding private var text: String init(_ title: String, text: Binding<String>) { self.title = title self._text = text } var body: some View { ZStack(alignment: .leading) { Text(title) TextField("", text: $text) } .padding(.top, 15) .animation(.default) } Placeholder TextField
  43. Floating Label struct TextInputField: View { private var title: String

    @Binding private var text: String init(_ title: String, text: Binding<String>) { self.title = title self._text = text } var body: some View { ZStack(alignment: .leading) { Text(title) TextField("", text: $text) } .padding(.top, 15) .foregroundColor(text.isEmpty ? Color(.placeholderText) : .accentColor) Foreground color
  44. struct TextInputField: View { private var title: String @Binding private

    var text: String init(_ title: String, text: Binding<String>) { self.title = title self._text = text } var body: some View { ZStack(alignment: .leading) { Text(title) .foregroundColor(text.isEmpty ? Color(.placeholderText) : .accentColor) TextField("", text: $text) } .padding(.top, 15) .animation(.default) Floating Label .offset(y: text.isEmpty ? 0 : -25) Offset
  45. struct TextInputField: View { private var title: String @Binding private

    var text: String init(_ title: String, text: Binding<String>) { self.title = title self._text = text } var body: some View { ZStack(alignment: .leading) { Text(title) .foregroundColor(text.isEmpty ? Color(.placeholderText) : .accentColor) .offset(y: text.isEmpty ? 0 : -25) TextField("", text: $text) } .padding(.top, 15) Floating Label scale .scaleEffect(text.isEmpty ? 1: 0.8, anchor: .leading)
  46. Clear Button struct TextInputField: View { var clearButton: some View

    { HStack { if !clearButtonHidden { Spacer() Button(action: { text = "" }) { Image(systemName: "multiply.circle.fill") .foregroundColor(Color(UIColor.systemGray)) } } else { EmptyView() } } } var body: some View { Inner view
  47. EmptyView() } } } var body: some View { ZStack(alignment:

    .leading) { // ... TextField("", text: $text) .padding(.trailing, clearButtonPadding) .overlay(clearButton) } .padding(.top, 15) .animation(.default) } } Clear Button Prevent clipping
  48. extension View { func clearButtonHidden(_ hidesClearButton: Bool = true) -

    > some View { environment(\.clearButtonHidden, hidesClearButton) } } private struct TextInputFieldClearButtonHidden: EnvironmentKey { static var defaultValue: Bool = false } extension EnvironmentValues { var clearButtonHidden: Bool { get { self[TextInputFieldClearButtonHidden.self] } set { self[TextInputFieldClearButtonHidden.self] = newValue } } } Customising Views Using the SwiftUI Environment
  49. else { // .. . } } } } extension

    View { func clearButtonHidden(_ hidesClearButton: Bool = true) - > some View { environment(\.clearButtonHidden, hidesClearButton) } } private struct TextInputFieldClearButtonHidden: EnvironmentKey { static var defaultValue: Bool = false } extension EnvironmentValues { var clearButtonHidden: Bool { get { self[TextInputFieldClearButtonHidden.self] } set { self[TextInputFieldClearButtonHidden.self] = newValue } } } Customising Views Using the SwiftUI Environment
  50. struct TextInputField: View { @Environment(\.clearButtonHidden) var clearButtonHidden var clearButton: some

    View { HStack { if !clearButtonHidden { // ... } else { // .. . } } } } extension View { func clearButtonHidden(_ hidesClearButton: Bool = true) - > some View { environment(\.clearButtonHidden, hidesClearButton) } } Customising Views Using the SwiftUI Environment
  51. Customising Views var body: some View { Form { Section(header:

    Text("Shipping Address")) { TextInputField("First Name", text: $vm.firstName) TextInputField("Last Name", text: $vm.lastName) TextInputField("Street", text: $vm.street) TextInputField("Number", text: $vm.number) .clearButtonHidden(false) TextInputField("Post code", text: $vm.postcode) TextInputField("City", text: $vm.city) TextInputField("County", text: $vm.county) TextInputField("Country", text: $vm.country) .clearButtonHidden(false) } .clearButtonHidden(true) } } Values trickle down through the environment
  52. View styling ❓Can we still style or views? ❓What about

    view modifiers such as 
 disableAutocorrection or keyboardType? ❓Will we need to expose them all manually? This all still works, thanks to the SwiftUI Environment!
  53. View styling var body: some View { Form { Section(header:

    Text("Shipping Address")) { TextInputField("First Name", text: $vm.firstName) .disableAutocorrection(true) TextInputField("Last Name", text: $vm.lastName) TextInputField("Street", text: $vm.street) TextInputField("Number", text: $vm.number) .keyboardType(.numberPad) .clearButtonHidden(false) TextInputField("Post code", text: $vm.postcode) TextInputField("City", text: $vm.city) TextInputField("County", text: $vm.county) TextInputField("Country", text: $vm.country) .clearButtonHidden(false) } .clearButtonHidden(true) This all still works, thanks to the SwiftUI Environment!
  54. Focus handling var body: some View { @FocusState private var

    focus: FocusableField? Form { Section(header: Text("Shipping Address")) { TextInputField("First Name", text: $vm.firstName) .disableAutocorrection(true) TextInputField("Last Name", text: $vm.lastName) TextInputField("Street", text: $vm.street) TextInputField("Number", text: $vm.number) .keyboardType(.numberPad) .clearButtonHidden(false) TextInputField("Post code", text: $vm.postcode) TextInputField("City", text: $vm.city) TextInputField("County", text: $vm.county) TextInputField("Country", text: $vm.country) .clearButtonHidden(false) }
  55. Focus handling enum FocusableField: Hashable { case firstName case lastName

    } var body: some View { @FocusState private var focus: FocusableField? Form { Section(header: Text("Shipping Address")) { TextInputField("First Name", text: $vm.firstName) .disableAutocorrection(true) TextInputField("Last Name", text: $vm.lastName) TextInputField("Street", text: $vm.street) TextInputField("Number", text: $vm.number) .keyboardType(.numberPad) .focused($focus, equals: .firstName) .focused($focus, equals: .lastName) Again, this works thanks to the SwiftUI Environment
  56. } } } } extension View { func isMandatory(_ value:

    Bool = true) -> some View { environment(\.isMandatory, value) } } private struct TextInputFieldMandatory: EnvironmentKey { static var defaultValue: Bool = false } extension EnvironmentValues { var isMandatory: Bool { get { self[TextInputFieldMandatory.self] } set { self[TextInputFieldMandatory.self] = newValue } } } ✅ Validation handling Connecting to the SwiftUI Environment
  57. struct TextInputField: View { var body: some View { ZStack(alignment:

    .leading) { Text(title) // ... TextField("", text: $text) ✅ Validation handling Connecting to the SwiftUI Environment @Environment(\.isMandatory) var isMandatory
  58. struct TextInputField: View { @Environment(\.isMandatory) var isMandatory var body: some

    View { ZStack(alignment: .leading) { Text(title) // ... TextField("", text: $text) ✅ Validation handling Performing the validation @State private var isValid: Bool = true @State var validationMessage: String = “" fileprivate func validate(_ value: String) { if isMandatory { isValid = !value.isEmpty validationMessage = isValid ? "" : "This is a mandatory field" } }
  59. if isMandatory { isValid = !value.isEmpty validationMessage = isValid ?

    "" : "This is a mandatory field" } } var body: some View { ZStack(alignment: .leading) { Text(title) // ... TextField("", text: $text) ✅ Validation handling Update the UI according to the validation state if !isValid { Text(validationMessage) .foregroundColor(.red) .offset(y: -25) .scaleEffect(0.8, anchor: .leading) } .onAppear { validate(text) } .onChange(of: text) { value in validate(value) } Trigger validation
  60. ✅ Validation handling - Exposing inner state How can we

    expose the view’s inner state to the outside world?
  61. ✅ Validation handling - Exposing inner state Form { Section(header:

    errorLabel) { TextInputField("Email address", text: $viewModel.email, isValid: $viewModel.isFormValid) .isMandatory() } Section { Button("Submit") { ... } .disabled(!viewModel.isFormValid) } }
  62. ✅ Validation handling - Exposing inner state struct TextInputField: View

    { @Binding private var isValidBinding: Bool @State private var isValid: Bool = true init(_ title: String, text: Binding<String>, isValid isValidBinding: Binding<Bool>? = nil) { self.title = title self._text = text self._isValidBinding = isValidBinding ? ? .constant(true) } }
  63. ✅ Validation handling - Exposing inner state struct TextInputField: View

    { @Binding private var isValidBinding: Bool @State private var isValid: Bool = true init(_ title: String, text: Binding<String>, isValid isValidBinding: Binding<Bool>? = nil) { self.title = title self._text = text self._isValidBinding = isValidBinding ? ? .constant(true) } } { didSet { isValidBinding = isValid } } Every change to isValid will be assigned to the binding
  64. ✅ Validation handling - Custom Validation How can we let

    the outside world take part in the validation process?
  65. } ✅ Validation handling - Custom Validation Form { Section(header:

    errorLabel) { TextInputField("Email address", text: $viewModel.email, isValid: $viewModel.isEmailValid) .isMandatory() .onValidate { value in value.isEmail() ? .success(true) : .failure(.init(message: "\(value) is not a valid email address")) } .autocapitalization(.none) } } Register a custom validation callback Return success or failure
  66. return NSLocalizedString("\(message)", comment: "Message for generic validation errors.") } }

    private struct TextInputFieldValidationHandler: EnvironmentKey { static var defaultValue: ((String) - > Result<Bool, ValidationError>)? } extension EnvironmentValues { var validationHandler: ((String) -> Result<Bool, ValidationError>)? { get { self[TextInputFieldValidationHandler.self] } set { self[TextInputFieldValidationHandler.self] = newValue } } } extension View { func onValidate(validationHandler: @escaping (String) -> Result<Bool, ValidationError>) -> some View { environment(\.validationHandler, validationHandler) } } ✅ How to register Closures / Callbacks
  67. struct ValidationError: Error { let message: String } extension ValidationError:

    LocalizedError { public var errorDescription: String? { return NSLocalizedString("\(message)", comment: "Message for generic validation errors.") } } private struct TextInputFieldValidationHandler: EnvironmentKey { static var defaultValue: ((String) - > Result<Bool, ValidationError>)? } extension EnvironmentValues { var validationHandler: ((String) -> Result<Bool, ValidationError>)? { get { self[TextInputFieldValidationHandler.self] } set { self[TextInputFieldValidationHandler.self] = newValue } } } ✅ How to register Closures / Callbacks
  68. struct TextInputField: View { @Environment(\.validationHandler) var validationHandler fileprivate func validate(_

    value: String) { isValid = true if isMandatory { isValid = !value.isEmpty validationMessage = isValid ? "" : "This is a mandatory field" } if isValid { guard let validationHandler = self.validationHandler else { return } let validationResult = validationHandler(value) if case .failure(let error) = validationResult { isValid = false self.validationMessage = "\(error.localizedDescription)" } else if case .success(let isValid) = validationResult { self.isValid = isValid self.validationMessage = "" } } ✅ How to register Closures / Callbacks Call the custom handler
  69. Component Library / / MARK: - Component Library public struct

    TextInputField_Library: LibraryContentProvider { public var views: [LibraryItem] { [LibraryItem(TextInputField("First Name", text: .constant(“Peter")), title: "TextInputField", category: .control)] } public func modifiers(base: TextInputField) -> [LibraryItem] { [LibraryItem(base.clearButtonHidden(true), category: .control)] } }
  70. Peter’s Wishlist 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 reviews for Xcode Component Library
  71. import SwiftUI struct ContentView: View { var body: some View

    { HStack { Image(systemName: "at") TextField("Email", text: $viewModel.email) .textInputAutocapitalization(.never) .disableAutocorrection(true) .focused($focus, equals: .email) .submitLabel(.next) .onSubmit { self.focus = .password } } } } Drop-in replacement
  72. Building a Reusable Text Input Field ✨Refactoring your SwiftUI code

    ✨Using view modifiers ✨Customising SwiftUI view appearance ✨Making use of the SwiftUI environment ✨Adding hooks for custom behaviour ✨Re-using locally ✨Using the Xcode Component library ✨Publishing to GitHub ✨Building drop-in replacements for built-in views
  73. Thanks! Peter Friese h tt p://pete rf riese.dev @pete rf

    riese 
 youtube.com/c/PeterFriese/ Follow me