Представьте, что вам нужно реализовать комплексный компонент инпута на SwiftUI. Что может пойти не так? Какие сложности могут возникнуть? На эти и другие вопросы постараюсь ответить в статье, где реализуем компонент со скрина, а также разберем возможные проблемы.

Спойлер

Одни решения, представленные в данной статье, гуглятся без особого труда.

Другие же уникальны и являются собственным велосипедом изобретением. Возможно они не идеальны, но они работают. Если у вас есть альтернативные решения подобных проблем — велком в комментарии :)

Первые шаги

Набрасываем код для самого инпута:

XTextField.swift - промежуточный код #0
import SwiftUI

struct XTextField: View {
    
    var text: String
    
    var isEnabled: Bool = true
    var hasError: Bool = false
    
    var label: String = ""
    var placeholder: String = ""
    
    var trailingImage: UIImage? = nil
    
    var captionText: String? = nil
    
    var onTextChange: (String) -> Void
    
    var onTrailingImageClick: () -> Void = { }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            let shouldUseLabel = true // Изменим после
            Text(label)
                .font(labelFont)
                .foregroundColor(labelColor)
                .opacity(shouldUseLabel ? 1 : 0)
            
            Spacer().frame(height: 8)
            
            HStack(alignment: .center, spacing: 0) {
                ZStack(alignment: .leading) {
                    Text(placeholder)
                        .font(placeholderFont)
                        .foregroundColor(placeholderColor)
                        .opacity(shouldUseLabel ? 0 : 1)
                    textField()
                        .font(textFont)
                        .foregroundColor(textColor)
                        .accentColor(cursorColor)
                }
                
                if let trailingImage = trailingImage {
                    Spacer().frame(width: 16)
                    Image(uiImage: trailingImage)
                        .foregroundColor(trailingImageColor)
                        .frame(width: 24, height: 24, alignment: .center)
                        .onTapGesture { onTrailingImageClick() }
                }
            }.frame(minHeight: 24)
            
            ZStack {
                Spacer()
                Rectangle()
                    .fill(underlineColor)
                    .frame(height: 1)
            }.frame(height: 16, alignment: .bottom)
            
            if let captionText = captionText {
                Spacer().frame(height: 8)
                Text(captionText)
                    .font(captionFont)
                    .foregroundColor(captionColor)
            }
        }
        .disabled(!isEnabled)
        .opacity(isEnabled ? 1 : 0.4)
    }
    
    @ViewBuilder
    private func textField() -> some View { // вынос в отдельную функцию поможет в дальнейшем
        TextField("", text: Binding(
            get: { text },
            set: { onTextChange($0) }
        ))
    }
}

// MARK: Colors
private extension XTextField {
    
    var labelColor: Color { Color.gray }
    
    var placeholderColor: Color { Color.gray }
    
    var textColor: Color { Color.black }
    
    var cursorColor: Color {
        if hasError {
            Color.red
        } else {
            Color.green
        }
    }
    
    var underlineColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var captionColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var trailingImageColor: Color { Color.gray }
}

// MARK: Fonts
private extension XTextField {
    
    var labelFont: Font { Font.caption }
    
    var placeholderFont: Font { Font.body }
    
    var textFont: Font { Font.body }
    
    var captionFont: Font { Font.caption }
}

Сама верстка не такая уж и сложная. Комментировать её не входило в планы. Этот код нам нужен как отправная точка для решения дальнейших проблем.

Набрасываем код для скрина, на котором мы будем все это обкатывать:

XScreen.swift - промежуточный код #0
import SwiftUI

struct XScreen: View {
    
    @State private var text1 = "TextField1"
    @State private var text2 = "TextField2"
    
    var body: some View {
        VStack {
            XTextField(
                text: text1,
                hasError: false,
                label: "TextField1",
                placeholder: "TextField1",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField1",
                onTextChange: { text in
                    text1 = text
                    print("TextField1: text changed to \(text)")
                }
            ).padding(20)
            
            XTextField(
                text: text2,
                hasError: true,
                label: "TextField2",
                placeholder: "TextField2",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField2",
                onTextChange: { text in
                    text2 = text
                    print("TextField2: text changed to \(text)")
                }
            ).padding(20)
        }
    }
}

Запускаем, смотрим. Вроде даже работает.

Начало
Начало

Проблема №1. Двойное событие обновления

Смотрим в лог. Обнаруживаем, что событие обновления текста (onTextChange ) вызывается дважды:

TextField1: text changed to TextField1
TextField1: text changed to TextField1
TextField1: text changed to TextField1a
TextField1: text changed to TextField1a
TextField1: text changed to TextField1as
TextField1: text changed to TextField1as
TextField1: text changed to TextField1asd
TextField1: text changed to TextField1asd
TextField1: text changed to TextField1asda
TextField1: text changed to TextField1asda

Очевидно, что что-то пошло не так... Гуглим проблему, получаем решение. Что здесь происходит? Почему решение работает? На эти вопросы ответов у меня нет...

Все, что нужно сделать — это добавить вызов textFieldStyle(.plain) сразу после textField().

После запуска видим, что в обновленном логе все работает как следует:

TextField1: text changed to TextField1
TextField1: text changed to TextField1a
TextField1: text changed to TextField1as
TextField1: text changed to TextField1asd
TextField1: text changed to TextField1asda
XTextField.swift - промежуточный код #1
import SwiftUI


struct XTextField: View {
    
    var text: String
    
    var isEnabled: Bool = true
    var hasError: Bool = false
    
    var label: String = ""
    var placeholder: String = ""
    
    var trailingImage: UIImage? = nil
    
    var captionText: String? = nil
    
    var onTextChange: (String) -> Void
    
    var onTrailingImageClick: () -> Void = { }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            let shouldUseLabel = true // Изменим после
            Text(label)
                .font(labelFont)
                .foregroundColor(labelColor)
                .opacity(shouldUseLabel ? 1 : 0)
            
            Spacer().frame(height: 8)
            
            HStack(alignment: .center, spacing: 0) {
                ZStack(alignment: .leading) {
                    Text(placeholder)
                        .font(placeholderFont)
                        .foregroundColor(placeholderColor)
                        .opacity(shouldUseLabel ? 0 : 1)
                    textField()
                        .textFieldStyle(.plain) // https://stackoverflow.com/a/74745555
                        .font(textFont)
                        .foregroundColor(textColor)
                        .accentColor(cursorColor)
                }
                
                if let trailingImage = trailingImage {
                    Spacer().frame(width: 16)
                    Image(uiImage: trailingImage)
                        .foregroundColor(trailingImageColor)
                        .frame(width: 24, height: 24, alignment: .center)
                        .onTapGesture { onTrailingImageClick() }
                }
            }.frame(minHeight: 24)
            
            ZStack {
                Spacer()
                Rectangle()
                    .fill(underlineColor)
                    .frame(height: 1)
            }.frame(height: 16, alignment: .bottom)
            
