Slide 1

Slide 1 text

Building Reusable SwiftUI Components Peter Friese, Developer Advocate, Firebase @pete rf riese

Slide 2

Slide 2 text

Peter Friese @pete rf riese Developer Advocate, Firebase

Slide 3

Slide 3 text

Building Reusable SwiftUI Components Why this talk?

Slide 4

Slide 4 text

Help developers succeed 
 by making it easy to build 
 and grow apps

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

Hello World!

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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)

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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)

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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 ⚠

Slide 20

Slide 20 text

DRY - Don’t repeat yourself

Slide 21

Slide 21 text

Useful SwiftUI Refactorings ♻ Extract Subview ♻ Extract local Subview ♻ Extract local function

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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)

Slide 26

Slide 26 text

❌ 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

Slide 27

Slide 27 text

❌ 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

Slide 28

Slide 28 text

❌ 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

Slide 29

Slide 29 text

Peter’s Wishlist Make Extract to Subview work all of the time Extract to Subview: handle dependent properties Add Extract to File

Slide 30

Slide 30 text

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)

Slide 31

Slide 31 text

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) } } } } ?

Slide 32

Slide 32 text

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()

Slide 33

Slide 33 text

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()

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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)

Slide 36

Slide 36 text

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)

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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)

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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) }

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

Building a Reusable Text Input Field With a floating label

Slide 47

Slide 47 text

TextInputField ✨ Drop-in replacement for TextField ✨ Mandatory fields ✨ Custom validation ✨ Floating label ✨ Styling options ✨ Focus handling ✨ Clear button

Slide 48

Slide 48 text

Drop-in replacement for TextField TextField("First Name", text: $shippingAddress.firstName) Original (TextField) Drop-in (TextInputField) TextInputField("First Name", text: $shippingAddress.firstName)

Slide 49

Slide 49 text

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(_ title: S, text: Binding) where S : StringProtocol Original (TextField) Drop-in (TextInputField) TextInputField("First Name", text: $shippingAddress.firstName)

Slide 50

Slide 50 text

/ // / // - 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(_ title: S, text: Binding) where S : StringProtocol Original (TextField) struct TextInputField: View { private var title: String @Binding private var text: String init(_ title: String, text: Binding) { self.title = title self._text = text } var body: some View { ZStack(alignment: .leading) { Text(title) TextField("", text: $text) } Drop-in (TextInputField)

Slide 51

Slide 51 text

Floating Label struct TextInputField: View { private var title: String @Binding private var text: String init(_ title: String, text: Binding) { 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

Slide 52

Slide 52 text

Floating Label struct TextInputField: View { private var title: String @Binding private var text: String init(_ title: String, text: Binding) { 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

Slide 53

Slide 53 text

struct TextInputField: View { private var title: String @Binding private var text: String init(_ title: String, text: Binding) { 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

Slide 54

Slide 54 text

struct TextInputField: View { private var title: String @Binding private var text: String init(_ title: String, text: Binding) { 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)

Slide 55

Slide 55 text

Clear Button Floating Label

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

Customising Views

Slide 59

Slide 59 text

Customising Views TextInputField("First Name", text: $vm.firstName) .clearButtonHidden(false) TextInputField("First Name", text: $vm.firstName) .clearButtonHidden(true) How?

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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!

Slide 65

Slide 65 text

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!

Slide 66

Slide 66 text

🎯What about focus handling?

Slide 67

Slide 67 text

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) }

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

✅ Validation handling

Slide 70

Slide 70 text

✅ Validation handling

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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" } }

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

✅ Validation handling - Exposing inner state How can we expose the view’s inner state to the outside world?

Slide 76

Slide 76 text

✅ 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) } }

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

✅ Validation handling - Exposing inner state struct TextInputField: View { @Binding private var isValidBinding: Bool @State private var isValid: Bool = true init(_ title: String, text: Binding, isValid isValidBinding: Binding? = 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

Slide 79

Slide 79 text

✅ Validation handling - Custom Validation How can we let the outside world take part in the validation process?

Slide 80

Slide 80 text

} ✅ 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

Slide 81

Slide 81 text

return NSLocalizedString("\(message)", comment: "Message for generic validation errors.") } } private struct TextInputFieldValidationHandler: EnvironmentKey { static var defaultValue: ((String) - > Result)? } extension EnvironmentValues { var validationHandler: ((String) -> Result)? { get { self[TextInputFieldValidationHandler.self] } set { self[TextInputFieldValidationHandler.self] = newValue } } } extension View { func onValidate(validationHandler: @escaping (String) -> Result) -> some View { environment(\.validationHandler, validationHandler) } } ✅ How to register Closures / Callbacks

Slide 82

Slide 82 text

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)? } extension EnvironmentValues { var validationHandler: ((String) -> Result)? { get { self[TextInputFieldValidationHandler.self] } set { self[TextInputFieldValidationHandler.self] = newValue } } } ✅ How to register Closures / Callbacks

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

􀈈 Reusing the Component

Slide 85

Slide 85 text

􀈈 Reusing the Component locally

Slide 86

Slide 86 text

File > New Package Add to the current project

Slide 87

Slide 87 text

♻ Extract into package 🤣 j/k - there is no such refactoring

Slide 88

Slide 88 text

􀈈 Xcode Component Library

Slide 89

Slide 89 text

No content

Slide 90

Slide 90 text

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)] } }

Slide 91

Slide 91 text

😞

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

􀈂 Sharing globally

Slide 94

Slide 94 text

No content

Slide 95

Slide 95 text

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

Slide 96

Slide 96 text

No content

Slide 97

Slide 97 text

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

Slide 98

Slide 98 text

Thanks! Peter Friese h tt p://pete rf riese.dev @pete rf riese 
 youtube.com/c/PeterFriese/ Follow me

Slide 99

Slide 99 text

🔗 https: // bit.ly/3n99fis Resources 🔗 https: // bit.ly/3Of3Q5o 🔗 https: // www.fivestars.blog/