I'm experimenting code from https://alanquatermain.me/programming/swiftui/2019-11-15-CoreData-and-bindings/
my goal is to have DatePicker bind to Binding< Date? > which allow for nil value instead of initiate to Date(); this is useful, if you have Date attribute in your core data model entity which accept nil as valid value.
Here is my swift playground code:
extension Binding {
init<T>(isNotNil source: Binding<T?>, defaultValue: T) where Value == Bool {
self.init(get: { source.wrappedValue != nil },
set: { source.wrappedValue = $0 ? defaultValue : nil})
}
}
struct LiveView: View {
#State private var testDate: Date? = nil
var body: some View {
VStack {
Text("abc")
Toggle("Has Due Date",
isOn: Binding(isNotNil: $testDate, defaultValue: Date()))
if testDate != nil {
DatePicker(
"Due Date",
selection: Binding($testDate)!,
displayedComponents: .date
)
}
}
}
}
let liveView = LiveView()
PlaygroundPage.current.liveView = UIHostingController(rootView: liveView)
I can't find solution to fix this code. It works when the toggle first toggled to on, but crash when the toggle turned back off.
The code seems to behave properly when I removed the DatePicker, and change the code to following:
extension Binding {
init<T>(isNotNil source: Binding<T?>, defaultValue: T) where Value == Bool {
self.init(get: { source.wrappedValue != nil },
set: { source.wrappedValue = $0 ? defaultValue : nil})
}
}
struct LiveView: View {
#State private var testDate: Date? = nil
var body: some View {
VStack {
Text("abc")
Toggle("Has Due Date",
isOn: Binding(isNotNil: $testDate, defaultValue: Date()))
if testDate != nil {
Text("\(testDate!)")
}
}
}
}
let liveView = LiveView()
PlaygroundPage.current.liveView = UIHostingController(rootView: liveView)
I suspect it's something to do with this part of the code
DatePicker("Due Date", selection: Binding($testDate)!, displayedComponents: .date )
or
problem when the source.wrappedValue set back to nil (refer to Binding extension)
The problem is that DatePicker grabs binding and is not so fast to release it even when you remove it from view, due to Toggle action, so it crashes on force unwrap optional, which becomes nil ...
The solution for this crash is
DatePicker(
"Due Date",
selection: Binding<Date>(get: {self.testDate ?? Date()}, set: {self.testDate = $0}),
displayedComponents: .date
)
An alternative solution that I use in all my SwiftUI pickers...
I learned almost all I know about SwiftUI Bindings (with Core Data) by reading that blog by Jim Dovey. The remainder is a combination of some research and quite a few hours of making mistakes.
So when I use Jim's technique to create Extensions on SwiftUI Binding then we end up with something like this for a deselection to nil...
public extension Binding where Value: Equatable {
init(_ source: Binding<Value>, deselectTo value: Value) {
self.init(get: { source.wrappedValue },
set: { source.wrappedValue = $0 == source.wrappedValue ? value : $0 }
)
}
}
Which can then be used throughout your code like this...
Picker("Due Date",
selection: Binding($testDate, deselectTo: nil),
displayedComponents: .date
)
OR when using .pickerStyle(.segmented)
Picker("Date Format Options", // for example
selection: Binding($selection, deselectTo: -1)) { ... }
.pickerStyle(.segmented)
... which sets the index of the segmented style picker to -1 as per the documentation for UISegmentedControl and selectedSegmentIndex.
The default value is noSegment (no segment selected) until the user
touches a segment. Set this property to -1 to turn off the current
selection.
Here is my solution, I added a button to remove the date and add a default date. All it's wrapped in a new component
https://gist.github.com/Fiser12/62ef54ba0048e5b62cf2f2a61f279492
import SwiftUI
struct NullableBindedValue<T>: View {
var value: Binding<T?>
var defaultView: (Binding<T>, #escaping (T?) -> Void) -> AnyView
var nullView: ( #escaping (T?) -> Void) -> AnyView
init(
_ value: Binding<T?>,
defaultView: #escaping (Binding<T>, #escaping (T?) -> Void) -> AnyView,
nullView: #escaping ( #escaping (T?) -> Void) -> AnyView
) {
self.value = value
self.defaultView = defaultView
self.nullView = nullView
}
func setValue(newValue: T?) {
self.value.wrappedValue = newValue
}
var body: some View {
HStack(spacing: 0) {
if value.unwrap() != nil {
defaultView(value.unwrap()!, setValue)
} else {
nullView(setValue)
}
}
}
}
struct DatePickerNullable: View {
var title: String
var selected: Binding<Date?>
#State var defaultToday: Bool = false
var body: some View {
NullableBindedValue(
selected,
defaultView: { date, setDate in
let setDateNil = {
setDate(nil)
self.defaultToday = false
}
return AnyView(
HStack {
DatePicker(
"",
selection: date,
displayedComponents: [.date, .hourAndMinute]
).font(.title2)
Button(action: setDateNil) {
Image(systemName: "xmark.circle")
.foregroundColor(Color.defaultColor)
.font(.title2)
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.cornerRadius(10)
}
)
},
nullView: { setDate in
let setDateNow = {
setDate(Date())
}
return AnyView(
HStack {
TextField(
title,
text: .constant("Is empty")
).font(.title2).disabled(true).textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: setDateNow) {
Image(systemName: "plus.circle")
.foregroundColor(Color.defaultColor)
.font(.title2)
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.cornerRadius(10)
}.onAppear(perform: {
if self.defaultToday {
setDateNow()
}
})
)
}
)
}
}
Most optional binding problems can be solved with this:
public func ??<T>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
Binding(
get: { lhs.wrappedValue ?? rhs },
set: { lhs.wrappedValue = $0 }
)
}
Here's how I use it with DatePicker:
DatePicker(
"",
selection: $testDate ?? Date(),
displayedComponents: [.date]
)
Related
I'm trying to have a marquee horizontal scrolling effect but my buttons are not clickable. The view renders well, but when I tap the buttons it should print out 'tapped user1', for example but there is no effect.
EDIT: If I put the marquee modifier on the ScrollView as suggested, it causes the extremely buggy scrolling behavior. Ideally, this would just be a marquee'd HStack with a bunch of clickable buttons in it with no scrolling behavior built in, but the module doesn't seem to work without the ScrollView wrapping it.
I used this link to create a Marquee view modifier: https://swiftuirecipes.com/blog/swiftui-marquee
My code for the view is below:
struct MyView: View {
var body: some View {
let users = ["user1", "user2", "user3", "user4", "user5", "user6"]
return ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(users, id: \.self) { user in
if user == users.first {
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
} else {
Text("•")
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
}
}
}.frame(height: 20)
.marquee(duration: 10)
}
}
}
The code from the marquee tutorial is below:
struct Marquee: ViewModifier {
let duration: TimeInterval
let direction: Direction
let autoreverse: Bool
#State private var offset = CGFloat.zero
#State private var parentSize = CGSize.zero
#State private var contentSize = CGSize.zero
func body(content: Content) -> some View {
// measures parent view width
Color.clear
.frame(height: 0)
// measureSize from https://swiftuirecipes.com/blog/getting-size-of-a-view-in-swiftui
.measureSize { size in
parentSize = size
updateAnimation(sizeChanged: true)
}
content
.measureSize { size in
contentSize = size
updateAnimation(sizeChanged: true)
}
.offset(x: offset)
// animationObserver from https://swiftuirecipes.com/blog/swiftui-animation-observer
.animationObserver(for: offset, onComplete: {
updateAnimation(sizeChanged: false)
})
}
private func updateAnimation(sizeChanged: Bool) {
if sizeChanged || !autoreverse {
offset = max(parentSize.width, contentSize.width) * ((direction == .leftToRight) ? -1 : 1)
}
withAnimation(.linear(duration: duration)) {
offset = -offset
}
}
enum Direction {
case leftToRight, rightToLeft
}
}
extension View {
func marquee(duration: TimeInterval,
direction: Marquee.Direction = .rightToLeft,
autoreverse: Bool = false) -> some View {
self.modifier(Marquee(duration: duration,
direction: direction,
autoreverse: autoreverse))
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct MeasureSizeModifier: ViewModifier {
func body(content: Content) -> some View {
content.background(GeometryReader { geometry in
Color.clear.preference(key: SizePreferenceKey.self,
value: geometry.size)
})
}
}
extension View {
func measureSize(perform action: #escaping (CGSize) -> Void) -> some View {
self.modifier(MeasureSizeModifier())
.onPreferenceChange(SizePreferenceKey.self, perform: action)
}
}
public struct AnimationObserverModifier<Value: VectorArithmetic>: AnimatableModifier {
// this is the view property that drives the animation - offset, opacity, etc.
private let observedValue: Value
private let onChange: ((Value) -> Void)?
private let onComplete: (() -> Void)?
// SwiftUI implicity sets this value as the animation progresses
public var animatableData: Value {
didSet {
notifyProgress()
}
}
public init(for observedValue: Value,
onChange: ((Value) -> Void)?,
onComplete: (() -> Void)?) {
self.observedValue = observedValue
self.onChange = onChange
self.onComplete = onComplete
animatableData = observedValue
}
public func body(content: Content) -> some View {
content
}
private func notifyProgress() {
DispatchQueue.main.async {
onChange?(animatableData)
if animatableData == observedValue {
onComplete?()
}
}
}
}
public extension View {
func animationObserver<Value: VectorArithmetic>(for value: Value,
onChange: ((Value) -> Void)? = nil,
onComplete: (() -> Void)? = nil) -> some View {
self.modifier(AnimationObserverModifier(for: value,
onChange: onChange,
onComplete: onComplete))
}
}
Put the modifier on scrollview, it will fix your issue
Like this.
import SwiftUI
struct MyView: View {
var body: some View {
let users = ["user1", "user2", "user3", "user4", "user5", "user6"]
return ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(users, id: \.self) { user in
if user == users.first {
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
} else {
Text("•")
Button(action: {
print("tapped \(user)")
}, label: {
Text(user)
})
}
}
}.frame(height: 20)
}
.marquee(duration: 10)
}
}
Try adding the modifier on the button .buttonStyle(PlainButtonStyle()) or similar with other button styles. If that doesn't work, then try using just the button label that you desire and add the modifier .onTapGesture {your code}
This post is mainly just meant for rob mayoff, but anybody else is free to help, because I don't know how to contact him other than making a post as I don't have enough reputation to comment.
In your answer on SwiftUI Multiple Labels Vertically Aligned, I tried to implement the code, and even though I changed nothing besides iOS requirements as per XCode warnings, I get the error of Cannot convert value of type 'VStack<TupleView<(Text, Divider, HStack<TupleView<(VStack<TupleView<(Label<Text, Text>, Label<Text, Image>)>>, VStack<TupleView<(Label<Text, Image>, Label<Text, Image>)>>)>>)>>' to closure result type 'Content' Is this the result of Xcode 13.4.1, or is it something else? I didn't see anybody have issues with the code.
My code structure is:
fileprivate struct IconWidthKey: PreferenceKey {
static var defaultValue: CGFloat? { nil }
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
switch (value, nextValue()) {
case (nil, let next): value = next
case (_, nil): break
case (.some(let current), .some(let next)): value = max(current, next)
}
}
}
extension IconWidthKey: EnvironmentKey { }
extension EnvironmentValues {
fileprivate var iconWidth: CGFloat? {
get { self[IconWidthKey.self] }
set { self[IconWidthKey.self] = newValue }
}
}
fileprivate struct IconWidthModifier: ViewModifier {
#Environment(\.iconWidth) var width
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: IconWidthKey.self, value: proxy.size.width)
})
.frame(width: width)
}
}
struct EqualIconWidthLabelStyle: LabelStyle {
#available(iOS 14.0, *)
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.icon.modifier(IconWidthModifier())
configuration.title
}
}
}
#available(iOS 14.0, *)
struct EqualIconWidthDomain<Content: View>: View {
let content: Content
#State var iconWidth: CGFloat? = nil
init(#ViewBuilder _ content: () -> Content) {
self.content = content()
}
var body: some View {
content
.environment(\.iconWidth, iconWidth)
.onPreferenceChange(IconWidthKey.self) { self.iconWidth = $0 }
.labelStyle(EqualIconWidthLabelStyle())
}
struct WelcomeScreen: View{
...
#ViewBuilder
func playWelcomeScreen() -> some View{
...
EqualIconWidthDomain {
VStack {
Text("Le Menu")
.font(.caption)
Divider()
HStack {
VStack(alignment: .leading) {
Label(
title: { Text("Strawberry") },
icon: { Text("🍓") })
Label("Money", systemImage: "banknote")
}
VStack(alignment: .leading) {
Label("People", systemImage: "person.3")
Label("Star", systemImage: "star")
}
}
}
}
...
}
}
It is just bad copy-pasting, you loose one brace
#available(iOS 14.0, *)
struct EqualIconWidthDomain<Content: View>: View {
let content: Content
#State var iconWidth: CGFloat? = nil
init(#ViewBuilder _ content: () -> Content) {
self.content = content()
}
var body: some View {
content
.environment(\.iconWidth, iconWidth)
.onPreferenceChange(IconWidthKey.self) { self.iconWidth = $0 }
.labelStyle(EqualIconWidthLabelStyle())
} // << here !!
}
In SwiftUI on MacOs, when implementing
onDrop(of supportedTypes: [String], isTargeted: Binding<Bool>?, perform action: #escaping ([NSItemProvider]) -> Bool) -> some View
we receive an array of NSItemProvider and this makes it possible to drop multiple items inside our view.
When implementing onDrag(_ data: #escaping () -> NSItemProvider) -> some View , how can we provide multiple items to drag?
I've not been able to find any examples online of multiple items drag and I'd like to know if there's another way to implement a drag operation that allows me to provide multiple NSItemProvider or the way to do it with the above method
My goal is to be able to select multiple items and drag them exactly how it happens in the Finder. In order to do that I want to provide an [URL] as [NItemProvider], but at the moment I can only provide one URL per drag Operation.
Might be worth checking if View's exportsItemProviders functions added in macOS 12 do what we need. If you use the version of List that supports multi-selection (List(selection: $selection) where #State var selection: Set<UUID> = [] (or whatever)).
Unfortunately my Mac is still on macOS 11.x so I can't test this :-/
Actually, you do not need an [NSItemProvider] to process a drag and drop with multiple items in SwiftUI. Since you must keep track of the multiple selected Items in your own selection manager anyway, use that selection when generating a custom dragging preview and when processing the drop.
Replace the ContentView of a new MacOS App project with all of the code below. This is a complete working sample of how to drag and drop multiple items using SwiftUI.
To use it, you must select one or more items in order to initiate a drag and then it/they may be dragged onto any other unselected item. The results of what would happen during the drop operation is printed on the console.
I threw this together fairly quickly, so there may be some inefficiencies in my sample, but it does seem to work well.
import SwiftUI
import Combine
struct ContentView: View {
private let items = ["Item 0", "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7"]
#StateObject var selection = StringSelectionManager()
#State private var refreshID = UUID()
#State private var dropTargetIndex: Int? = nil
var body: some View {
VStack(alignment: .leading) {
ForEach(0 ..< items.count, id: \.self) { index in
HStack {
Image(systemName: "folder")
Text(items[index])
}
.opacity((dropTargetIndex != nil) && (dropTargetIndex == index) ? 0.5 : 1.0)
// This id must change whenever the selection changes, otherwise SwiftUI will use a cached preview
.id(refreshID)
.onDrag { itemProvider(index: index) } preview: {
DraggingPreview(selection: selection)
}
.onDrop(of: [.text], delegate: MyDropDelegate(items: items,
selection: selection,
dropTargetIndex: $dropTargetIndex,
index: index) )
.padding(2)
.onTapGesture { selection.toggle(items[index]) }
.background(selection.isSelected(items[index]) ?
Color(NSColor.selectedContentBackgroundColor) : Color(NSColor.windowBackgroundColor))
.cornerRadius(5.0)
}
}
.onReceive(selection.objectWillChange, perform: { refreshID = UUID() } )
.frame(width: 300, height: 300)
}
private func itemProvider(index: Int) -> NSItemProvider {
// Only allow Items that are part of a selection to be dragged
if selection.isSelected(items[index]) {
return NSItemProvider(object: items[index] as NSString)
} else {
return NSItemProvider()
}
}
}
struct DraggingPreview: View {
var selection: StringSelectionManager
var body: some View {
VStack(alignment: .leading, spacing: 1.0) {
ForEach(selection.items, id: \.self) { item in
HStack {
Image(systemName: "folder")
Text(item)
.padding(2.0)
.background(Color(NSColor.selectedContentBackgroundColor))
.cornerRadius(5.0)
Spacer()
}
}
}
.frame(width: 300, height: 300)
}
}
struct MyDropDelegate: DropDelegate {
var items: [String]
var selection: StringSelectionManager
#Binding var dropTargetIndex: Int?
var index: Int
func dropEntered(info: DropInfo) {
dropTargetIndex = index
}
func dropExited(info: DropInfo) {
dropTargetIndex = nil
}
func validateDrop(info: DropInfo) -> Bool {
// Only allow non-selected Items to be drop targets
if !selection.isSelected(items[index]) {
return info.hasItemsConforming(to: [.text])
} else {
return false
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
// Sets the proper DropOperation
if !selection.isSelected(items[index]) {
let dragOperation = NSEvent.modifierFlags.contains(NSEvent.ModifierFlags.option) ? DropOperation.copy : DropOperation.move
return DropProposal(operation: dragOperation)
} else {
return DropProposal(operation: .forbidden)
}
}
func performDrop(info: DropInfo) -> Bool {
// Only allows non-selected Items to be drop targets & gets the "operation"
let dropProposal = dropUpdated(info: info)
if dropProposal?.operation != .forbidden {
let dropOperation = dropProposal!.operation == .move ? "Move" : "Copy"
if selection.selection.count > 1 {
for item in selection.selection {
print("\(dropOperation): \(item) Onto: \(items[index])")
}
} else {
// https://stackoverflow.com/a/69325742/899918
if let item = info.itemProviders(for: ["public.utf8-plain-text"]).first {
item.loadItem(forTypeIdentifier: "public.utf8-plain-text", options: nil) { (data, error) in
if let data = data as? Data {
let item = NSString(data: data, encoding: 4)
print("\(dropOperation): \(item ?? "") Onto: \(items[index])")
}
}
}
return true
}
}
return false
}
}
class StringSelectionManager: ObservableObject {
#Published var selection: Set<String> = Set<String>()
let objectWillChange = PassthroughSubject<Void, Never>()
// Helper for ForEach
var items: [String] {
return Array(selection)
}
func isSelected(_ value: String) -> Bool {
return selection.contains(value)
}
func toggle(_ value: String) {
if isSelected(value) {
deselect(value)
} else {
select(value)
}
}
func select(_ value: String?) {
if let value = value {
objectWillChange.send()
selection.insert(value)
}
}
func deselect(_ value: String) {
objectWillChange.send()
selection.remove(value)
}
}
I want to change the DatePicker's date view. I just want to get a month and year selection. I want to assign ObservedObject to a variable at each selection.
My Code:
#State private var date = Date()
var body: some View {
DatePicker("", selection: $date, in: Date()..., displayedComponents: .date)
.labelsHidden()
.datePickerStyle(WheelDatePickerStyle())
.onDisappear(){
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MM/yyyy"
self.cardData.validThru = dateFormatter.string(from: self.date)
}
}
As others have already commented You would need to implement an HStack with two Pickers:
struct ContentView: View {
#State var monthIndex: Int = 0
#State var yearIndex: Int = 0
let monthSymbols = Calendar.current.monthSymbols
let years = Array(Date().year..<Date().year+10)
var body: some View {
GeometryReader{ geometry in
HStack(spacing: 0) {
Picker(selection: self.$monthIndex.onChange(self.monthChanged), label: Text("")) {
ForEach(0..<self.monthSymbols.count) { index in
Text(self.monthSymbols[index])
}
}.frame(maxWidth: geometry.size.width / 2).clipped()
Picker(selection: self.$yearIndex.onChange(self.yearChanged), label: Text("")) {
ForEach(0..<self.years.count) { index in
Text(String(self.years[index]))
}
}.frame(maxWidth: geometry.size.width / 2).clipped()
}
}
}
func monthChanged(_ index: Int) {
print("\(years[yearIndex]), \(index+1)")
print("Month: \(monthSymbols[index])")
}
func yearChanged(_ index: Int) {
print("\(years[index]), \(monthIndex+1)")
print("Month: \(monthSymbols[monthIndex])")
}
}
You would need this helper from this post to monitor the Picker changes
extension Binding {
func onChange(_ completion: #escaping (Value) -> Void) -> Binding<Value> {
.init(get:{ self.wrappedValue }, set:{ self.wrappedValue = $0; completion($0) })
}
}
And this calendar helper
extension Date {
var year: Int { Calendar.current.component(.year, from: self) }
}
I am trying to validate user input in a TextField by removing certain characters using a regular expression. Unfortunately, I am running into problems with the didSet method of the text var calling itself recursively.
import SwiftUI
import Combine
class TextValidator: ObservableObject {
#Published var text = "" {
didSet {
print("didSet")
text = text.replacingOccurrences(
of: "\\W", with: "", options: .regularExpression
) // `\W` is an escape sequence that matches non-word characters.
}
}
}
struct ContentView: View {
#ObservedObject var textValidator = TextValidator()
var body: some View {
TextField("Type Here", text: $textValidator.text)
.padding(.horizontal, 20.0)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
On the swift docs (see the AudioChannel struct), Apple provides an example in which a property is re-assigned within its own didSet method and explicitly notes that this does not cause the didSet method to be called again. I did some testing in a playground and confirmed this behavior. However, things seem to work differently when I use an ObservableObject and a Published variable.
How do I prevent the didSet method from calling itself recursively?
I tried the examples in this post, but none of them worked. Apple may have changed things since then, so this post is NOT a duplicate of that one.
Also, setting the text back to oldValue within the didSet method upon encountering invalid characters would mean that if a user pastes text, then the entire text would be removed, as opposed to only the invalid characters being removed. So that option won't work.
Since SwiftUI 2 you can check the input using the onChange method and do any validations or changes there:
TextField("", value: $text)
.onChange(of: text) { [text] newValue in
// do any validation or alteration here.
// 'text' is the old value, 'newValue' is the new one.
}
Try to validate what you want in the TextField onRecive method like this:
class TextValidator: ObservableObject {
#Published var text = ""
}
struct ContentView: View {
#ObservedObject var textValidator = TextValidator()
var body: some View {
TextField("Type Here", text: $textValidator.text)
.padding(.horizontal, 20.0)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onReceive(Just(textValidator.text)) { newValue in
let value = newValue.replacingOccurrences(
of: "\\W", with: "", options: .regularExpression)
if value != newValue {
self.textValidator.text = value
}
print(newValue)
}
}
}
Here is possible approach using proxy binding, which still also allow separation of view & view model logic
class TextValidator: ObservableObject {
#Published var text = ""
func validate(_ value: String) -> String {
value.replacingOccurrences(
of: "\\W", with: "", options: .regularExpression
)
}
}
struct ContentView: View {
#ObservedObject var textValidator = TextValidator()
var body: some View {
let validatingText = Binding<String>(
get: { self.textValidator.text },
set: { self.textValidator.text = self.textValidator.validate($0) }
)
return TextField("Type Here", text: validatingText)
.padding(.horizontal, 20.0)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
2021 | SwiftUI 2
Custom extension usage:
TextField("New Branch name", text: $model.newNameUnified)
.ignoreSymbols( symbols: [" ", "\n"], string: $model.newNameUnified )
Extension:
#available(OSX 11.0, *)
public extension TextField {
func ignoreSymbols(symbols: [Character], string: Binding<String>) -> some View {
self.modifier( IgnoreSymbols(symbols: symbols, string: string) )
}
}
#available(OSX 11.0, *)
public struct IgnoreSymbols: ViewModifier {
var symbols: [Character]
var string: Binding<String>
public func body (content: Content) -> some View
{
content.onChange(of: string.wrappedValue) { value in
var newValue = value
for symbol in symbols {
newValue = newValue.replace(of: "\(symbol)", to: "")
}
if value != newValue {
string.wrappedValue = newValue
}
}
}
}
Here's what I came up with:
struct ValidatableTextField: View {
let placeholder: String
#State private var text = ""
var validation: (String) -> Bool
#Binding private var sourceText: String
init(_ placeholder: String, text: Binding<String>, validation: #escaping (String) -> Bool) {
self.placeholder = placeholder
self.validation = validation
self._sourceText = text
self.text = text.wrappedValue
}
var body: some View {
TextField(placeholder, text: $text)
.onChange(of: text) { newValue in
if validation(newValue) {
self.sourceText = newValue
} else {
self.text = sourceText
}
}
}
}
Usage:
ValidatableTextField("Placeholder", text: $text, validation: { !$0.contains("%") })
Note: this code doesn't solve specifically your problem but shows how to deal with validations in general.
Change body to this to solve your problem:
TextField(placeholder, text: $text)
.onChange(of: text) { newValue in
let value = newValue.replacingOccurrences(of: "\\W", with: "", options: .regularExpression)
if value != newValue {
self.sourceText = newValue
self.text = sourceText
}
}
Since didSet and willSet are always called when setting values, and objectWillChange triggers an update to the TextField (which triggers didSet again), a loop was created when the underlying value is updated unconditionally in didSet.
Updating the underlying value conditionally breaks the loop.
For example:
import Combine
class TextValidator: ObservableObject {
#Published var text = "" {
didSet {
if oldValue == text || text == acceptableValue(oldValue) {
return
}
text = acceptableValue(text)
}
}
var acceptableValue: (String) -> String = { $0 }
}
import SwiftUI
struct TestingValidation: View {
#StateObject var textValidator: TextValidator = {
let o = TextValidator()
o.acceptableValue = { $0.replacingOccurrences(
of: "\\W", with: "", options: .regularExpression) }
return o
}()
#StateObject var textValidator2: TextValidator = {
let o = TextValidator()
o.acceptableValue = { $0.replacingOccurrences(
of: "\\D", with: "", options: .regularExpression) }
return o
}()
var body: some View {
VStack {
Text("Word characters only")
TextField("Type here", text: $textValidator.text)
Text("Digits only")
TextField("Type here", text: $textValidator2.text)
}
.padding(.horizontal, 20.0)
.textFieldStyle(RoundedBorderTextFieldStyle())
.disableAutocorrection(true)
.autocapitalization(.none)
}
}