Property Wrapper doesn't affect TextField - swiftui

I wrote MaxCount propertyWrapper to limit String count in TextField. However, while Text view shows trimmed String, TextField shows full String.
I can achieve expected behavior via below ViewModifier, but this doesn't seem a good practice to me, I would like to achieve that behaviour via #propertyWrapper.
TextField("Type here...", text: $text)
.onChange(of: text) { newText in
// Check if newText has more characters than maxCount, if so trim it.
guard maxCount < newText.count else { text = newText; return }
text = String(newText.prefix(maxCount))
}
MaxCount.swift
#propertyWrapper struct MaxCount<T: RangeReplaceableCollection>: DynamicProperty {
// MARK: Properties
private var count: Int = 0
#State private var value: T = .init()
var wrappedValue: T {
get { value }
nonmutating set {
value = limitValue(newValue, count: count)
}
}
var projectedValue: Binding<T> {
Binding(
get: { value },
set: { wrappedValue = $0 }
)
}
// MARK: Initilizations
init(wrappedValue: T, _ count: Int) {
self.count = count
self._value = State(wrappedValue: limitValue(wrappedValue, count: count))
}
// MARK: Functions
private func limitValue(_ value: T, count: Int) -> T {
guard value.count > count else { return value }
let lastIndex = value.index(value.startIndex, offsetBy: count - 1)
let firstIndex = value.startIndex
return T(value[firstIndex...lastIndex])
}
}
ContentView.swift
struct ContentView: View {
#MaxCount(5) private var text = "This is a test text"
var body: some View {
VStack {
Text(text)
TextField("Type here...", text: $text)
}
}
}