            if let captionText = captionText {
                Spacer().frame(height: 8)
                Text(captionText)
                    .font(captionFont)
                    .foregroundColor(captionColor)
            }
        }
        .disabled(!isEnabled)
        .opacity(isEnabled ? 1 : 0.4)
    }
    
    @ViewBuilder
    private func textField() -> some View {
        TextField("", text: Binding(
            get: { text },
            set: { onTextChange($0) }
        ))
    }
}

// MARK: Colors
private extension XTextField {
    
    var labelColor: Color { Color.gray }
    
    var placeholderColor: Color { Color.gray }
    
    var textColor: Color { Color.black }
    
    var cursorColor: Color {
        if hasError {
            Color.red
        } else {
            Color.green
        }
    }
    
    var underlineColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var captionColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var trailingImageColor: Color { Color.gray }
}

// MARK: Fonts
private extension XTextField {
    
    var labelFont: Font { Font.caption }
    
    var placeholderFont: Font { Font.body }
    
    var textFont: Font { Font.body }
    
    var captionFont: Font { Font.caption }
}

Проблема №2. Производительность

Вряд ли на текущем этапе вы задумаетесь о такой неважной штуке, как производительность. И я тоже не задумывался. Но, в рамках повествования, предположим, что мы об этом все же вспомнили сейчас.

Вообще, в интернетах мне удалось найти мало чего реально годного на тему производительности и SwiftUI. Но, прогулявшись по forums.developer.apple и посмотрев несколько официальных видео по данной теме, можно прийти к выводу, что метод body не должен вызываться лишний раз, так как это может оказывать негативное влияние на производительность. Сам же повторный вызов данного метода называют (но не общепринято) body reinvoke или reevaluate. Мне привычнее второй вариант, его я и буду использовать в дальнейшем.

Пасхалка для тех, кто знаком с Android

На самом деле еще куда более привычно называть это рекомпозицией по аналогии с Jetpack Compose в Android. Но мы не в Android и не в Jetpack Compose ??

Как же нам отследить повторный вызов метода body? Ответ есть в данной статье. Но, если коротко, сделать это можно двумя способами:

  • let _ = Self._printChanges()

  • выставляя рандомный background нашей View

Добавим рандомный background нашему инпуту для отслеживания и запустим.

struct XTextField: View {
    
    ...
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ...
        }
        .background(.random)
    }
}

extension ShapeStyle where Self == Color {
    static var random: Color {
        Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        )
    }
}

Можно заметить, что меняя значение одного инпута, body reevaluate происходит и у второго инпута тоже, а так быть не должно.

Добавим _printChanges в XTextField.body. Лог не показывает ничего полезного:

XTextField: @self changed.
XTextField: @self changed.

Долгими поисками мне удалось выяснить, что проблема заключается в самом вызове инпута:

            XTextField(
                text: text1,
                hasError: false,
                label: "TextField1",
                placeholder: "TextField1",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField1",
                onTextChange: { text in
                    text1 = text
                    print("TextField1: text changed to \(text)")
                }
            )

А, если быть точнее, то в создании кложуры. И сейчас я постараюсь объяснить, почему это происходит.

Наши текста хранятся в XScreen в качестве @State полей. Любой из XTextField'ов, меняя значение текстового поля, обновляет текст в XScreen. Из-за этого вызывается XScreen.body, так как изменилось значение поля, помеченного как @State. В методе XScreen.body мы сталкиваемся с XTextField, body которого будет вызван в том случае, если какие-то входные параметры изменились. Таким образом, меняя текст в одном из инпутов XScreen, в рамках body reevaluate, опросит оба инпута, а не хотят ли они тоже вызвать повторно свой body?

Есть и множество других причин, по которым body может быть вызван снова. Они могут быть связаны, например с @State / @ObservedObject / @StateObject, но на данный момент наш инпут лишен всего этого

Дальше нужно понять, что значит "входные параметры изменились". Если кратко, то берутся параметры до и после и сравниваются (подробнее почитать можно в этой статье). Если какие-то параметры реализуют протокол Equatable, то сравниваться они будут именно по нему. В этом легко убедиться, если:

  • создать класс

  • реализовать Equatable

  • в метод == добавить print

  • использовать этот класс как параметр для View

Интересным моментом является еще и то, что SwiftUI как-то умеет сравнивать даже те параметры, которые не являются Equatable. (как-то по ссылке?)

Мы же в коде выше, на строке 8, каждый раз создаем новую кложуру. Кложуры, в свою очередь, не являются Equatable. Новая кложура с новой ссылкой и является причиной лишнего body reevaluate.

Чтобы убедиться в этом, мы придем к решению, где кложура будет инициализироваться единожды, не создаваясь в вызове XTextField:

            XTextField(
                ...
                onTextChange: onText1Change
//              onTextChange: { onText1Change($0) } // вот так делать не стоит
            )
Пасхалка для тех, кто знаком с Android

Интересно, что в Jetpack Compose есть ровно схожая проблема со стабильностью лямбда параметров. Решается она "запоминанием" лямбды. Здесь, в SwiftUI, мы тоже, своего рода, будем запоминать кложуру, объявляя ее в конструкторе.

Самый простой вариант это проверить — оставить одну кложуру как есть (например, для второго текста), а другую разово проинициализировать в конструкторе XScreen:

struct XScreen: View {
    
    ...
    
    private let onText1Change: (String) -> Void
    
    init() {
        self.onText1Change = { _ in }
    }
    
    var body: some View {
        VStack {
            XTextField(
                ...
                onTextChange: onText1Change
            )
            
            ...
        }
    }
}

Запустив, можно убедиться, что изменение второго инпута теперь не вызывает body reevaluate первого инпута.

Все бы ничего, вот только изменение первого инпута в таком случае ни к чему не приводит, так как кложура ничего не обновляет.

Если же мы внутрь создаваемой в конструкторе кложуры добавим обновление состояния (как это и было раньше), то Xcode выдаст нам следующую ошибку: "Escaping closure captures mutating 'self' parameter".

Решение есть. Оно, возможно, далеко не лучшее, но зато работает.

Сначала мы заведем ObservableObject, который хранит изменяемое значение и кложуру по изменению этого значения:

class XObject<T>: ObservableObject {
    var value: T
    lazy var update: (T) -> Void = {
        { [weak self] newValue in
            guard let self = self else { return }
            self.value = newValue
            self.objectWillChange.send()
        }
    }()
    
    init(value: T) {
        self.value = value
    }
}

Затем заменим этой штукой существующие @State'ы:

struct XScreen: View {
    
    @StateObject private var text1Object: XObject<String>
    @StateObject private var text2Object: XObject<String>
    
    private var onText1Change: (String) -> Void
    private var onText2Change: (String) -> Void
    
