Представьте, что вам нужно реализовать комплексный компонент инпута на 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
в отдельную новую View —XTextField
, которая уже, как раз-таки, будет использоваться публично
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)
vova9110
05.06.2024 05:04Потихоньку изучая СвифтЮАй после Компоуза могу сказать, что зря я жаловался прежде...
Кто-б мог подумать, что при вёрстке, на любой шаг влево/вправо придётся расплачиваться созданием вьюшки с нуля. В том же Компоузе есть BasicTextField, ну и благословенная VisualTransformation
Плюс, автору отдельное спасибо за подсказку на рандомный бэкграунд при рекомпозиции - удобнее, чем логирование выходит
jibji
Толково