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