    init() {
        var text1Obj = XObject(value: "TextField1")
        var text2Obj = XObject(value: "TextField2")
        
        self._text1Object = StateObject(wrappedValue: text1Obj)
        self._text2Object = StateObject(wrappedValue: text2Obj)
        
        self.onText1Change = { text in
            text1Obj.update(text)
            print("TextField1: text changed to \(text)")
        }
        self.onText2Change = { text in
            text2Obj.update(text)
            print("TextField2: text changed to \(text)")
        }
    }
    
    var body: some View {
        VStack {
            XTextField(
                text: text1Object.value,
                onTextChange: onText1Change
            )
            
            XTextField(
                text: text2Object.value,
                ...
                onTextChange: onText2Change
            )
        }
    }
}

После запуска видим, что изменение одного инпута теперь никак не влияет на body reevaluate второго.

XObject, на самом деле, вряд ли пригодится на практике. Скорее всего, вы не будете хранить текст (а более глобально — состояние) в какой-то View по архитектурным причинам.

Например, мы можем использовать MVI архитектуру, где View общается с ViewModel, которая через AnyPublisher отдает ViewState, хранящий значения для инпутов. Все это может выглядеть примерно следующим образом:

struct XScreen: View {
    
    @State
    private var viewState: XViewState
    
    private let onTextChange: (String) -> Void
    
    init(viewModel: XViewModel) {
        self.viewState = viewModel.currentViewState
        
        self.onTextChange = { newText in
            viewModel.obtainEvent(
                viewEvent: XEvent.OnTextChanged(text: newText)
            )
        }
    }
    
    var body: some View {
        VStack(spacing: 0) {
            XTextField(
                text: viewState.text,
                onTextChange: onTextChange
            )
        }.onReceive(viewModel.viewStatePublisher) { (newViewState: XViewState) in
            viewState = newViewState
        }
    }
}
XTextField.swift - промежуточный код #2
import SwiftUI

struct XTextField: View {
    
    var text: String
    
    var isEnabled: Bool = true
    var hasError: Bool = false
    
    var label: String = ""
    var placeholder: String = ""
    
    var trailingImage: UIImage? = nil
    
    var captionText: String? = nil
    
    var onTextChange: (String) -> Void
    
    var onTrailingImageClick: () -> Void = { }
    
    var body: some View {
        let _ = Self._printChanges()
        VStack(alignment: .leading, spacing: 0) {
            let shouldUseLabel = true // Изменим после
            Text(label)
                .font(labelFont)
                .foregroundColor(labelColor)
                .opacity(shouldUseLabel ? 1 : 0)
            
            Spacer().frame(height: 8)
            
            HStack(alignment: .center, spacing: 0) {
                ZStack(alignment: .leading) {
                    Text(placeholder)
                        .font(placeholderFont)
                        .foregroundColor(placeholderColor)
                        .opacity(shouldUseLabel ? 0 : 1)
                    textField()
                        .textFieldStyle(.plain) // https://stackoverflow.com/a/74745555
                        .font(textFont)
                        .foregroundColor(textColor)
                        .accentColor(cursorColor)
                }
                
                if let trailingImage = trailingImage {
                    Spacer().frame(width: 16)
                    Image(uiImage: trailingImage)
                        .foregroundColor(trailingImageColor)
                        .frame(width: 24, height: 24, alignment: .center)
                        .onTapGesture { onTrailingImageClick() }
                }
            }.frame(minHeight: 24)
            
            ZStack {
                Spacer()
                Rectangle()
                    .fill(underlineColor)
                    .frame(height: 1)
            }.frame(height: 16, alignment: .bottom)
            
            if let captionText = captionText {
                Spacer().frame(height: 8)
                Text(captionText)
                    .font(captionFont)
                    .foregroundColor(captionColor)
            }
        }
        .disabled(!isEnabled)
        .opacity(isEnabled ? 1 : 0.4)
        .background(.random)
    }
    
    @ViewBuilder
    private func textField() -> some View {
        TextField("", text: Binding(
            get: { text },
            set: { onTextChange($0) }
        ))
    }
}

// MARK: Colors
private extension XTextField {
    
    var labelColor: Color { Color.gray }
    
    var placeholderColor: Color { Color.gray }
    
    var textColor: Color { Color.black }
    
    var cursorColor: Color {
        if hasError {
            Color.red
        } else {
            Color.green
        }
    }
    
    var underlineColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var captionColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var trailingImageColor: Color { Color.gray }
}

// MARK: Fonts
private extension XTextField {
    
    var labelFont: Font { Font.caption }
    
    var placeholderFont: Font { Font.body }
    
    var textFont: Font { Font.body }
    
    var captionFont: Font { Font.caption }
}

private extension ShapeStyle where Self == Color {
    static var random: Color {
        Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        )
    }
}

XScreen.swift - промежуточный код #2
import SwiftUI

class XObject<T>: ObservableObject {
    var value: T
    lazy var update: (T) -> Void = {
        { [weak self] newValue in
            guard let self = self else { return }
            self.value = newValue
            self.objectWillChange.send()
        }
    }()
    
    init(value: T) {
        self.value = value
    }
}

struct XScreen: View {
    
    @StateObject private var text1Object: XObject<String>
    @StateObject private var text2Object: XObject<String>
    
    private var onText1Change: (String) -> Void
    private var onText2Change: (String) -> Void
    
    init() {
        var text1Obj = XObject(value: "TextField1")
        var text2Obj = XObject(value: "TextField2")
        
        self._text1Object = StateObject(wrappedValue: text1Obj)
        self._text2Object = StateObject(wrappedValue: text2Obj)
        
        self.onText1Change = { text in
            text1Obj.update(text)
            print("TextField1: text changed to \(text)")
        }
        self.onText2Change = { text in
            text2Obj.update(text)
            print("TextField2: text changed to \(text)")
        }
    }
    
    var body: some View {
        VStack {
            XTextField(
                text: text1Object.value,
                hasError: false,
                label: "TextField1",
                placeholder: "TextField1",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField1",
                onTextChange: onText1Change
            ).padding(20)
            
            XTextField(
                text: text2Object.value,
                hasError: true,
                label: "TextField2",
                placeholder: "TextField2",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField2",
                onTextChange: onText2Change
            ).padding(20)
        }
    }
}

Проблема №3. Область нажатия

Перейдем к чему-то менее нудному. Немного поигравшись, обнаруживаем, что область нажатия вовсе не та, которая должна быть.

Решением является работа с фокусом: задавать кастомный onTapGesture { } и по клику захватывать фокус для нашего TextField. Для кого-то данный момент может стать критичным, так как фокус подвезли только в iOS 15. Возможно, для более ранних версий есть и альтернативные способы решения проблемы, однако все они (что я видел) выглядят как один большой костыль.

Итак, добавим захват фокуса по тапу:

struct XTextField: View {

    ...
  
