Combine n number of Text objects - ios

In SwiftUI, you can combine Text objects like so:
var body: some View {
Text("You can") + Text(" add lots of objects that wrap normally")
}
This gives the benefit of having multiple Text objects that act as one, meaning they are on the same line and wrap appropriately.
What I can't figure out is how to combine n number of Text objects, based on an array or really any way to increment.
I've tried ForEach like so,
var texts = ["You can", " add lots of objects that wrap normally"]
var body: some View {
ForEach(texts.identified(by: \.self)) {
Text($0)
}
}
But that looks like this
when I want it to look like this
Could anyone show me how this is done?
Use case: Styling part of a Text object and not the other parts. Like what is possible with NSMutableAttributedString

You seem to be describing something like this:
var body: some View {
let strings = ["manny ", "moe", " jack"]
var texts = strings.map{Text($0)}
texts[1] = texts[1].underline() // illustrating your use case
return texts[1...].reduce(texts[0], +)
}
However, I think it would be better to wait until attributed strings arrive.

Related

Pull unique items & calculations from an array in SwiftUI

I'm trying to create a Metrics view in my SwiftUI app. I'm building this so I can track my poker sessions I play. Each Session model looks like this:
struct PokerSession: Hashable, Codable, Identifiable {
var id = UUID()
let location: String
let game: String
let stakes: String
let date: Date
let profit: Int
let notes: String
let imageName: String
let startTime: Date
let endTime: Date
In my Metrics View I would like to iterate through all of the sessions which are stored in an array of type: [PokerSession] that displays a List of all the unique locations and their corresponding profit totals. This is how my code looks right now, obviously not working because I'm getting duplicate locations:
List {
ForEach(viewModel.sessions) { location in
HStack {
Text(location.location)
Spacer()
Text("$500")
.bold()
.foregroundColor(.green)
}
}
.navigationBarTitle(Text("Profit by Location"))
}
Does anyone know how I can grab unique locations and calculate their total profit? Many thanks!
I'd define a new type to store your totals:
struct LocationProfit: Identifiable {
let id = UUID()
let location: String
let profit: Int
}
Then you can group all your sessions by location in a dictionary, transform the sessions into a sum of profits, then transform the location and profit totals into our LocationProfit structure.
let locationProfits = Dictionary(grouping: sessions) { element in
element.location
}.mapValues { sessionsGroupedByLocation -> Int in
sessionsGroupedByLocation
.map { $0.profit }
.reduce(0, +)
}.map { locationProfitPair in
LocationProfit(location: locationProfitPair.key, profit: locationProfitPair.value)
}
Just stuff the whole conversion into your viewModel and iterate over the locationProfits in your View.
You need to filter which will return a filtered array of your PokerSessions by location then you reduce the filtered array to get the sum of your profit like this:
viewModel.sessions.filter( { $0.location == location}).reduce(0) { $0 + $1.profit})
edit with use case assuming it is in USD:
Text("$\(viewModel.sessions.filter( { $0.location == location}).reduce(0) { $0 + $1.profit}))")
You could also turn it into a string by add .description to the end of it. If you only need to display the data to the user, and don't need it generally available to the app, this is the simplest way of doing it.
You can generate an array of unique locations like this:
Array(Set(viewModel.sessions.map { $0.location }))
Use this in your ForEach to iterate over the location strings.
EDIT
To calculate the total per location, you can simply query your existing data (as described in Yrb's answer):
viewModel.sessions.filter({ $0.location == location }).reduce(0) { $0 + $1.profit }
Since you already use a view model, I suggest to hide both the creation of the unique locations list and the total profit lookup inside the view model, which will make your UI code much cleaner and more readable.
Also, if you have a lot of PokerSession entries, I suggest to generate the data only once and cache it inside the view model, e.g. using a custom data model as described in Rob's answer (or even simpler by generating a dictionary which maps from location string to total profit). The good thing about abstracting the data access away inside the view model is that you can introduce the caching approach later, without changing the UI layer, since you will be able to keep the same access methods and just change their implementation.
My solution was borrowed from most of your responses, thank you for the feedback. Below is the final code that worked, was able to avoid creating a dictionary and instead relied on map and reduce.
List {
ForEach(viewModel.uniqueLocations, id: \.self) { location in
HStack {
Text(location)
Spacer()
Text("$" + "\(viewModel.sessions.filter({$0.location == location}).reduce(0) { $0 + $1.profit})")
.bold()
.foregroundColor(.green)
}
}
.navigationBarTitle(Text("Profit by Location"))
}

Filtering a SectionedFetchRequest in SwiftUI on iOS15

I am trying to implement the new iOS 15 SectionedFetchRequest with Core Data. This allows you to change the predicates and sort descriptors at runtime.
My model has a Group, which can contain many Items.
I declare the fetch request like this, then assign to it in the view's init method, so that I can apply a custom NSPredicate depending on where the view is being shown.
#SectionedFetchRequest private var mySections: SectionedFetchResults<String, Item>
The they are displayed in a ForEach loop, similar to the code from the WWDC 2021 session.
var body: some View {
// Other code
List {
ForEach(mySections) { section in
Section(header: Text(section.id) {
ForEach(section) { item in
Text(item.name) // Just an example
}
}
}
}
}
I am also implementing the .searchable view modifier, to provide search functionality. This is bound to a string variable like this.
#State private var searchText: String = ""
Then in the body property I use .searchable($searchText) on the bottom of the list.
This all works without issues. However, the problem comes when I try to use the search text to filter my results at runtime. I tried using a dynamic predicate instead and changing the fetch request predicate (compounded with the one from initialisation), but this proved very buggy (maintaining the search state when the view popped to a detail view was very difficult and led to details views popping unexpectedly).
So, I decided to filter the fetch request in runtime with standard Swift code. I have done this before easily with a one-dimensional array. However, SectionedFetchRequest is not just a two-dimensional array; it is a generic struct with Section and Element types and the section has an important id property (used to define the section heading).
Standard two dimensional filtering (such as this answer does not work, as the types are lost and you merely get a two-dimensional array.
I can easily get the sections that have an item that fits the search, but this returns all of the items in the section (rather than just the ones that fit the search):
mySections.filter { section in
section.contains(where: checkItemMethod())
}
I tried to then make my own type that conformed to RandomAccessCollection (required by the ForEach) that I could populate recursively, but this seems very complex as there are so many "sub-protocols" to conform to.
Is there a way to easily filter a SectionedFetchRequest based on the inner objects, and only returning sections containing objects that match?
Thanks
EDIT
Here is my view init code. The SelectedSort type is straight out of the WWDC video. I just persist the by and order values in User Defaults.
init(filterGroup: DrugViewFilterGroup) {
self.filterGroup = filterGroup
// This relates to retrieving persisted value of the SelectedSort type demonstrated in WWDC
let sortBy = UserDefaults.standard.integer(forKey: "sortBy")
let sortOrder = UserDefaults.standard.integer(forKey: "sortOrder")
let selectedSort = SelectedSort(by: sortBy, order: sortOrder)
let sectionIdentifier = sorts[selectedSort.index].section
let sortDescriptors = sorts[selectedSort.index].descriptors
// Create sectioned fetch request
let sfr = SectionedFetchRequest<String, Item>(sectionIdentifier: sectionIdentifier, sortDescriptors: sortDescriptors, predicate: filterGroupPredicate(for: filterGroup), animation: .spring())
// Assign fetch request to the #SectionedFetchRequest property of the view
self._mySections = sfr
}
This all works just fine. The filterGroupPredicate(for:) method just returns an NSPredicate to show a subset of data.
The issue is with implementing the search bar with the .searchable modifier. This is bound to #State private var searchText: String.
Here is an example function filtering the results by the search text:
private func getFilteredSections() -> [SectionedFetchResults<String, Item>.Section] {
let cleanedFilter = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanedFilter.isEmpty else { return mySections.map { $0 } }
func checkItem(_ item: Item) -> Bool {
return item.name.localizedCaseInsensitiveContains(cleanedFilter)
}
return mySections.filter { section in
section.contains(where: checkItem)
}
This works, but the problem is it only returns the "top level" i.e. the sections themselves. If a section contains a single item meeting the search criteria, all items in the section will still be returned.
Filtering it like a multidimensional array (as noted in original question), results in a simple 2D array with no section information to display in the nested ForEach, and thus the functionality of the SectionedFetchRequest is lost.
I tried setting the fetch request predicate (to a compound predicate based on the one set in init and one for the search text). This works briefly, but as the view is reinitialised on e.g. navigating to a tapped detail view, it results in erratic search bar behaviour.
SectionedFetchResults has a property nsPredicate. If you set that with your desired NSPredicate(format:), the results are updated more or less immediately.
Works with SwiftUI search with something like:
mySection.nsPredicate = NSPredicate(format: "name contains[cd] %#", searchText)
Most of the other code can be erased. You need a little magic to manage displaying the filtered collection.
SectionedFetchResults > nsPredicate in Apple Dev Docs

Exceeding max Text("") concatenation length - SwiftUI -

With reference to the answer posted by Asperi (https://stackoverflow.com/users/12299030/asperi) on Question: Highlight a specific part of the text in SwiftUI
I have found his answer quite useful, however, when my String input exceeds 32k characters the app crashes, so I am assuming the String() is a max of 32k and am looking for a work around.
In my app, if someone searches for the word "pancake", the search word will be stored and when the user looks at the detail page (of lets say a recipe), the word pancake will highlight. All works well with this answer, but when the recipe exceeds 32k characters, the app crashes with exceeding index range messages. (specific error message: Thread 1: EXC_BAD_ACCESS (code=2, address=0x16d43ffb4))
Here is the modified code from the answer on that question:
This will print the data:
hilightedText(str: self.recipes.last!.recipeData!)
.multilineTextAlignment(.leading)
.font(.system(size: CGFloat( settings.fontSize )))
There is obviously more to this code above, but in essence, it iterates a database, and finds the last record containing 'search word' and displays the recipeData here, which is a large string contained in the database.
to implement the highlightedText functionality:
func hilightedText(str: String) -> Text {
let textToSearch = searched
var result: Text!
for word in str.split(separator: " ") {
var text = Text(word)
if word.uppercased().contains(textToSearch.uppercased()) {
text = text.bold().foregroundColor(.yellow)
}
//THIS NEXT LINE has been identified as the problem:
result = (result == nil ? text : result + Text(" ") + text)
}
return result
}
I've modified the answer from Asperi slightly to suit my needs and all works really well, unless I come across a recipeData entry that is larger than 32k in size, as stated before.
I have tried Replacing String with a few other data types and nothing works..
Any ideas?
Thank you!
UPDATE:
After lengthy discussion in the comments, it appears that the root cause of the issue is at some point, for some records, I am exceeding the maximum Text("") concatenations.
In the above code, each word is split out, evaluated and added to the long string "result" which winds up looking like this:
Text("word") + Text(" ") + Text("Word")
and so on.
This is done, so I can easily apply color attributes per word, but it would seem that once I hit a certain number of words (which is less that 32k, one record was 22k and crashed), the app crashes.
Leo suggested https://stackoverflow.com/a/59531265/2303865 this thread as an alternative and I will have to attempt to implement that instead.
Thank you..
Hmm... unexpected limitation... anyway - learn something new.
Ok, here is improved algorithm, which should move that limitation far away.
Tested with Xcode 12 / iOS 14. (also updated code in referenced topic Highlight a specific part of the text in SwiftUI)
func hilightedText(str: String, searched: String) -> Text {
guard !str.isEmpty && !searched.isEmpty else { return Text(str) }
var result = Text("")
var range = str.startIndex..<str.endIndex
repeat {
guard let found = str.range(of: searched, options: .caseInsensitive, range: range, locale: nil) else {
result = result + Text(str[range])
break
}
let prefix = str[range.lowerBound..<found.lowerBound]
result = result + Text(prefix) + Text(str[found]).bold().foregroundColor(.yellow)
range = found.upperBound..<str.endIndex
} while (true)
return result
}
After much discussion in the comments, it became clear that I was hitting a maximum Text() concatenations limit, so beware, apparently there is one.
I realized however that I only needed to have a split Text("Word") when that particular word required special formatting (IE highlighting, etc), otherwise, I could concat all of the raw strings together and send that as a Text("String of words").
This approach mitigated the action of having every single word sent as a Text("Word" by itself and cut down greatly on the number of Text()'s being returned.
see code below that solved the issue:
func hilightedText(str: String) -> Text {
let textToSearch = searched
var result = Text(" ")
var words: String = " "
var foundWord = false
for line in str.split(whereSeparator: \.isNewline) {
for word in line.split(whereSeparator: \.isWhitespace) {
if word.localizedStandardContains(textToSearch) {
foundWord = true
result += Text(words) + Text(" ") + Text(word).bold().foregroundColor(.yellow)
} else {
if foundWord {
words = ""
}
foundWord = false
words += " " + word
}
}
words += "\n\n"
}
return result + Text(" ") + Text(words)
}
extension Text {
static func += (lhs: inout Text, rhs: Text) {
lhs = lhs + rhs
}
}
It could use some cleanup as also discussed in the comments for splitting by whitespace, etc, but this was just to overcome the issue of crashing. Needs some additional testing before I call it good, but no more crashing..
ADDED:
the suggestion to use separator by .isWhiteSpace worked, but when I put it back together, everything was a space, no more line breaks, so I added the extra split by line breaks to preserve the line breaks.

SwiftUI Struct for Dictionary entry

I have decided after several years of development to restart my project using SwiftUI to future proof as much as I can.
In my current project I have my data in several .CSV's which I then process into dictionaries and then create a list of entries on screen using an Array of keys which are generated programmatically from user input.
All examples I've seen for SwiftUI use JSON. However the structure of these files are identical to an Array of Dictionaries. My question is; is it possible to create a Struct of a dictionary entry to pass in a forEach watching an Array of Keys (data inside the dictionary will never change and I am not looking to iterate or watch the dictionary).
My main goal is to reuse as much as possible but am willing to change what I have to get full benefit of SwiftUI. Obviously if I change the way I store my data almost everything will be useless. If there's a real benefit to converting my data to JSON or even start using something like CoreData I will.
If I'm understanding correctly, you are looking to
Take some user input
Transform that into keys that correspond to your data dictionary
Extract the data for the matching keys into some struct
Display a list of those structs using SwiftUI
Here is a simple implementation of those steps.
import SwiftUI
// A dictionary containing your data
let data: [String: Int] = [
"apples": 5,
"bananas": 3,
"cherries": 12
]
// A struct representing a match from your data
struct Item {
var name: String
var quantity: Int
}
// A view that displays the contents of your struct
struct RowView: View {
var item: Item
var body: some View {
Text("\(item.quantity) \(item.name)")
}
}
struct ContentView: View {
#State private var searchText: String = ""
func items(matching search: String) -> [Item] {
// 2 - split the user input into individual keys
let split = search.split(separator: " ", omittingEmptySubsequences: true).map { $0.lowercased() }
// 3 - turn any matching keys/values in your dictionary to a view model
return split.compactMap { name in
guard let quantity = data[name] else { return nil }
return Item(name: name, quantity: quantity)
}
}
var body: some View {
VStack {
// 1 - get user input
TextField("Search", text: $searchText)
.padding()
// 4 - display the matching values using ForEach (note that the id: \.name is important)
List {
ForEach(items(matching: searchText), id: \.name) { item in
RowView(item: item)
}
}
}
}
}
You'll see that as you type in the text field, if you enter any of the strings "apples", "bananas", or "cherries", a corresponding row will pop into your list.
Depending on the size of your list, and what kind of validation you are performing on your users search queries, you might need to be a little more careful about doing the filtering/searching in an efficient way (e.g. using Combine to only split and search after the user stops typing).

Linking 2 strings (or arrays)

I'm trying to make a connection between multiple arrays. Example; (Mario bros)
var names = ["Mario", "Luigi"]
var colors = ["Red", "Green"]
Instead of making if-statements such as if names == "Mario" && colors == "Red" I would like to make an easier connection - just like buttons has tags I would like to make a String-tag ("Mario".tag = 1 and "Red".tag = 1)
Note that I have 10 different arrays such as the above.
Rather than having 10 parallel arrays, I suggest you create a struct with 10 properties, and make an array of those structs.
Structs are a package of data that abstracts away the details of the contents, and lets you deal with the data as a whole.
I would recommend you check out the Swift Programming guide (in its entirity). It's very well written. In particular, here is the page on Classes and Structs.
In addition, if you want to compare one struct to, say, Mario (as you do in your example), you could implement a method == and make your struct conform to the Equatable protocol, which will allow you do something like:
if someCharacter == Mario {... //automatically compares all properties.
See The Swift Programming Language (Swift 2.2) - Protocols.
First of all you need a ModelValue
struct Element {
let name: String
let color: String
}
Now given
let names = ["Mario", "Luigi"]
let colors = ["Red", "Green"]
you can create a list of Element(s).
let elements = zip(names, colors).map { Element(name: $0.0, color: $0.1) }
Finally your can use it
elements[0].name
elements[0].color

Resources