I ended up building a new TextField as below.
Drawback: It doesn't support initialization with formatters which exists in TextField
struct FilteredTextField<Label: View>: View {
// MARK: Properties
private let label: Label
private var bindingText: Binding<String>
private let prompt: Text?
private let filter: (String) -> Bool
#State private var stateText: String
#State private var lastValidText: String = ""
// MARK: Initializations
init(text: Binding<String>, prompt: Text? = nil, label: () -> Label, filter: ((String) -> Bool)? = nil) {
self.label = label()
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
init(_ titleKey: LocalizedStringKey, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
self.label = Text(titleKey)
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
init(_ title: String, text: Binding<String>, prompt: Text? = nil, filter: ((String) -> Bool)? = nil) where Label == Text {
self.label = Text(title)
self.bindingText = text
self.prompt = prompt
self.filter = filter ?? { _ in true }
self._stateText = State(initialValue: text.wrappedValue)
}
// MARK: View
var body: some View {
TextField(text: $stateText, prompt: prompt, label: { label })
.onChange(of: stateText) { newValue in
guard newValue != bindingText.wrappedValue else { return }
guard filter(newValue) else { stateText = lastValidText; return }
bindingText.wrappedValue = newValue
}
.onChange(of: bindingText.wrappedValue) { newValue in
if filter(newValue) { lastValidText = newValue }
stateText = newValue
}
}
}
Usage
struct ContentView: View {
#State var test: String = ""
var body: some View {
VStack {
HStack {
Text("Default TextField")
TextField(text: $test, label: { Text("Type here...") })
}
HStack {
Text("FilteredTextField")
FilteredTextField(text: $test, label: { Text("Type here...") }) { inputString in inputString.count <= 5 }
}
}
}
}

Related

How to clear all text in a UIViewRepresentable Text Field with a view modifier

I'm trying to delete all text inside a Text Field when a button to the right of the text field is tapped. When I tap the clear button it behaves as though the text has been cleared. It shows the placeholder text which is set to be shown when the Text Field is cleared. However, the text is still showing 'underneath' the placeholder text. I can delete the typed in text by using the delete key on the keyboard, one character at a time, but clearing all at once should be a quicker option.
I have a UIViewRepresentable for a TextField as below. The reason I'm using a UIViewRepresentable is because I need the keyboard language layout to change languages as required by the user. SwiftUI doesn't currently support this:
struct UITextFieldViewRepresentable: UIViewRepresentable {
#Binding var language: String
#Binding var text: String
var onCommit: (() -> Bool)
init(language: Binding<String>, text: Binding<String>, onCommit: #escaping() -> Bool = { return true }) {
self._language = language
self._text = text
self.onCommit = onCommit
}
func makeUIView(context: Context) -> WordTextField {
let textField = WordTextField(onCommit: onCommit)
textField.language = self.language
textField.textAlignment = .center
textField.font = UIFont.systemFont(ofSize: 15, weight: .regular)
return textField
}
func updateUIView(_ uiView: WordTextField, context: Context) {
uiView.textDidChange = {
text = $0
}
// Change the keyboard language only when uiView.language != self.language
//
if uiView.language != self.language {
uiView.language = self.language
}
}
}
class WordTextField: UITextField, UITextFieldDelegate {
var textDidChange: ((String) -> Void)?
var onCommit: (() -> Bool)
var language: String? {
didSet {
if self.isFirstResponder{
self.resignFirstResponder()
self.becomeFirstResponder()
}
}
}
init(textDidChange: ( (String) -> Void)? = nil, onCommit: #escaping () -> Bool = { return true }, language: String? = nil) {
self.textDidChange = textDidChange
self.onCommit = onCommit
self.language = language
super.init(frame: .zero)
self.delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func textFieldDidChangeSelection(_ textField: UITextField) {
textDidChange?(textField.text ?? "")
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
return onCommit()
}
override var textInputMode: UITextInputMode? {
if let language = self.language {
print("text input mode: \(language)")
for inputMode in UITextInputMode.activeInputModes {
if let inputModeLanguage = inputMode.primaryLanguage, inputModeLanguage == language {
return inputMode
}
}
}
return super.textInputMode
}
}
I call it as below:
#State private var userAnswer: String = ""
#State private var keyboardLanguage: String = ""
#State private var hintColour: Color = .green
#State private var hintDisabled: Bool = false
UITextFieldViewRepresentable(language: $keyboardLanguage, text: Binding<String>(
get: { self.userAnswer },
set: {
self.userAnswer = $0.allowedCharacters(string: $0)
self.enableHint()
}), onCommit: {
checkAnswer()
return true
})
.showClearButton(userAnswer: $userAnswer, hintDisabled: $hintDisabled, hintColour: $hintColour)
The view modifier .showClearButton does not clear the userAnswer text when tapped. Tapping the keyboard delete button clears userAnswer one character at a time.
The view modifier code is below:
extension View {
func showClearButton(userAnswer: Binding<String>, hintDisabled: Binding<Bool>, hintColour: Binding<Color>) -> some View {
self.modifier(TextFieldClearButton(text: userAnswer, hintDisabled: hintDisabled, hintColour: hintColour))
}
}
struct TextFieldClearButton: ViewModifier {
#Binding var text: String
#Binding var hintDisabled: Bool
#Binding var hintColour: Color
func body(content: Content) -> some View {
HStack {
content
if !text.isEmpty {
Button(
action: { self.text = ""; hintDisabled = false; hintColour = Color.systemBlue },
label: {
Image(systemName: "delete.left")
.foregroundColor(Color(UIColor.gray))
.imageScale(.large)
.frame(width: 44, height: 44, alignment: .trailing)
}
)
}
}
}
}
You forgot to set the new text on the UITextField which in the case of the clear button is an empty string, fix like this:
func updateUIView(_ uiView: WordTextField, context: Context) {
// set the new text
uiView.text = text
// you also forgot to set the new onCommit
uiView.onCommit = onCommit
uiView.textDidChange = {
text = $0
}
// Change the keyboard language only when uiView.language != self.language
//
if uiView.language != self.language {
uiView.language = self.language
}
}
Also, you could benefit from a Coordinator here, it works like this:
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UITextField {
context.coordinator.textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
context.coordinator.textDidChange = {
text = $0
}
uiView.onCommit = onCommit
uiView.text = text // maybe could check it is different first
if uiView.language != self.language {
uiView.language = self.language
}
}
class Coordinator: NSObject, UITextFieldDelegate {
lazy var textField: {
let textField = UITextField()
textField.textAlignment = .center
textField.font = UIFont.systemFont(ofSize: 15, weight: .regular)
return textField
}()
// your closure properties
// all your delegate methods
}

SwiftUI: Custom binding that get value from #StateObject property is set back to old value after StateObject property change

I'm trying to implement a passcode view in iOS. I was following the guide here.
I'm trying to improve it a bit so it allows me to create a passcode by enter same passcode twice. I added a "state" property to the #StateObject and want to clear entered passcode after user input the passcode first time.
Here is my current code:
LockScreenModel.swift
====================
import Foundation
class LockScreenModel: ObservableObject {
#Published var pin: String = ""
#Published var showPin = false
#Published var isDisabled = false
#Published var state = LockScreenState.normal
}
enum LockScreenState: String, CaseIterable {
case new
case verify
case normal
case remove
}
====================
LockScreen.swift
====================
import SwiftUI
struct LockScreen: View {
#StateObject var lockScreenModel = LockScreenModel()
let initialState: LockScreenState
var handler: (String, LockScreenState, (Bool) -> Void) -> Void
var body: some View {
VStack(spacing: 40) {
Text(NSLocalizedString("lock.label.\(lockScreenModel.state.rawValue)", comment: "")).font(.title)
ZStack {
pinDots
backgroundField
}
showPinStack
}
.onAppear(perform: {lockScreenModel.state = initialState})
.onDisappear(perform: {
lockScreenModel.pin = ""
lockScreenModel.showPin = false
lockScreenModel.isDisabled = false
lockScreenModel.state = .normal
})
}
private var pinDots: some View {
HStack {
Spacer()
ForEach(0..<6) { index in
Image(systemName: self.getImageName(at: index))
.font(.system(size: 30, weight: .thin, design: .default))
Spacer()
}
}
}
private var backgroundField: some View {
let boundPin = Binding<String>(get: { lockScreenModel.pin }, set: { newValue in
if newValue.last?.isWholeNumber == true {
lockScreenModel.pin = newValue
}
self.submitPin()
})
return TextField("", text: boundPin, onCommit: submitPin)
.accentColor(.clear)
.foregroundColor(.clear)
.keyboardType(.numberPad)
.disabled(lockScreenModel.isDisabled)
}
private var showPinStack: some View {
HStack {
Spacer()
if !lockScreenModel.pin.isEmpty {
showPinButton
}
}
.frame(height: 20)
.padding([.trailing])
}
private var showPinButton: some View {
Button(action: {
lockScreenModel.showPin.toggle()
}, label: {
lockScreenModel.showPin ?
Image(systemName: "eye.slash.fill").foregroundColor(.primary) :
Image(systemName: "eye.fill").foregroundColor(.primary)
})
}
private func submitPin() {
guard !lockScreenModel.pin.isEmpty else {
lockScreenModel.showPin = false
return
}
if lockScreenModel.pin.count == 6 {
lockScreenModel.isDisabled = true
handler(lockScreenModel.pin, lockScreenModel.state) { isSuccess in
if isSuccess && lockScreenModel.state == .new {
lockScreenModel.state = .verify
lockScreenModel.pin = ""
lockScreenModel.isDisabled = false
} else if !isSuccess {
lockScreenModel.pin = ""
lockScreenModel.isDisabled = false
print("this has to called after showing toast why is the failure")
}
}
}
// this code is never reached under normal circumstances. If the user pastes a text with count higher than the
// max digits, we remove the additional characters and make a recursive call.
if lockScreenModel.pin.count > 6 {
lockScreenModel.pin = String(lockScreenModel.pin.prefix(6))
submitPin()
}
}
private func getImageName(at index: Int) -> String {
if index >= lockScreenModel.pin.count {
return "circle"
}
if lockScreenModel.showPin {
return lockScreenModel.pin.digits[index].numberString + ".circle"
}
return "circle.fill"
}
}
extension String {
var digits: [Int] {
var result = [Int]()
for char in self {
if let number = Int(String(char)) {
result.append(number)
}
}
return result
}
}
extension Int {
var numberString: String {
guard self < 10 else { return "0" }
return String(self)
}
}
====================
The problem is the line lockScreenModel.state = .verify. If I include this line, the passcode TextField won't get cleared, but if I remove this line, the passcode TextField is cleared.
If I add a breakpoint in set method of boundPin, I can see after set pin to empty and state to verify, the set method of boundPin is called with newValue of the old pin which I have no idea why. If I only set pin to empty but don't set state to verify, that set method of boundPin won't get called which confuse me even more. I can't figure out which caused this strange behavior.

SwiftUI: stay on same TextField after on commit?

is it possible in SwiftUI to keep the typing cursor on the same Textfield even after the user taps on Return key on keyboard ?
Here is my code:
struct RowView: View {
#Binding var checklistItem: ChecklistItem
#ObservedObject var checklist = Checklist()
#ObservedObject var viewModel: ChecklistViewModel
var body: some View {
HStack {
Button {
self.checklistItem.isChecked.toggle()
self.viewModel.updateChecklist(checklistItem)
} label: {
Circle()
.strokeBorder(checklistItem.isChecked ? checklistSelected : contentPrimary, lineWidth: checklistItem.isChecked ? 6 : 2)
.foregroundColor(backgroundSecondary)
.clipShape(Circle())
.frame(width: 16, height: 16)
}.buttonStyle(BorderlessButtonStyle())
// swiftlint:disable trailing_closure
TextField(
"Add...",
text: $checklistItem.name,
onCommit: {
do {
if !checklistItem.name.isEmpty {
self.viewModel.updateChecklist(checklistItem)
self.checklistItem.name = checklistItem.name
}
}
}
)
// swiftlint:enable trailing_closure
.foregroundColor(checklistItem.isChecked ? contentTertiary : contentPrimary)
Spacer()
}
}
}
So after the user taps on return key on keyboard, TextField() onCommit should be activated normally but the cursor stays in that same textfield so the user can keep typing in new elements.
iOS 15+
You can use #FocusState and, on commit, immediately set the TextField to have focus again.
Example:
struct ContentView: View {
#State private var text = "Hello world!"
#FocusState private var isFieldFocused: Bool
var body: some View {
Form {
TextField("Field", text: $text, onCommit: {
isFieldFocused = true
print("onCommit")
})
.focused($isFieldFocused)
}
}
}
Result:
I was able to achieve this in iOS 14 by creating a custom TextField class:
struct AlwaysActiveTextField: UIViewRepresentable {
let placeholder: String
#Binding var text: String
var focusable: Binding<[Bool]>?
var returnKeyType: UIReturnKeyType = .next
var autocapitalizationType: UITextAutocapitalizationType = .none
var keyboardType: UIKeyboardType = .default
var isSecureTextEntry: Bool
var tag: Int
var onCommit: () -> Void
func makeUIView(context: Context) -> UITextField {
let activeTextField = UITextField(frame: .zero)
activeTextField.delegate = context.coordinator
activeTextField.placeholder = placeholder
activeTextField.font = .systemFont(ofSize: 14)
activeTextField.attributedPlaceholder = NSAttributedString(
string: placeholder,
attributes: [NSAttributedString.Key.foregroundColor: UIColor(contentSecondary)]
)
activeTextField.returnKeyType = returnKeyType
activeTextField.autocapitalizationType = autocapitalizationType
activeTextField.keyboardType = keyboardType
activeTextField.isSecureTextEntry = isSecureTextEntry
activeTextField.textAlignment = .left
activeTextField.tag = tag
// toolbar
if keyboardType == .numberPad { // keyboard does not have next so add next button in the toolbar
var items = [UIBarButtonItem]()
let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let toolbar: UIToolbar = UIToolbar()
toolbar.sizeToFit()
let nextButton = UIBarButtonItem(title: "Next", style: .plain, target: context.coordinator, action: #selector(Coordinator.showNextTextField))
items.append(contentsOf: [spacer, nextButton])
toolbar.setItems(items, animated: false)
activeTextField.inputAccessoryView = toolbar
}
// Editin listener
activeTextField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldDidChange(_:)), for: .editingChanged)
return activeTextField
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
if let focusable = focusable?.wrappedValue {
if focusable[uiView.tag] { // set focused
uiView.becomeFirstResponder()
} else { // remove keyboard
uiView.resignFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UITextFieldDelegate {
let activeTextField: AlwaysActiveTextField
var hasEndedViaReturn = false
weak var textField: UITextField?
init(_ activeTextField: AlwaysActiveTextField) {
self.activeTextField = activeTextField
}
func textFieldDidBeginEditing(_ textField: UITextField) {
self.textField = textField
guard let textFieldCount = activeTextField.focusable?.wrappedValue.count else { return }
var focusable: [Bool] = Array(repeating: false, count: textFieldCount) // remove focus from all text field
focusable[textField.tag] = true // mark current textField focused
activeTextField.focusable?.wrappedValue = focusable
}
// work around for number pad
#objc
func showNextTextField() {
if let textField = self.textField {
_ = textFieldShouldReturn(textField)
}
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
hasEndedViaReturn = true
guard var focusable = activeTextField.focusable?.wrappedValue else {
textField.resignFirstResponder()
return true
}
focusable[textField.tag] = true // mark current textField focused
activeTextField.focusable?.wrappedValue = focusable
activeTextField.onCommit()
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
if !hasEndedViaReturn {// user dismisses keyboard
guard let textFieldCount = activeTextField.focusable?.wrappedValue.count else { return }
// reset all text field, so that makeUIView cannot trigger keyboard
activeTextField.focusable?.wrappedValue = Array(repeating: false, count: textFieldCount)
} else {
hasEndedViaReturn = false
}
}
#objc
func textFieldDidChange(_ textField: UITextField) {
activeTextField.text = textField.text ?? ""
}
}
}
and use in in the SwiftUI view by adding this #State variable:
#State var fieldFocus: [Bool] = [false]
and add the Textfield code it self anywhere waiting the view body:
AlwaysActiveTextField(
placeholder: "Add...",
text: $newItemName,
focusable: $fieldFocus,
returnKeyType: .next,
isSecureTextEntry: false,
tag: 0,
onCommit: {
print("any action you want on commit")
}
)

Editable Text() [ Text() that can be TextField() on double tap]

Some time ago I had a problem with making UI logic of view that can be Label and EditBox both:
I mean Text() -> doubleClick action -> display TextEdit() -> fill it with new text -> Enter key press -> text changes saved and displayed in Text()
solution is in answer below
import SwiftUI
#available(macOS 10.15, *)
public struct EditableLabel: View {
#Binding var text: String
#State private var newValue: String = ""
#State var editProcessGoing = false { didSet{ newValue = text } }
let onEditEnd: () -> Void
public init(_ txt: Binding<String>, onEditEnd: #escaping () -> Void) {
_text = txt
self.onEditEnd = onEditEnd
}
#ViewBuilder
public var body: some View {
ZStack {
// Text variation of View
Text(text)
.opacity(editProcessGoing ? 0 : 1)
// TextField for edit mode of View
TextField("", text: $newValue,
onEditingChanged: { _ in },
onCommit: { text = newValue; editProcessGoing = false; onEditEnd() } )
.opacity(editProcessGoing ? 1 : 0)
}
// Enable EditMode on double tap
.onTapGesture(count: 2, perform: { editProcessGoing = true } )
// Exit from EditMode on Esc key press
.onExitCommand(perform: { editProcessGoing = false; newValue = text })
}
}
sample of usage:
EditableLabel($name, onEditEnd: { print("New name is *\(name)* ") } )

SwiftUI: Textfield shake animation when input is not valid

I want to create a shake animation when the User presses the "save"-button and the input is not valid. My first approach is this (to simplify I removed the modifiers and not for this case relevant attributes):
View:
struct CreateDeckView: View {
#StateObject var viewModel = CreateDeckViewModel()
HStack {
TextField("Enter title", text: $viewModel.title)
.offset(x: viewModel.isValid ? 0 : 10) //
.animation(Animation.default.repeatCount(5).speed(4)) // shake animation
Button(action: {
viewModel.buttonPressed = true
viewModel.saveDeck(){
self.presentationMode.wrappedValue.dismiss()
}
}, label: {
Text("Save")
})
}
}
ViewModel:
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var buttonPressed = false
var validTitle: Bool {
buttonPressed && !(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){ ... }
}
But this solution doesn't really work. For the first time when I press the button nothing happens. After that when I change the textfield it starts to shake.
using GeometryEffect,
struct ContentView: View {
#StateObject var viewModel = CreateDeckViewModel()
var body: some View {
HStack {
TextField("Enter title", text: $viewModel.title)
.modifier(ShakeEffect(shakes: viewModel.shouldShake ? 2 : 0)) //<- here
.animation(Animation.default.repeatCount(6).speed(3))
Button(action: {
viewModel.saveDeck(){
...
}
}, label: {
Text("Save")
})
}
}
}
//here
struct ShakeEffect: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
return ProjectionTransform(CGAffineTransform(translationX: -30 * sin(position * 2 * .pi), y: 0))
}
init(shakes: Int) {
position = CGFloat(shakes)
}
var position: CGFloat
var animatableData: CGFloat {
get { position }
set { position = newValue }
}
}
class CreateDeckViewModel: ObservableObject{
#Published var title: String = ""
#Published var shouldShake = false
var validTitle: Bool {
!(title.trimmingCharacters(in: .whitespacesAndNewlines) == "")
}
public func saveDeck(completion: #escaping () -> ()){
if !validTitle {
shouldShake.toggle() //<- here (you can use PassThrough subject insteadof toggling.)
}
}
}