    @FocusState
    var isFocused: Bool
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ...
        }
        .onTapGesture {
            isFocused = true
        }
    }
    
    @ViewBuilder
    private func textField() -> some View {
        TextField("", text: Binding(
            get: { text },
            set: { onTextChange($0) }
        ))
            .focused($isFocused)
    }
}

Проверяем. Работает!

Есть у нас такая магическая строка:

VStack(alignment: .leading, spacing: 0) {

Здесь мы выставляем отступ в 0. Когда мне довелось реализовывать данный инпут, нулевой инпут я выставил уже куда позже, сверяя дизайн с реализацией (pixel perfect, все дела). Допустим, у нас тоже не был выставлен нулевой отступ (просто не передаем параметр spacing). Запустим.

Инпут стал несколько больше. Но суть не в этом. Теперь захват фокуса работает не всегда. Если мы попадем в тот самый отступ, то onTapGesture { } не отработает. Решением проблемы является выставление contentShape(Rectangle()) над ним:

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
        }
        .contentShape(Rectangle())
        .onTapGesture {
            isFocused = true
        }
    }

Также обвешаем наш код чтением состояния фокуса, чтобы было как по дизайну (поддержка плейсхолдера при пустой строке, цвет и высота полоски в фокусе):

XTextField.swift - промежуточный код #3
import SwiftUI

struct XTextField: View {
    
    var text: String
    
    var isEnabled: Bool = true
    var hasError: Bool = false
    
    var label: String = ""
    var placeholder: String = ""
    
    var trailingImage: UIImage? = nil
    
    var captionText: String? = nil
    
    var onTextChange: (String) -> Void
    
    var onTrailingImageClick: () -> Void = { }
    
    @FocusState
    var isFocused: Bool
    
    var body: some View {
        let _ = Self._printChanges()
        VStack(alignment: .leading, spacing: 0) {
            let shouldUseLabel = isFocused || !text.isEmpty
            Text(label)
                .font(labelFont)
                .foregroundColor(labelColor)
                .opacity(shouldUseLabel ? 1 : 0)
            
            Spacer().frame(height: 8)
            
            HStack(alignment: .center, spacing: 0) {
                ZStack(alignment: .leading) {
                    Text(placeholder)
                        .font(placeholderFont)
                        .foregroundColor(placeholderColor)
                        .opacity(shouldUseLabel ? 0 : 1)
                    textField()
                        .textFieldStyle(.plain) // https://stackoverflow.com/a/74745555
                        .font(textFont)
                        .foregroundColor(textColor)
                        .accentColor(cursorColor)
                }
                
                if let trailingImage = trailingImage {
                    Spacer().frame(width: 16)
                    Image(uiImage: trailingImage)
                        .foregroundColor(trailingImageColor)
                        .frame(width: 24, height: 24, alignment: .center)
                        .onTapGesture { onTrailingImageClick() }
                }
            }.frame(minHeight: 24)
            
            ZStack {
                Spacer()
                Rectangle()
                    .fill(underlineColor)
                    .frame(height: isFocused ? 2 : 1)
            }.frame(height: 16, alignment: .bottom)
            
            if let captionText = captionText {
                Spacer().frame(height: 8)
                Text(captionText)
                    .font(captionFont)
                    .foregroundColor(captionColor)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture {
            isFocused = true
        }
        .disabled(!isEnabled)
        .opacity(isEnabled ? 1 : 0.4)
        .background(.random)
    }
    
    @ViewBuilder
    private func textField() -> some View {
        TextField("", text: Binding(
            get: { text },
            set: { onTextChange($0) }
        ))
            .focused($isFocused)
    }
}

// MARK: Colors
private extension XTextField {
    
    var labelColor: Color { Color.gray }
    
    var placeholderColor: Color { Color.gray }
    
    var textColor: Color { Color.black }
    
    var cursorColor: Color {
        if hasError {
            Color.red
        } else {
            Color.green
        }
    }
    
    var underlineColor: Color {
        if hasError {
            Color.red
        } else {
            if isFocused {
                Color.black
            } else {
                Color.gray
            }
        }
    }
    
    var captionColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var trailingImageColor: Color { Color.gray }
}

// MARK: Fonts
private extension XTextField {
    
    var labelFont: Font { Font.caption }
    
    var placeholderFont: Font { Font.body }
    
    var textFont: Font { Font.body }
    
    var captionFont: Font { Font.caption }
}

private extension ShapeStyle where Self == Color {
    static var random: Color {
        Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        )
    }
}

Проблема #4. Звездочки вместо пароля

В UIKit у UITextField есть поле, при помощи которого можно переключиться на безопасный ввод. Это поле — isSecureTextEntry. Переключая его в true, вводимый текст заменяется условными звездочками. Кроме этого, значение инпута при таких условиях нельзя копировать.

В SwiftUI ровно этот же функционал почему-то не является частью TextField, а вынесен в отдельную View SecureField.

Зачем же мы ранее выносили TextField в отдельную @ViewBuilder функцию? Как раз для того, чтобы параллельно добавить туда же SecureField и реализовать переключение на этот самый безопасный режим ввода.

Обновим наш XTextField:

struct XTextField: View {
    
    var isSecureTextEntry: Bool = false
    
    ...
    
    @ViewBuilder
    private func textField() -> some View {
        ZStack {
            TextField("", text: Binding(
                get: { text },
                set: { onTextChange($0) }
            ))
                .opacity(isSecureTextEntry ? 0 : 1)
            
            SecureField("", text: Binding(
                get: { text },
                set: { onTextChange($0) }
            ))
                .opacity(isSecureTextEntry ? 1 : 0)
        }

    }
}

Чтобы посмотреть новый функционал в действии, также видоизменим код в XScreen, дописав переключение isSecureTextEntry по клику на иконку в первом инпуте:

struct XScreen: View {

    ...
    
    @StateObject private var text1SecureObject: XObject<Bool>
    
    private var onTrailing1IconClick: () -> Void
    
    init() {
        ...
        
        var text1SecureObj = XObject(value: false)
        
        self._text1SecureObject = StateObject(wrappedValue: text1SecureObj)
        
        self.onTrailing1IconClick = {
            text1SecureObj.update(!text1SecureObj.value)
        }
    }
    
    var body: some View {
        VStack {
            XTextField(
                text: text1Object.value,
                hasError: false,
                isSecureTextEntry: text1SecureObject.value,
                label: "TextField1",
                placeholder: "TextField1",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField1",
                onTextChange: onText1Change,
                onTrailingImageClick: onTrailing1IconClick
            )
            
            ...
        }
    }
}

Как видим, работает. Однако есть два критических минуса:

  • изменения с фокусом, сделанные на прошлом этапе, не работают

  • пароль затирается

Начнем с проблемы фокуса. Решение будет заключаться в следующем:

  • меняем тип для @FocusState с Bool на FocusField?, где последнее — это enum, отражающий то, что сейчас в фокусе (TextField или SecureField)

  • по тапу выставляем фокус в соответствии с тем, что отображается

  • по смене isSecureTextEntry обновляем и фокус, так как в ином случае, при наличии фокуса, смена данного поля не закроет клавиатуру, но скроет курсор

struct XTextField: View {

    ...
  
    @FocusState
    // (1)
    // var isFocused: Bool // меняем на:
    var focusField: FocusField?
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ...
        }
        .onTapGesture {
            // (2)
            focusField = isSecureTextEntry ? .secureField : .textField
        }
        .onChange(of: isSecureTextEntry) { newIsSecureTextEntry in
            if focusField != nil {
                // (3)
                // обновляем поле для фокуса, если поле было в фокусе, но изменился isSecureTextEntry
                focusField = newIsSecureTextEntry ? .secureField : .textField
            }
        }
    }
    
    @ViewBuilder
    private func textField() -> some View {
        ZStack {
            TextField("", text: Binding(
                get: { text },
                set: { onTextChange($0) }
            ))
                // (4)
                .focused($focusField, equals: .textField)
                .opacity(isSecureTextEntry ? 0 : 1)
            
            SecureField("", text: Binding(
                get: { text },
                set: { onTextChange($0) }
            ))
                // (4)
                .focused($focusField, equals: .secureField)
                .opacity(isSecureTextEntry ? 1 : 0)
        }
    }

    // (5)
    var isFocused: Bool {
        focusField != nil
    }
}

extension XTextField {
    
    enum FocusField {
        case textField, secureField
    }
}

С затиранием пароля дела обстоят куда сложнее.

В схожем инпуте на UIKit мы переопределяем функционал получения фокуса таким образом, что если выставлен безопасный ввод, то сбрасываем текст и тут же вставляем его через insertText (почитать можно здесь).

Со SwiftUI сходу такой фокус не получится. Любые попытки подобной реализации приводят к тому, что ничего не работает.

Методом долгих проб и ошибок мне повстречался SwiftUI Introspect. При помощи него можно получить доступ к базовому элементу из UIKit, которым в нашем случае является UITextField.

И вроде бы решение на SwiftUI Introspect даже можно написать. И оно даже будет работать. Но мой выбор все же пал в сторону самодельного костыля (который сейчас мне достаточно сложно обосновать). Заключается он в следующем:

  • "прослушиваем" UITextField.textDidBeginEditingNotification

  • если для UITextField подразумевается безопасный ввод, то затираем текст и выставляем его же через insertText

  • всю эту логику единожды будем весить на рутовую View

extension View {    

    @ViewBuilder
    public func preventPasswordReset() -> some View {
        onReceive(
            NotificationCenter.default.publisher(
                for: UITextField.textDidBeginEditingNotification
            )
        ) { obj in
            if let textField = obj.object as? UITextField {
                if textField.isSecureTextEntry {
                    let currentText = textField.text ?? ""
                    textField.text = ""
                    textField.insertText(currentText)
                }
            }
        }
    }
}

struct XScreen: View {

    ...
    
    var body: some View {
        VStack {
            ...
        }
        .preventPasswordReset()
    }
}
XTextField.swift - промежуточный код #4
import SwiftUI

struct XTextField: View {
    
    var text: String
    
    var isEnabled: Bool = true
    var hasError: Bool = false
    var isSecureTextEntry: Bool = false
    
    var label: String = ""
    var placeholder: String = ""
    
    var trailingImage: UIImage? = nil
    
    var captionText: String? = nil
    
    var onTextChange: (String) -> Void
    
    var onTrailingImageClick: () -> Void = { }
    
    @FocusState
    var focusField: FocusField?
    
    var body: some View {
        let _ = Self._printChanges()
        VStack(alignment: .leading, spacing: 0) {
            let shouldUseLabel = isFocused || !text.isEmpty
            Text(label)
                .font(labelFont)
                .foregroundColor(labelColor)
                .opacity(shouldUseLabel ? 1 : 0)
            
            Spacer().frame(height: 8)
            
            HStack(alignment: .center, spacing: 0) {
                ZStack(alignment: .leading) {
                    Text(placeholder)
                        .font(placeholderFont)
                        .foregroundColor(placeholderColor)
                        .opacity(shouldUseLabel ? 0 : 1)
                    textField()
                        .textFieldStyle(.plain) // https://stackoverflow.com/a/74745555
                        .font(textFont)
                        .foregroundColor(textColor)
                        .accentColor(cursorColor)
                }
                
                if let trailingImage = trailingImage {
                    Spacer().frame(width: 16)
                    Image(uiImage: trailingImage)
                        .foregroundColor(trailingImageColor)
                        .frame(width: 24, height: 24, alignment: .center)
                        .onTapGesture { onTrailingImageClick() }
                }
            }.frame(minHeight: 24)
            
            ZStack {
                Spacer()
                Rectangle()
                    .fill(underlineColor)
                    .frame(height: isFocused ? 2 : 1)
            }.frame(height: 16, alignment: .bottom)
            
            if let captionText = captionText {
                Spacer().frame(height: 8)
                Text(captionText)
                    .font(captionFont)
                    .foregroundColor(captionColor)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture {
            focusField = isSecureTextEntry ? .secureField : .textField
        }
        .disabled(!isEnabled)
        .opacity(isEnabled ? 1 : 0.4)
        .onChange(of: isSecureTextEntry) { newIsSecureTextEntry in
            if focusField != nil {
                focusField = newIsSecureTextEntry ? .secureField : .textField
            }
        }
        .background(.random)
    }
    
    @ViewBuilder
    private func textField() -> some View {
        ZStack {
            TextField("", text: Binding(
                get: { text },
                set: { onTextChange($0) }
            ))
                .focused($focusField, equals: .textField)
                .opacity(isSecureTextEntry ? 0 : 1)
            
            SecureField("", text: Binding(
                get: { text },
                set: { onTextChange($0) }
            ))
                .focused($focusField, equals: .secureField)
                .opacity(isSecureTextEntry ? 1 : 0)
        }
    }
    
    var isFocused: Bool {
        focusField != nil
    }
}

// MARK: Colors
private extension XTextField {
    
    var labelColor: Color { Color.gray }
    
    var placeholderColor: Color { Color.gray }
    
    var textColor: Color { Color.black }
    
    var cursorColor: Color {
        if hasError {
            Color.red
        } else {
            Color.green
        }
    }
    
    var underlineColor: Color {
        if hasError {
            Color.red
        } else {
            if isFocused {
                Color.black
            } else {
                Color.gray
            }
        }
    }
    
    var captionColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var trailingImageColor: Color { Color.gray }
}

// MARK: Fonts
private extension XTextField {
    
    var labelFont: Font { Font.caption }
    
    var placeholderFont: Font { Font.body }
    
    var textFont: Font { Font.body }
    
    var captionFont: Font { Font.caption }
}

extension XTextField {
    
    enum FocusField {
        case textField, secureField
    }
}

extension View {
    
    @ViewBuilder
    public func preventPasswordReset() -> some View {
        onReceive(
            NotificationCenter.default.publisher(
                for: UITextField.textDidBeginEditingNotification
            )
        ) { obj in
            if let textField = obj.object as? UITextField {
                if textField.isSecureTextEntry {
                    let currentText = textField.text ?? ""
                    textField.text = ""
                    textField.insertText(currentText)
                }
            }
        }
    }
}

private extension ShapeStyle where Self == Color {
    static var random: Color {
        Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        )
    }
}

XScreen.swift - промежуточный код #4

import SwiftUI

class XObject<T>: ObservableObject {
    var value: T
    lazy var update: (T) -> Void = {
        { [weak self] newValue in
            guard let self = self else { return }
            self.value = newValue
            self.objectWillChange.send()
        }
    }()
    
