Build Recursive Text View in SwiftUI - swift

My goal is to create a SwiftUI view that takes a String and automatically formats that text into Text views. The portion of the string that needs formatting is found using regex and then returned as a Range<String.Index>. This can be used to reconstruct the String once the formatting has been applied to the appropriate Text views. Since there could be multiple instances of text that needs to be formatted, running the formatting function should be done recursively.
struct AttributedText: View {
#State var text: String
var body: some View {
AttributedTextView(text: text)
}
#ViewBuilder
private func AttributedTextView(text: String) -> some View {
if let range = text.range(of: "[0-9]+d[0-9]+", options: .regularExpression) {
//The unattributed text
Text(text[text.startIndex..<range.lowerBound]) +
//Append the attributed text
Text(text[range]).bold() +
//Search for additional instances of text that needs attribution
AttributedTextView(text: String(text[range.upperBound..<text.endIndex]))
} else {
//If the searched text is not found, add the rest of the string to the end
Text(text)
}
}
I get an error Cannot convert value of type 'some View' to expected argument type 'Text', with the recommended fix being to update the recursive line to AttributedTextView(text: String(text[range.upperBound..<text.endIndex])) as! Text. I apply this fix, but still see the same compiler error with the same suggested fix.
A few workarounds that I've tried:
Changing the return type from some View to Text. This creates a different error Cannot convert value of type '_ConditionalContent<Text, Text>' to specified type 'Text'. I didn't really explore this further, as it does make sense that the return value is reliant on that conditional.
Returning a Group rather than a Text, which causes additional errors throughout the SwiftUI file
Neither of these solutions feel very "Swifty". What is another way to go about this? Am I misunderstanding something in SwiftUI?

There are a few things to clarify here:
The + overload of Text only works between Texts which is why it's saying it cannot convert some View (your return type) to Text. Text + Text == Text, Text + some View == ☠️
Changing the return type to Text doesn't work for you because you're using #ViewBuilder, remove #ViewBuilder and it'll work fine.
Why? #ViewBuilder allows SwiftUI to defer evaluation of the closure until later but ensures it'll result in a specific view type (not AnyView). In the case where your closure returns either a Text or an Image this is handy but in your case where it always results in Text there's no need, #ViewBuilder forces the return type to be ConditionalContent<Text, Text> so that it could have different types.
Here's what should work:
private static func attributedTextView(text: String) -> Text {
if let range = text.range(of: "[0-9]+d[0-9]+", options: .regularExpression) {
//The unattributed text
return Text(text[text.startIndex..<range.lowerBound]) +
//Append the attributed text
Text(text[range]).bold() +
//Search for additional instances of text that needs attribution
AttributedTextView(text: String(text[range.upperBound..<text.endIndex]))
} else {
//If the searched text is not found, add the rest of the string to the end
return Text(text)
}
}
I made it static too because there's no state here it's a pure function and lowercased it so it was clear it was a function not a type (the function name looks like a View type).
You'd just call it Self.attributedTextView(text: ...)

Related

SwiftUI Localization of Strings inside an Array

I'm trying to localize my App in English and German and everything worked for now, expect the strings inside an array apparently get not localized.
I have this array, which holds the options for my Picker:
let sorting = ["Newest First", "Oldest First"]
This is my Picker, which works correctly function wise:
Picker("Sort by", selection: $sortingSelection) {
ForEach(sorting, id: \.self) {
Text($0)
.foregroundColor(.accentColor)
.font(.footnote)
}
}
.pickerStyle(MenuPickerStyle())
And this is the correleting Localizable.strings (German):
"Newest First" = "Neueres Zuerst";
"Oldest First" = "Älteres Zuerst";
So I tried just writing it as strings, which is the easiest way to use localization now.
I also tried using the it as a variable, but this doesn't work:
let localizedString1 = "Newest First"
let localizedString2 = "Oldest First"
let sorting = [localizedString1, localizedString2]
I also saw this post:
Swift: Localize an Array of strings
but like the comment on the answer says, is there a way to get some example code? Or is there a better method now?
As stated in Text documentation :
If you intialize a text view with a variable value, the view uses the init(:) initializer, which doesn’t localize the string. However, you can request localization by creating a LocalizedStringKey instance first, which triggers the init(:tableName:bundle:comment:) initializer instead:
// Don't localize a string variable...
Text(writingImplement)
// ...unless you explicitly convert it to a localized string key.
Text(LocalizedStringKey(writingImplement))
The array defaults to type String, you can specify the type to LocalizedStringKey
let sorting: [LocalizedStringKey] = ["Newest First", "Oldest First"]
You might have to change the selection type too to make them match.
This works for me:
Picker("Select Category", selection: $transactionTypeString) {
ForEach(transactionTypes, id:\.self) {
Text(LocalizedStringKey($0))
}
}

How do I get the string value from Text?

I have a custom view that gets passed some Text objects. But I can't figure out how to get the string value out of them. For example:
let text = Text("hello")
// no way to get "hello" back
I've gone through the documentation, but for some reason the properties are not publicized.
in SwiftUI, you can use #State to bind variables you want to change.
#State var value = "Test value"
and in the view:
Text(value)
Then you can normally
print(value)
I'm not sure of the context and if this helps with your particular scenario, but I came across your post as I had just been having trouble testing a Text value.
What I worked out for me, was I could use the Text's verbatim parameter to test the String (as below):
it("should show the content I'm after") {
expect(viewModel.someText) == Text(verbatim: "some content I'm after")
}

How can I capitalize one word in a UILabel in swift?

I am trying to make a dynamic UILabel like this:
"I Love Watching The BBC"
As the label is dynamic, I will have no idea of its contents.
I can leave the text alone and let the user define what they want. I can capitalize the whole string, the first letter of each word, or all words in a string.
The problem is that when capitalizing, words that are entered as uppercase become lower case.
So, in the example above
BBC
becomes
Bbc
I've searched all over the web, and don't think there is a way to do this.
As requested, code so far:
cell.projectName?.text = projectNameArray[indexPath.row].localizedCapitalized
I suggest you to add this extension to your code.
extension String {
func capitalizingFirstLetter() -> String {
return prefix(1).capitalized + dropFirst()
}
mutating func capitalizeFirstLetter() {
self = self.capitalizingFirstLetter()
}
}
(Form Hacking With Swift)
Also for the UILabel.text property apply a custom function that split each word in string, and apply the capital letter.
An example could be that:
extension String {
func capitalizeWords() -> String {
self.split(separator: " ")
.map({ String($0).capitalizingFirstLetter() })
.joined(separator: " ")
}
}
The complexity could be decreased I think, but it's just a working hint ;)

SwiftUI TextField won't change its contents under weird corner case with Binding<String> computed property

I have a TextField inside a SwiftUI body. I have bound it to a #State var through an intermediary binding which lets me get and set a computed value...
struct ExampleView: View {
#State var symbolToBeValidated: String = ""
var body: some View {
let binding = Binding<String> (get: {
return self.symbolToBeValidated
}, set: {
var newString = $0.uppercased()
self.symbolToBeValidated = newString // <- fig. 1: redundant assignment I wish I didn't have to put
newString = newString.replacingOccurrences(
of: #"[^A-Z]"#,
with: "",
options: .regularExpression
)
self.symbolToBeValidated = newString // <- fig. 2: the final, truly valid assignment
})
let form = Form {
Text("What symbol do you wish to analyze?")
TextField("ex.AAPL", text: binding)
// [...]
I'm using the intermediary Binding so that I can transform the string to always be an Uppercased format only containing letters A-Z (as referenced by my .regularExpression). (I'm trying to make it so that the TextField only shows a validly formatted Stock Symbol on each keypress).
This works, somewhat. The problem I discovered is that if I don't call the assignment twice (as seen in fig 1) the TextField will begin to show numbers and letters (even though it isn't included in the symbolToBeValidated string. This happens, I suspect, because SwiftUI is checking the oldValue against the newValue internally, and because it hasn't changed in the background, it doesn't call a refresh to get the internal value again. The way I've found to thwart this is to include an extra assignment before the .replacingOccurences call.
This results in the number or symbol being flashed on the screen for a blip as it is being typed by the user, then it is correctly removed by the .replacingOccurences call.
There must be a more elegant way to do this. I went down the Formatter class type and tried this alternative only because Formatter resulted in a similar behavior where the errant character would blip on the screen before being removed.
If someone knows a way for this to be intercepted before anything is displayed on the screen, I would appreciate it. This is super nit-picky, but I'm just fishing for the right answer here.
Try this:
extension Binding where Value == String {
public func validated() -> Self {
return .init(
get: { self.wrappedValue },
set: {
var newString = $0.uppercased()
newString = newString.replacingOccurrences(
of: #"[^A-Z]"#,
with: "",
options: .regularExpression
)
self.wrappedValue = newString
}
)
}
}
// ...
TextField("ex.AAPL", text: self.$symbolToBeValidated.validated())
This way also allows you to reuse and test the validation code.

Setting Text View to be Int rather than String? Swift

I have a text view for the user to input an Int.
I am having an issue with saving the result into an Int variable as it default thinks its a string.
What would be the best solution to force the output as an Int? I am using a numerical only keyboard so the user cannot enter strings
code:
#IBOutlet weak var userExerciseWeight: UITextField!
var userExerciseWeightSet = Int()
if let userExerciseWeightSet = Int(self.userExerciseWeight.text!) {
}
You can simply use if let construct for this. textView always will be string. all you need to do is convert textView text to Int.
if let intText = Int(self.textView.text) {
print(intText)
}
I am having an issue with saving the result into an Int variable as it default thinks its a string.
Text view is a view, not a model. What it displays is always a string. When the string happens to represent an Int, your code is responsible for converting Int to String when setting the initial value, and then for converting String back to Int when reading the value back from the view.
if let intVal = Int(textView.text) {
... intVal from text is valid
} else {
... text does not represent an int - e.g. it's empty
}
The same approach applies to displaying and reading values of other types: your program is responsible for formatting and parsing the data into an appropriate type.