    init(value: T) {
        self.value = value
    }
}

struct XScreen: View {
    
    @StateObject private var text1Object: XObject<String>
    @StateObject private var text2Object: XObject<String>
    @StateObject private var text1SecureObject: XObject<Bool>
    
    private var onText1Change: (String) -> Void
    private var onText2Change: (String) -> Void
    
    private var onTrailing1IconClick: () -> Void
    
    init() {
        var text1Obj = XObject(value: "TextField1")
        var text2Obj = XObject(value: "TextField2")
        var text1SecureObj = XObject(value: false)
        
        self._text1Object = StateObject(wrappedValue: text1Obj)
        self._text2Object = StateObject(wrappedValue: text2Obj)
        self._text1SecureObject = StateObject(wrappedValue: text1SecureObj)
        
        self.onText1Change = { text in
            text1Obj.update(text)
            print("TextField1: text changed to \(text)")
        }
        self.onText2Change = { text in
            text2Obj.update(text)
            print("TextField2: text changed to \(text)")
        }
        self.onTrailing1IconClick = {
            text1SecureObj.update(!text1SecureObj.value)
        }
    }
    
    var body: some View {
        VStack {
            XTextField(
                text: text1Object.value,
                hasError: false,
                isSecureTextEntry: text1SecureObject.value,
                label: "TextField1",
                placeholder: "TextField1",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField1",
                onTextChange: onText1Change,
                onTrailingImageClick: onTrailing1IconClick
            ).padding(20)
            
            XTextField(
                text: text2Object.value,
                hasError: true,
                label: "TextField2",
                placeholder: "TextField2",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField2",
                onTextChange: onText2Change
            ).padding(20)
        }
        .preventPasswordReset()
    }
}

Проблема №5. Форматирование

Что если нам нужно исказить и отформатировать текст, введенный пользователем? Например:

  • ограничить максимальное количество вводимых символов

  • позволить вводить только цифры

  • выводить номер телефона в формате +7-999-999-99-99

Очевидно, что нам нужно отредактировать кложуру по обновлению текста таким образом, чтобы текст выставлялся в нужном нам формате. Поэкспериментируем с нашим вторым инпутом:

  • позволим вводить только цифры

  • разрешим максимум четыре цифры

  • цифры будем разделять дефисом

  • будем отображать текст в формате: 1, 1-2, 1-2-3, 1-2-3-4

struct XScreen: View {
    
    ...
    
    init() {
        self.onText2Change = { text in
            let numbers = text
                .filter { $0.isNumber }
                .prefix(4)
            let charArray = Array(numbers)
            let formattedText = charArray
                .map { String($0) }
                .joined(separator: "-")
            text2Obj.update(formattedText)
            print("TextField2: text changed to \(formattedText)")
        }
    }
}

Ну, оно не работает. То есть как. Оно что-то форматирует. Но не все. И ничего не ограничивает. Такое решение никуда не годится.

В чем суть, почему так происходит? Проведя некоторое исследование, мне удалось выяснить, где находится проблема:

            TextField("", text: Binding(
                get: { text },
                set: { onTextChange($0) }
            ))

Когда у нас нет форматирования, то кложура onTextChange вызывает непосредственное обновление text, что приводит к body reevaluate всего инпута и выставлению нового значения. Когда есть форматирование, то text меняется не всегда, из-за чего body reevaluate не происходит, так как входные данные не изменились. При этом интересным моментом является то, что вызов get: в биндинге происходит со старым значением, однако, это никак не влияет на отображаемый текст.

Решение данной проблемы заключается в отказе от ручного создания биндига и, вместо этого, переезде на State / ObservableObject.

Первая попытка базировалась на ObservableObject. Она получилась громоздкой и сложной, а также заняла уйму времени. Обиднее всего было то, что на этапе тестирования получили разное поведение на разных версиях iOS (которое где-то и вовсе не работало).

Финальное же решение строилась на State. Оно получилось куда более простым, но логика осталась примерно прежней.

struct XTextField: View {
    
    var text: String
    var currentText: (() -> String)? = nil
    
    ...
    
    @State
    private var internalText: String
    
    init(
        text: String,
        currentText: (() -> String)? = nil,
        ...
    ) {
        ...
        self.text = text
        self.currentText = currentText
        self._internalText = State(wrappedValue: text)
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ...
        }
        .onChange(of: internalText) { newText in
            // Текст изменился из-за пользовательского ввода
            if newText != text {
                // Уведомляем о вводе
                onTextChange(newText)
                if let currentText = currentText {
                    // Сразу после уведомления запрашиваем актуальный текст в моменте
                    internalText = currentText()
                }
            }
        }
        .onChange(of: text) { newText in
            // Пришел новый текст через конструктор
            internalText = newText
        }
    }
    
    @ViewBuilder
    private func textField() -> some View {
        ZStack {
            // Получаем биндинг из State
            TextField("", text: $internalText)
            SecureField("", text: $internalText)
        }
    }

Здесь мы оперируем тремя вещами:

  • внешний текст (text)

  • внутренний текст (internalText)

  • текст в моменте (кложура currentText).

Внешний текст прилетает в конструкторе и обновляет внутренний текст. Сам же он обновляется примерно во всех случаях, когда нет форматирования, где onTextChange побочно вызывает смену передаваемого в конструктор параметра text.

Внутренний текст необходим для общения с TextField / SecureField, для отслеживания пользовательского ввода. Именно его значение и будет отрисовываться.

Что же такое "текст в моменте"? Это кложура, которая способна отдавать актуальное значение необходимого текста в любой момент времени. Применима она только для случаев с форматированием, когда вводимый текст может отличаться от отображаемого.

Рассмотрим пример с нашим форматом 1-2-3-4:

  • введено 1-2-3-4 (и этому же равен внешний текст)

  • пользователь вводит букву a

  • внутренний текст меняется на 1-2-3-4a и больше не равен внешнему тексту

  • 1-2-3-4a отправляется в onTextChange

    • реализация onTextChange форматирует 1-2-3-4a в 1-2-3-4 и посылает последнее значение в XObject (или в любую другую сущность, ответственную за хранение актуального текста)

    • выставленное значение (1-2-3-4) передается в качестве внешнего текста в конструкторе нашего инпута

    • внешний текст не изменился, ничего не происходит (в плане body reevaluate)

  • получаем значение текста в моменте. Оно равно тому, что сейчас находится в нашем XObject. В нашем случае это 1-2-3-4

  • внутренний текст меняется на значение текста в моменте, а именно на 1-2-3-4

XTextField.swift - промежуточный код #5
import SwiftUI

struct XTextField: View {
    
    var text: String
    var currentText: (() -> String)? = nil
    
    var isEnabled: Bool = true
    var hasError: Bool = false
    var isSecureTextEntry: Bool = false
    
    var label: String = ""
    var placeholder: String = ""
    
    var trailingImage: UIImage? = nil
    
    var captionText: String? = nil
    
    var onTextChange: (String) -> Void
    
    var onTrailingImageClick: () -> Void = { }
    
    @FocusState
    var focusField: FocusField?
    @State
    private var internalText: String
    
    init(
        text: String,
        currentText: (() -> String)? = nil,
        isEnabled: Bool = true,
        hasError: Bool = false,
        isSecureTextEntry: Bool = false,
        label: String = "",
        placeholder: String = "",
        trailingImage: UIImage? = nil,
        captionText: String? = nil,
        onTextChange: @escaping (String) -> Void,
        onTrailingImageClick: @escaping () -> Void = { }
    ) {
        self.text = text
        self.currentText = currentText
        self.isEnabled = isEnabled
        self.hasError = hasError
        self.isSecureTextEntry = isSecureTextEntry
        self.label = label
        self.placeholder = placeholder
        self.trailingImage = trailingImage
        self.captionText = captionText
        self.onTextChange = onTextChange
        self.onTrailingImageClick = onTrailingImageClick
        self._internalText = State(wrappedValue: text)
    }
    
    var body: some View {
        let _ = Self._printChanges()
        VStack(alignment: .leading, spacing: 0) {
            let shouldUseLabel = isFocused || !internalText.isEmpty
            Text(label)
                .font(labelFont)
                .foregroundColor(labelColor)
                .opacity(shouldUseLabel ? 1 : 0)
            
            Spacer().frame(height: 8)
            
            HStack(alignment: .center, spacing: 0) {
                ZStack(alignment: .leading) {
                    Text(placeholder)
                        .font(placeholderFont)
                        .foregroundColor(placeholderColor)
                        .opacity(shouldUseLabel ? 0 : 1)
                    textField()
                        .textFieldStyle(.plain) // https://stackoverflow.com/a/74745555
                        .font(textFont)
                        .foregroundColor(textColor)
                        .accentColor(cursorColor)
                }
                
                if let trailingImage = trailingImage {
                    Spacer().frame(width: 16)
                    Image(uiImage: trailingImage)
                        .foregroundColor(trailingImageColor)
                        .frame(width: 24, height: 24, alignment: .center)
                        .onTapGesture { onTrailingImageClick() }
                }
            }.frame(minHeight: 24)
            
            ZStack {
                Spacer()
                Rectangle()
                    .fill(underlineColor)
                    .frame(height: isFocused ? 2 : 1)
            }.frame(height: 16, alignment: .bottom)
            
            if let captionText = captionText {
                Spacer().frame(height: 8)
                Text(captionText)
                    .font(captionFont)
                    .foregroundColor(captionColor)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture {
            focusField = isSecureTextEntry ? .secureField : .textField
        }
        .disabled(!isEnabled)
        .opacity(isEnabled ? 1 : 0.4)
        .onChange(of: isSecureTextEntry) { newIsSecureTextEntry in
            if focusField != nil {
                focusField = newIsSecureTextEntry ? .secureField : .textField
            }
        }
        .onChange(of: internalText) { newText in
            if newText != text {
                onTextChange(newText)
                if let currentText = currentText {
                    internalText = currentText()
                }
            }
        }
        .onChange(of: text) { newText in
            internalText = newText
        }
        .background(.random)
    }
    
    @ViewBuilder
    private func textField() -> some View {
        ZStack {
            TextField("", text: $internalText)
                .focused($focusField, equals: .textField)
                .opacity(isSecureTextEntry ? 0 : 1)
            
            SecureField("", text: $internalText)
                .focused($focusField, equals: .secureField)
                .opacity(isSecureTextEntry ? 1 : 0)
        }
    }
    
    var isFocused: Bool {
        focusField != nil
    }
}

// MARK: Colors
private extension XTextField {
    
    var labelColor: Color { Color.gray }
    
    var placeholderColor: Color { Color.gray }
    
    var textColor: Color { Color.black }
    
    var cursorColor: Color {
        if hasError {
            Color.red
        } else {
            Color.green
        }
    }
    
    var underlineColor: Color {
        if hasError {
            Color.red
        } else {
            if isFocused {
                Color.black
            } else {
                Color.gray
            }
        }
    }
    
    var captionColor: Color {
        if hasError {
            Color.red
        } else {
            Color.gray
        }
    }
    
    var trailingImageColor: Color { Color.gray }
}

// MARK: Fonts
private extension XTextField {
    
    var labelFont: Font { Font.caption }
    
    var placeholderFont: Font { Font.body }
    
    var textFont: Font { Font.body }
    
    var captionFont: Font { Font.caption }
}

extension XTextField {
    
    enum FocusField {
        case textField, secureField
    }
}

extension View {
    
    @ViewBuilder
    public func preventPasswordReset() -> some View {
        onReceive(
            NotificationCenter.default.publisher(
                for: UITextField.textDidBeginEditingNotification
            )
        ) { obj in
            if let textField = obj.object as? UITextField {
                if textField.isSecureTextEntry {
                    let currentText = textField.text ?? ""
                    textField.text = ""
                    textField.insertText(currentText)
                }
            }
        }
    }
}

private extension ShapeStyle where Self == Color {
    static var random: Color {
        Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        )
    }
}

XScreen.swift - промежуточный код #5
import SwiftUI

class XObject<T>: ObservableObject {
    var value: T
    lazy var update: (T) -> Void = {
        { [weak self] newValue in
            guard let self = self else { return }
            self.value = newValue
            self.objectWillChange.send()
        }
    }()
    
    init(value: T) {
        self.value = value
    }
}

struct XScreen: View {
    
    @StateObject private var text1Object: XObject<String>
    @StateObject private var text2Object: XObject<String>
    @StateObject private var text1SecureObject: XObject<Bool>
    
    private var onText1Change: (String) -> Void
    private var onText2Change: (String) -> Void
    
    private var onTrailing1IconClick: () -> Void
    
    private var text2CurrentText: () -> String
    
    init() {
        var text1Obj = XObject(value: "TextField1")
        var text2Obj = XObject(value: "TextField2")
        var text1SecureObj = XObject(value: false)
        
        self._text1Object = StateObject(wrappedValue: text1Obj)
        self._text2Object = StateObject(wrappedValue: text2Obj)
        self._text1SecureObject = StateObject(wrappedValue: text1SecureObj)
        
        self.onText1Change = { text in
            text1Obj.update(text)
            print("TextField1: text changed to \(text)")
        }
        self.onText2Change = { text in
            let numbers = text
                .filter { $0.isNumber }
                .prefix(4)
            let charArray = Array(numbers)
            let formattedText = charArray
                .map { String($0) }
                .joined(separator: "-")
            text2Obj.update(formattedText)
            print("TextField2: text changed to \(formattedText)")
        }
        self.onTrailing1IconClick = {
            text1SecureObj.update(!text1SecureObj.value)
        }
        
        self.text2CurrentText = { text2Obj.value }
    }
    
    var body: some View {
        VStack {
            XTextField(
                text: text1Object.value,
                hasError: false,
                isSecureTextEntry: text1SecureObject.value,
                label: "TextField1",
                placeholder: "TextField1",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField1",
                onTextChange: onText1Change,
                onTrailingImageClick: onTrailing1IconClick
            ).padding(20)
            
            XTextField(
                text: text2Object.value,
                currentText: text2CurrentText,
                hasError: true,
                label: "TextField2",
                placeholder: "TextField2",
                trailingImage: UIImage.add.withRenderingMode(.alwaysTemplate),
                captionText: "TextField2",
                onTextChange: onText2Change
            ).padding(20)
        }
        .preventPasswordReset()
    }
}

Проблема №6. Производительность

Возможно этой части никогда и не было бы, если бы не одно НО.

По мере написания продакшн кода существовала еще одна проблема с body reevaluate, о которой дальше и пойдет речь. Но у нас ее сейчас нет. Так произошло из-за того, что использовались разные версии эмуляторов (iOS 16.4 vs iOS 17.2).

Итак, все gif анимации в статье выше были записаны с эмулятора iOS 17.2. Давайте запустим то же самое на iOS 16.4 и посмотрим, в чем проблема.

Мы никак не воздействуем на второй инпут. Но у него вызывается body reevaluate. Причем вызывается в двух случаях:

  • если меняем значение безопасного ввода первого инпута

  • если делаем свайп экрана вниз

Баг это или ожидаемое поведение — неясно. Но, как уже писал ранее, на iOS 17.2 такого поведения не наблюдается.

Проблема кроется в @FocusState, которая, если честно, все же больше похожа на баг. Любые изменения фокуса приводят к тому, что все View, в которых есть чтение из @FocusState поля, подвергаются body reevaluate.

Избавиться от данной проблемы полностью не получится. Однако, можно минимизировать ее ущерб (важно отметить, что это не факт, а предположение-догадка, и, если это не так, то подобное решение излишне)

Что мы сделаем?

  • создадим FocusHolderView, которая будет хранить @FocusState (и забирать весь удар от body reevaluate на себя)

  • в нашем XTextField переедем с @FocusState на FocusState.Binding, который будем получать из FocusHolderView

  • FocusHolderView будет принимать в конструкторе кложуру FocusState.Binding -> View

  • XTextField переименуем в XTextField_Base (так как данная View больше не будет использоваться конечным пользователем)

  • вынесем вызов FocusHolderView + XTextField_Base в отдельную новую ViewXTextField, которая уже, как раз-таки, будет использоваться публично

struct FocusHolderView<Content: View, FSValue: Hashable>: View {
    
    @FocusState
    var focusValue: FSValue?
    
    var content: (FocusState<FSValue?>.Binding) -> Content
    
    var body: some View {
        content($focusValue)
    }
}
struct XTextField_Base: View {
    
    ...
    
//    @FocusState
//    var focusField: FocusField?
    var focusField: FocusState<FocusField?>.Binding
    
    init(
        ...
        focusField: FocusState<FocusField?>.Binding
    ) {
        ...
        self.focusField = focusField
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ...
        }
        .onTapGesture {
//            focusField = isSecureTextEntry ? .secureField : .textField
            focusField.wrappedValue = isSecureTextEntry ? .secureField : .textField
        }
        .onChange(of: isSecureTextEntry) { newIsSecureTextEntry in
//            if focusField.wrappedValue != nil {
//                focusField.wrappedValue = newIsSecureTextEntry ? .secureField : .textField
//            }
            if focusField.wrappedValue != nil {
                focusField.wrappedValue = newIsSecureTextEntry ? .secureField : .textField
            }
        }
    }
    
    @ViewBuilder
    private func textField() -> some View {
        ZStack {
            TextField("", text: $internalText)
//                .focused($focusField, equals: .textField)
                .focused(focusField, equals: .textField)
            
            SecureField("", text: $internalText)
//                .focused($focusField, equals: .secureField)
                .focused(focusField, equals: .secureField)
        }
    }
    
    var isFocused: Bool {
//        focusField != nil
        focusField.wrappedValue != nil
    }
}
struct XTextField: View {
    
    ... // все параметры для конструктора из XTextField_Base + init блок
    
    var body: some View {
        FocusHolderView { focusBinding in
            XTextField_Base(
                ...
                focusField: focusBinding
            )
        }
    }
}

Видим, что мы избавились от проблемы с излишнем body reevaluate.

На самом деле проблема осталась. Просто она переехала в FocusHolderView. FocusHolderView.body куда меньше, чем body нашего инпута и, исходя из этого, было сделано предположение, что таким образом можно минимизировать ущерб от данной проблемы.

Заключение

Мы проделали большой путь, попутно применяя очевидные решения и не очень. Выстроили инпут, который отвечает большому числу требований: поддерживает форматирование, работает с паролями, сам убирается и пылесосит и многое другое. Более того, не забыли про производительности и разобрали компонент и с этого аспекта тоже.

Кстати, финальный код, а также историю изменений в виде коммитов можно найти по этой ссылке.

Был рад поделиться опытом. А с какими сложностями вам приходилось сталкиваться в SwiftUI?

Комментарии (2)


  1. jibji
    05.06.2024 05:04

    Толково


  1. vova9110
    05.06.2024 05:04

    Потихоньку изучая СвифтЮАй после Компоуза могу сказать, что зря я жаловался прежде...

    Кто-б мог подумать, что при вёрстке, на любой шаг влево/вправо придётся расплачиваться созданием вьюшки с нуля. В том же Компоузе есть BasicTextField, ну и благословенная VisualTransformation

    Плюс, автору отдельное спасибо за подсказку на рандомный бэкграунд при рекомпозиции - удобнее, чем логирование выходит