Change the options in a Picker dynamically using distinct arrays - swift

I'm trying to get a Picker to update dynamically depending on the selection of the prior Picker. In order to achieve this, I'm using a multidimensional array. Unfortunately this seems to confuse my ForEach loop and I noticed the following message in the logs:
ForEach<Range<Int>, Int, Text> count (3) != its initial count (5).ForEach(:content:)should only be used for *constant* data. Instead conform data toIdentifiableor useForEach(:id:content:)and provide an explicitid!
This kinda makes sense, I'm guessing what is happening is that I'm passing it one array and it keeps referring to it, so as far as it is concerned, it keeps changing constantly whenever I pass it another array. I believe the way to resolve this is to use the id parameter that can be passed to ForEach, although I'm not sure this would actually solve it and I'm not sure what I would use. The other solution would be to somehow destroy the Picker and recreate it? Any ideas?
My code follows. If you run it, you'll notice that moving around the first picker can result in an out of bounds exception.
import SwiftUI
struct ContentView: View {
#State private var baseNumber = ""
#State private var dimensionSelection = 1
#State private var baseUnitSelection = 0
#State private var convertedUnitSelection = 0
let temperatureUnits = ["Celsius", "Fahrenheit", "Kelvin"]
let lengthUnits = ["meters", "kilometers", "feet", "yards", "miles"]
let timeUnits = ["seconds", "minutes", "hours", "days"]
let volumeUnits = ["milliliters", "liters", "cups", "pints", "gallons"]
let dimensionChoices = ["Temperature", "Length", "Time", "Volume"]
let dimensions: [[String]]
init () {
dimensions = [temperatureUnits, lengthUnits, timeUnits, volumeUnits]
}
var convertedValue: Double {
var result: Double = 0
let base = Double(baseNumber) ?? 0
if temperatureUnits[baseUnitSelection] == "Celsius" {
if convertedUnitSelection == 0 {
result = base
} else if convertedUnitSelection == 1 {
result = base * 9/5 + 32
} else if convertedUnitSelection == 2 {
result = base + 273.15
}
}
return result
}
var body: some View {
NavigationView {
Form {
Section {
TextField("Enter a number", text: $baseNumber)
.keyboardType(.decimalPad)
}
Section(header: Text("Select the type of conversion")) {
Picker("Dimension", selection: $dimensionSelection) {
ForEach(0 ..< dimensionChoices.count) {
Text(self.dimensionChoices[$0])
}
}.pickerStyle(SegmentedPickerStyle())
}
Group {
Section(header: Text("Select the base unit")) {
Picker("Base Unit", selection: $baseUnitSelection) {
ForEach(0 ..< self.dimensions[self.dimensionSelection].count) {
Text(self.dimensions[self.dimensionSelection][$0])
}
}.pickerStyle(SegmentedPickerStyle())
}
Section(header: Text("Select the unit to convert to")) {
Picker("Converted Unit", selection: $convertedUnitSelection) {
ForEach(0 ..< self.dimensions[self.dimensionSelection].count) {
Text(self.dimensions[self.dimensionSelection][$0])
}
}.pickerStyle(SegmentedPickerStyle())
}
}
Section(header: Text("The converted value is")) {
Text("\(convertedValue) \(dimensions[dimensionSelection][convertedUnitSelection])")
}
}.navigationBarTitle("Unit Converter")
}
}
}

I hate to answer my own question, but after spending some time on it, I think it's worth summarizing my findings in case it helps somebody. To summarize, I was trying to set the second Picker depending on what the selection of the first Picker was.
If you run the code that I pasted as is, you will get an out of bounds. This is only the case if I set #State private var dimensionSelection = 1 and the second array is larger than the first array. If you start with smaller array, you will be fine which you can observe by setting #State private var dimensionSelection = 0. There are a few ways to solve this.
Always start with the smallest array (Not great)
Instead of using an array of String, use an array of objects implementing Identifiable. this is the solution proposed by fuzz above. This got past the out of bound array exception. In my case though, I needed to specify the id parameter in the ForEach parameters.
Extend String to implement Identifiable as long as your strings are all different (which works in my trivial example). This is the solution proposed by gujci and his proposed solution looks much more elegant than mine, so I encourage you to take a look. Note that this to work in my own example. I suspect it might be due to how we built the arrays differently.
HOWEVER, once you get past these issues, it will still not work, You will hit an issue that appears be some kind of bug where the Picker keep adding new elements. My impression is that to get around this, one would have to destroy the Picker every time, but since I'm still learning Swift and SwiftUI, I haven't gotten round doing this.

So you'll want to make sure according to Apple's documentation that the array elements are Identifiable as you've mentioned.
Then you'll want to use ForEach like this:
struct Dimension: Identifiable {
let id: Int
let name: String
}
var temperatureUnits = [
Dimension(id: 0, name: "Celsius"),
Dimension(id: 1, name: "Fahrenheit"),
Dimension(id: 2, name: "Kelvin")
]
ForEach(temperatureUnits) { dimension in
Text(dimension.name)
}

Related

How to perform `ForEach` on a computed property in SwiftUI

Intro
Imagine we have an infinite range like the range of all possible integers and we can NOT just store them in memory. So we need to calculate them chunk by chunk like:
func numbers(around number: Int, distance: Int) -> [Int] {
((number - distance)...(number + distance))
.map { $0 } // Using `map` ONLY for making the question clear that we do NOT want to use a range
}
so now we can build our view like:
struct ContentView: View {
#State var lastVisibleNumber: Int = 0
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(numbers(around: lastVisibleNumber, distance: 10), id:\.self) { number in
Text("\(number)")
.padding()
.foregroundColor(.white)
.background(Circle())
// .onAppear { lastVisibleNumber = number }
}
}
}
}
}
Describing the issue and what tried
In order to load more numbers when needed, I have tried to update the lastVisibleNumber on the appearance of last visible Number by adding the following modifier on the Text:
.onAppear { lastVisibleNumber = number }
Although there is a safe range that is visible and should prevent the infinite loop (theoretically), adding this modifier will freeze the view for calculating all numbers!
So how can we achieve a scroll view with some kind of infinite data source?
Considerations
The range of Int is just a sample and the question can be about an infinite range of anything. (In a form of an Array!)
we don't want unexpected stops (like showing a dummy spinner at the leading or trailing of the list) in the middle of the scrolling.
For this, you should try using table view and data source. Let's assume you have an array of integers. You may create a buffer with an arbitrary number of instances. Let say 100. In that case, with a similar logic, your distance would become a 50. When you scroll, and close enough to the limits, you create another array and reload the table view data source so that you can pretend like you have an infinite array. Be aware that reusable cell and table view implementation is very consistent and optimized.
Don’t refresh view when lastVisibleNumber changes - it just gets into endless loop. You can create Observableobject, store it and update it only on-demand (I provided a button but you can obviously load it onAppear eith some criteria - e.g. last of the array was shown):
class NumberViewModel: ObservableObject {
var lastVisibleNumber = 0
#Published var lastRefreshedNumber = 0
func numbers(distance: Int) -> [Int] {
((lastRefreshedNumber - distance)...(lastRefreshedNumber + distance))
.map { $0 } // Using `map` ONLY for making the question clear that we do NOT want to use a range
}
}
struct ContentView: View {
#StateObject var viewModel = NumberViewModel()
var body: some View {
VStack {
Button("Refresh view", action: {
viewModel.lastRefreshedNumber = viewModel.lastVisibleNumber
})
ScrollView(.horizontal) {
LazyHStack {
ForEach(viewModel.numbers(distance: 10), id:\.self) { number in
Text("\(number)")
.padding()
.foregroundColor(.white)
.background(Circle())
.onAppear { viewModel.lastVisibleNumber = number }
}
}
}
}
}
}

ForEach not properly updating with dynamic content SwiftUI

Sorry to make this post so long, but in hindsight I should have shown you the simpler instance of the issue so you could better understand what the problem is. I am assuming the same issue with ForEach is at the root cause of both of these bugs, but I could be wrong. The second instance is still included to give you context, but the first intance should be all you need to fully understand the issue.
First Instance:
Here is a video of the issue: https://imgur.com/a/EIg9TSm. As you can see, there are 4 Time Codes, 2 of which are favorite and 2 are not favorites (shown by the yellow star). Additionally, there is text at the top that represents the array of Time Codes being displayed just as a list of favorite (F) or not favorite (N). I click on the last Time Code (Changing to favorite) and press the toggle to unfavorite it. When I hit save, the array of Time Codes is updated, yet as you see, this is not represented in the List. However, you see that the Text of the reduced array immediately updates to FNFF, showing that it is properly updated as a favorite by the ObservedObject.
When I click back on the navigation and back to the page, the UI is properly updated and there are 3 yellow stars. This makes me assume that the problem is with ForEach, as the Text() shows the array is updated but the ForEach does not. Presumably, clicking out of the page reloads the ForEach, which is why it updates after exiting the page. EditCodeView() handles the saving of the TimeCodeVieModel in CoreData, and I am 99% certain that it works properly through my own testing and the fact that the ObservedObject updates as expected. I am pretty sure I am using the dynamic version of ForEach (since TimeCodeViewModel is Identifiable), so I don't know how to make the behavior update immediately after saving. Any help would be appreciated.
Here is the code for the view:
struct ListTimeCodeView: View {
#ObservedObject var timeCodeListVM: TimeCodeListViewModel
#State var presentEditTimeCode: Bool = false
#State var timeCodeEdit: TimeCodeViewModel?
init() {
self.timeCodeListVM = TimeCodeListViewModel()
}
var body: some View {
VStack {
HStack {
Text("TimeCodes Reduced by Favorite:")
Text("\(self.timeCodeListVM.timeCodes.reduce(into: "") {$0 += $1.isFavorite ? "F" : "N"})")
}
List {
ForEach(self.timeCodeListVM.timeCodes) { timeCode in
TimeCodeDetailsCell(fullName: timeCode.fullName, abbreviation: timeCode.abbreviation, color: timeCode.color, isFavorite: timeCode.isFavorite, presentEditTimeCode: $presentEditTimeCode)
.contentShape(Rectangle())
.onTapGesture {
timeCodeEdit = timeCode
}
.sheet(item: $timeCodeEdit, onDismiss: didDismiss) { detail in
EditCodeView(timeCodeEdit: detail)
}
}
}
}
}
}
Here is the code for the View Models (shouldn't be relevant to the problem, but included for understanding):
class TimeCodeListViewModel: ObservableObject {
#Published var timeCodes = [TimeCodeViewModel]()
init() {
fetchAllTimeCodes()
}
func fetchAllTimeCodes() {
self.timeCodes = CoreDataManager.shared.getAllTimeCodes().map(TimeCodeViewModel.init)
}
}
class TimeCodeViewModel: Identifiable {
var id: String = ""
var fullName = ""
var abbreviation = ""
var color = ""
var isFavorite = false
var tags = ""
init(timeCode: TimeCode) {
self.id = timeCode.id!.uuidString
self.fullName = timeCode.fullName!
self.abbreviation = timeCode.abbreviation!
self.color = timeCode.color!
self.isFavorite = timeCode.isFavorite
self.tags = timeCode.tags!
}
}
Second Instance:
EDIT: I realize it may be difficult to understand what the code is doing, so I have included a gif demoing the problem (unfortunately I am not high enough reputation for it to be shown automatically). As you can see, I select the cells I want to change, then press the button to assign that TimeCode to it. The array of TimeCodeCellViewModels changes in the background, but you don't actually see that change until I press the home button and then reopen the app, which triggers a refresh of ForEach. Gif of issue. There is also this video if the GIF is too fast: https://imgur.com/a/Y5xtLJ3
I am trying to display a grid view using a VStack of HStacks, and am running into an issue where the ForEach I am using to display the content is not refreshing when the array being passed in changes. I know the array itself is changing because if I reduce it to a string and display the contents with Text(), it properly updates as soon as a change is made. But, the ForEach loop only updates if I close and reopen the app, forcing the ForEach to reload. I know that there is a special version of ForEach that is specifically designed for dynamic content, but I am pretty sure I am using this version since I pass in '''id: .self'''. Here is the main code snippet:
var hoursTimeCode: [[TimeCodeCellViewModel]] = []
// initialize hoursTimeCode
VStack(spacing: 3) {
ForEach(self.hoursTimeCode, id: \.self) {row in
HStack(spacing: 3){
HourTimeCodeCell(date: row[0].date) // cell view for hour
.frame(minWidth: 50)
ForEach(row.indices, id: \.self) {cell in
// TimeCodeBlockCell displays minutes normally. If it is selected, and a button is pressed, it is assigned a TimeCode which it will then display
TimeCodeBlockCell(timeCodeCellVM: row[cell], selectedArray: $selectedTimeCodeCells)
.frame(maxWidth: .infinity)
.aspectRatio(1.0, contentMode: .fill)
}
}
}
}
I'm pretty sure it doesn't change anything, but I did have to define a custom hash function for the TimeCodeCellViewModel, which might change the behavior of the ForEach (the attributes being changed are included in the hash function). However, I have noticed the same ForEach behavior in another part of my project that uses a different view model, so I highly doubt this is the issue.
class TimeCodeCellViewModel:Identifiable, Hashable {
static func == (lhs: TimeCodeCellViewModel, rhs: TimeCodeCellViewModel) -> Bool {
if lhs.id == rhs.id {
return true
}
else {
return false
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(isSet)
hasher.combine(timeCode)
hasher.combine(date)
}
var id: String = ""
var date = Date()
var isSet = false
var timeCode: TimeCode
var frame: CGRect = .zero
init(timeCodeCell: TimeCodeCell) {
self.id = timeCodeCell.id!.uuidString
self.date = timeCodeCell.date!
self.isSet = timeCodeCell.isSet
self.timeCode = timeCodeCell.toTimeCode!
}
}
Here is a snippet of what you need to make the code work.
See the comments for some basics of why
struct EditCodeView:View{
#EnvironmentObject var timeCodeListVM: TimeCodeListViewModel
//This will observe changes to the view model
#ObservedObject var timeCodeViewModel: TimeCodeViewModel
var body: some View{
EditTimeCodeView(timeCode: timeCodeViewModel.timeCode)
.onDisappear(perform: {
//*********TO SEE CHANGES WHEN YOU EDIT
//uncomment this line***********
//_ = timeCodeListVM.update(timeCodeVM: timeCodeViewModel)
})
}
}
struct EditTimeCodeView: View{
//This will observe changes to the core data entity
#ObservedObject var timeCode: TimeCode
var body: some View{
Form{
TextField("name", text: $timeCode.fullName.bound)
TextField("appreviation", text: $timeCode.abbreviation.bound)
Toggle("favorite", isOn: $timeCode.isFavorite)
}
}
}
class TimeCodeListViewModel: ObservableObject {
//Replacing this whole thing with a #FetchRequest would be way more efficient than these extra view models
//IF you dont want to use #FetchRequest the only other way to observe the persistent store for changes is with NSFetchedResultsController
//https://stackoverflow.com/questions/67526427/swift-fetchrequest-custom-sorting-function/67527134#67527134
//This array will not see changes to the variables of the ObservableObjects
#Published var timeCodeVMs = [TimeCodeViewModel]()
private var persistenceManager = TimeCodePersistenceManager()
init() {
fetchAllTimeCodes()
}
func fetchAllTimeCodes() {
//This method does not observe for new and or deleted timecodes. It is a one time thing
self.timeCodeVMs = persistenceManager.retrieveObjects(sortDescriptors: nil, predicate: nil).map({
//Pass the whole object there isnt a point to just passing the variables
//But the way you had it broke the connection
TimeCodeViewModel(timeCode: $0)
})
}
func addNew() -> TimeCodeViewModel{
let item = TimeCodeViewModel(timeCode: persistenceManager.addSample())
timeCodeVMs.append(item)
//will refresh view because there is a change in count
return item
}
///Call this to save changes
func update(timeCodeVM: TimeCodeViewModel) -> Bool{
let result = persistenceManager.updateObject(object: timeCodeVM.timeCode)
//You have to call this to see changes at the list level
objectWillChange.send()
return result
}
}
//DO you have special code that you aren't including? If not what is the point of this view model?
class TimeCodeViewModel: Identifiable, ObservableObject {
//Simplify this
//This is a CoreData object therefore an ObservableObject it needs an #ObservedObject in a View so changes can be seem
#Published var timeCode: TimeCode
init(timeCode: TimeCode) {
self.timeCode = timeCode
}
}
Your first ForEach probably cannot check if the identity of Array<TimeCodeCellViewModel> has changed.
Perhaps you want to use a separate struct which holds internally an array of TimeCodeCellViewModel and conforms to Identifiable, effectively implementing such protocol.
stuct TCCViewModels: Identifiable {
let models: Array<TimeCodeCellViewModel>
var id: Int {
models.hashValue
}
}
You might as well make this generic too, so it can be reused for different view models in your app:
struct ViewModelsContainer<V: Identifiable> where V.ID: Hashable {
let viewModels: Array<V>
let id: Int
init(viewModels: Array<V>) {
self.viewModels = viewModels
var hasher = Hasher()
hasher.combine(viewModels.count)
viewModels.forEach { hasher.combine($0.id) }
self.id = hasher.finalize
}
}

SwiftUI Country Picker - Show Country Name but store Country ID

UPDATE: I updated the code to my previous semi-working solution because there were multiple answers, but none answered the question the way I need it to work.
Also, note that I need United States at the top of the picker, even if it appears again in the alphabetical country listing.
I am trying to create a picker that displays a country name, and depending on what country is selected, stores the corresponding country id. This way the user sees the name of the country but I can pass only the country id into my database.
The code I have so far shows the list of country names, and stores that country name in the selectedCountry variable. It also updates the text element in the HStack properly.
The only thing that is not working is storing the corresponding countryId.
I am using SwiftUI with the latest Swift 5 and XCode 13.1.
Here's what I've got so far:
import SwiftUI
struct Country: View {
#State private var selectedCountry = ""
#State private var selectedCountryId = ""
let countryId = Locale.isoRegionCodes
let countryArray = Locale.isoRegionCodes.compactMap { Locale.current.localizedString(forRegionCode: $0) }
var body: some view {
HStack {
Text("Country:")
.font(.system(size: 17))
Spacer()
Text("")
if selectedCountry != "" {
Text("\(selectedCountry)")
.font(.system(size: 17))
.foregroundColor(Color("WhiteText"))
} else {
Text("Select Country")
.font(.system(size: 17))
.foregroundColor(Color("GrayText"))
}
} // End HStack
.onTapGesture {
self.showsCountryPicker.toggle()
}
Picker("Country", selection: $selectedCountry) {
ForEach(countryArray, id: \.self) {
Text($0)
}
}
.pickerStyle(WheelPickerStyle())
.padding()
.labelsHidden()
}
}
}
I'm sure it's completely the wrong way to do this, so don't worry so much about correcting my code. I'd really just love to know how to do this, because I'll also need to implement the same thing when it comes to selecting a US State (i.e. show the full name of the State but store the abbreviation).
Also, there is much more to the body view, but I've stripped down the code here just to show this specific issue.
Thanks in advance!
The Picker documentation says to use the tag modifier on each Text to control what the Picker stores in its selection.
There's no reason to store an array of country names if you just want to store the selected country code. And you should use SwiftUI's Environment to get the current Locale, so that your view will be redrawn if the user changes her locale.
import SwiftUI
import PlaygroundSupport
struct CountryPicker: View {
#Binding var countryId: String
#Environment(\.locale) var locale
var body: some View {
Picker("", selection: $countryId) {
ForEach(Locale.isoRegionCodes, id: \.self) { iso in
Text(locale.localizedString(forRegionCode: iso)!)
.tag(iso)
}
}
}
}
struct Test: View {
#State var countryId: String = ""
var body: some View {
VStack {
CountryPicker(countryId: $countryId)
Text("You picked \(countryId).")
}
.padding()
}
}
PlaygroundPage.current.setLiveView(Test())
I appreciate all the assistance, but I got it all working the way I needed it to. For this I am storing only the Country ID, which is all I need, but translating that ID into the country name for the text element in the HStack.
Here's the answer:
import SwiftUI
// Struct to store the country name and ID
fileprivate struct Country {
var id: String
var name: String
}
// Function to put United States at the top of the list
fileprivate func getLocales() -> [Country] {
let locales = Locale.isoRegionCodes
.filter { $0 != "United States"}
.compactMap { Country(id: $0, name: Locale.current.localizedString(forRegionCode: $0) ?? $0)}
return [Country(id: "US", name: Locale.current.localizedString(forRegionCode: "US") ?? "United States")] + locales
}
struct Test: view {
// selectedCountry stores the countryID (i.e. US)
#State private var selectedCountry: String = ""
// Main UI
var body: some View {
HStack {
Text("Country:")
.font(.system(size: 17))
Spacer()
Text("")
if selectedCountry != "" {
Text(Locale.current.localizedString(forRegionCode: selectedCountry) ?? selectedCountry)
.font(.system(size: 17))
.foregroundColor(Color("WhiteText"))
} else {
Text("Select Country")
.font(.system(size: 17))
.foregroundColor(Color("GrayText"))
}
} // End HStack
Picker("Country", selection: $selectedCountry) {
ForEach(getLocales(), id: \.id) { country in
Text(country.name).tag(country.id)
}
}
}
}
I would just do something simple like this:
struct Country: Identifiable, Hashable {
let id: String
let name: String
}
struct CountryView: View {
let countries = Locale.isoRegionCodes.compactMap{
Country(id: $0, name: Locale.current.localizedString(forRegionCode: $0)!) }
#State var selectedCountry: Country?
var body: some View {
Picker("Country", selection: $selectedCountry) {
ForEach(countries) {
Text($0.name).tag(Optional($0))
}
}.pickerStyle(.wheel)
.onChange(of: selectedCountry) { selected in
if let cntry = selected {
print("--> store country id: \(cntry.id)")
}
}
}
}
If you want to sort the countries, use this:
ForEach(countries.sorted(by: { $0.name < $1.name })) { ... }
This my friend is where dictionaries come in handy. A dictionary has two parts Key and Value or in swift terms ["Key":"Value"] There are three things to note about a dictionary.
#1, all key-value-pairs MUST be the same type, for example, [32:"String", 33: "String"] Which is important to remember.
#2, it does NOT guarantee order.
#3, It can only contain unique keys.
How does this apply to your problem? Well, it has to do with the type of data that you have. Currently you have 2 separate arrays, one with the Country, and one with the Codes. You can combine them into the dictionary and ensure that they are always together, referencing the value from the key, or searching for the value, to get a key or multiple keys. That previous sentence is important to pay attention to, though not for your case, you're guaranteed to only get one value for one key, but you can get multiple keys from one value. In your case you have a unique country, and unique ID.
var countries = ["USA": 9999,
"UK": 9998,
"Canada": 9997] // Etc..
Getting a value from a dictionary is even easier, but works similar to an array. You sub-script it. For example:
var canadaID= countries["Canada"]
Now it gets trickier getting a key from a value because you have to iterate over the whole dictionary to grab it. It's also possible that there are duplicate values, meaning you could technically get back an array of "Keys". In this example, I grabbed only the first found value. Again, remember that the order is not guaranteed and if you have multiple of the same value you may get the incorrect key.
var countryID = 9998
if let key = countries.first(where: { $0.value == someValue })?.key {
print(key)
}
From here it becomes trivial to store it.
func storeCountryIDFromKey(country: String) {
let countryId = countries[country]
// Store your ID.
}
What if my order is important??!??
This could be important for your case as you might want to display the countries in alphabetical order. To do that simply map the keys to an array and sort, as is tradition.
let keys: [String] = countries.map{String($0.key) }
Solution
This is a working solution. I'll leave it up to you to sort the arrays and link the data where you need it to go. You could use onChange(...) or even a Button(..) to handle the update, however your ID is the selectedCountry in this example.
struct FirstView: View {
#State var countries = ["US": 1,
"UK": 2,
"Canada": 4]
#State var selectedCountry = 1
var body: some View {
VStack {
Picker("Country", selection: $selectedCountry) {
let countriesArray = countries.keys.map({$0})
ForEach(countriesArray, id: \.self ) { country in
Text(country).tag(countries[country]!)
}
}.pickerStyle(.wheel)
Text("\(selectedCountry)")
}
}
}
Additional Reading
There is a concept in programming called Big-O notation typically expressed as O(n) or pronounced O-of-N. Which is the way that we describe time and space complexities. It's a great skill to learn if you want to become a great developer as it has to do with Data Structures and Algorithms. To make more sense of this, as it applies to your question, having two separate arrays to loop over vs one dictionary effectively takes 2x as long to accomplish with the double arrays. Furthermore it doubles the space complexity. Combining both into one Dictionary reduces your performance overhead by 1/2 which is a huge performance gain. With a small data-set such as countries, which there are a finite amount, it doesn't really matter; However, if you start working with massive datasets then suddenly 1/2 faster is a substantial performance boost.
Without digging too much into it, and to simply get your wheels spinning, every time you make a variable, or the compiler does that for you, that increases space complexity. Every time you run a line of code, or loop over a line of code, that increases the time complexity. Always, and I mean always, try your best to reduce that overhead. It'll force you to think outside the box and in turn, you'll learn better practices.
For creating and sorting the array of countries, this is my suggestion
// create a Country struct
struct Country:Equatable{
let code:String
let name:String
}
/** creating the array by first getting the
the codes and then sorting it, bubbling US to the top
[Sorted by][1]
**/
let countries = Locale.isoRegionCodes.compactMap
{ Country(code:$0,name:Locale.current.localizedString(forRegionCode: $0) ?? "")
}.sorted{
switch ($0,$1){
case ($0,$1) where $0.code == "US":
return true
case ($0,$1) where $1.code == "US":
return false
default:
return $0.name<$1.name
}
}
You can now store your selection and get the code and name or whatever you wish to by changing the struct as per your needs

SwiftUI strange behavior when moving items between sections in a List

so I've been trying to make a component using swiftUI that allows you to move items in a List between sections.
I prepared an example with two sections: "First List" and "Second List". Whenever you tap on an item it swaps sections. Here's a screenshot:
When I tap on "First List: 1", it correctly moves to the second section:
However, its name should now be changed to "Second List: 1" because of the way I named the elements in the sections (see code below). So that's strange. But it gets stranger:
When I now tap on "First List: 1" in the second section this happens:
It doesn't properly swap back. It just gets duplicated, but this time the name of the duplicate is actually correct.
Considering the code below I don't understand how this is possible. It seems that swiftUI somehow reuses the item, even though it re-renders the view? It also seems to reuse the .onTapGesture closure, because the method that's supposed to put the item back into the first section is never actually called.
Any idea what's going on here? Below is a fully working example of the problem:
import SwiftUI
import Combine
struct TestView: View {
#ObservedObject var viewModel: ViewModel
class ViewModel: ObservableObject {
let objectWillChange = PassthroughSubject<ViewModel,Never>()
public enum List {
case first
case second
}
public var first: [Int] = []
public var second: [Int] = []
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
self.objectWillChange.send(self)
}
init(first: [Int]) {
self.first = first
}
}
var body: some View {
NavigationView {
List {
Section(header: Text("First List")) {
ForEach(self.viewModel.first, id: \.self) { id in
Text("First List: \(id)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
}
Section(header: Text("First List")) {
ForEach(self.viewModel.second, id: \.self) { id in
Text("Second List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .second)
}
}
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Testing"))
}.environment(\.editMode, .constant(EditMode.active))
}
}
struct TestView_Preview: PreviewProvider {
static var previews: some View {
TestView(viewModel: TestView.ViewModel(first: [1, 2, 3, 4, 5]))
}
}
The only way I've solved this is to prevent diffing of the list by adding a random id to the list. This removes animations though, so looking for a better solution
List {
...
}
.id(UUID())
Removing the sections also fixes this, but isn't a valid solution either
I've found myself in a similar situation and have a found a more elegant workaround to this problem. I believe the issue lies with iOS13. In iOS14 the problem no longer exists. Below details a simple solution that works on both iOS13 and iOS14.
Try this:
extension Int {
var id:UUID {
return UUID()
}
}
and then in your ForEach reference \.id or \.self.id and not \.self i.e like so in both your Sections:
ForEach(self.viewModel.first, id: \.id) { id in
Text("First List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
This will make things work. However, when fiddling around I did find these issues:
Animations were almost none existent in iOS14. This can be fixed though.
In iOS13 the .listStyle(GroupedListStyle()) animation looks odd. Remove this and animations look a lot better.
I haven't tested this solution on large lists. So be warned around possible performance issues. For smallish lists it works.
Once again, this is a workaround but I think Apple is still working out the kinks in SwiftUI.
Update
PS if you use any onDelete or onMove modifiers in iOS14 this adds animations to the list which causes odd behaviour. I've found that using \.self works for iOS14 and \.self.id for iOS13. The code isn't pretty because you'll most likely have #available(iOS 14.0, *) checks in your code. But it works.
I don't know why, but it seems like your swap method does something weird on the first object you add, because if the second one works, maybe you've lost some instance.
By the way, do you need to removeAll every time you add a new object in each list?
public function interchange (identifier elementWithIdentifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll (where: {$ 0 == identifier})
self.second.append (identifier)
case .second:
print ("Called")
self.second.removeAll (where: {$ 0 == identifier})
self.first.append (identifier)
}
self.objectWillChange.send (self)
}
maybe your problem is in this function, everything looks great.
The fix is simple - use default ObservableObject publishers (which are correctly observed by ObservedObject wrapper) instead of Combine here, which is not valid for this case.
class ViewModel: ObservableObject {
public enum List {
case first
case second
}
#Published public var first: [Int] = [] // << here !!
#Published public var second: [Int] = [] // << here !!
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
}
init(first: [Int]) {
self.first = first
}
}
Tested with Xcode 13.3 / iOS 15.4
*and even with animation wrapping swap into withAnimation {}

Using ForEach with a an array of Bindings (SwiftUI)

My objective is to dynamically generate a form from JSON. I've got everything put together except for generating the FormField views (TextField based) with bindings to a dynamically generated list of view models.
If I swap out the FormField views for just normal Text views it works fine (see screenshot):
ForEach(viewModel.viewModels) { vm in
Text(vm.placeholder)
}
for
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: $vm)
}
I've tried to make the viewModels property of ConfigurableFormViewModel an #State var, but it loses its codability. JSON > Binding<[FormFieldViewModel] naturally doesn't really work.
Here's the gist of my code:
The first thing that you can try is this:
ForEach(0 ..< numberOfItems) { index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
The problem with the previous approach is that if numberOfItems is some how dynamic and could change because of an action of a Button for example, it is not going to work and it is going to throw the following error: ForEach<Range<Int>, Int, HStack<TextField<Text>>> count (3) != its initial count (0). 'ForEach(_:content:)' should only be used for *constant* data. Instead conform data to 'Identifiable' or use 'ForEach(_:id:content:)' and provide an explicit 'id'!
If you have that use case, you can do something like this, it will work even if the items are increasing or decreasing during the lifecycle of the SwiftView:
ForEach(items.indices, id:\.self ){ index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
Trying a different approach. The FormField maintains it's own internal state and publishes (via completion) when its text is committed:
struct FormField : View {
#State private var output: String = ""
let viewModel: FormFieldViewModel
var didUpdateText: (String) -> ()
var body: some View {
VStack {
TextField($output, placeholder: Text(viewModel.placeholder), onCommit: {
self.didUpdateText(self.output)
})
Line(color: Color.lightGray)
}.padding()
}
}
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: vm) { (output) in
vm.output = output
}
}
Swift 5.5
From Swift 5.5 version, you can use binding array directly by passing in the bindable like this.
ForEach($viewModel.viewModels, id: \.self) { $vm in
FormField(viewModel: $vm)
}
A solution could be the following:
ForEach(viewModel.viewModels.indices, id: \.self) { idx in
FormField(viewModel: self.$viewModel.viewModels[idx])
}
Took some time to figure out a solution to this puzzle. IMHO, it's a major omission, especially with SwiftUI Apps proposing documents that has models in struct and using Binding to detect changes.
It's not cute, and it takes a lot of CPU time, so I would not use this for large arrays, but this actually has the intended result, and, unless someone points out an error, it follows the intent of the ForEach limitation, which is to only reuse if the Identifiable element is identical.
ForEach(viewModel.viewModels) { vm in
ViewBuilder.buildBlock(viewModel.viewModels.firstIndex(of: zone) == nil
? ViewBuilder.buildEither(first: Spacer())
: ViewBuilder.buildEither(second: FormField(viewModel: $viewModel.viewModels[viewModel.viewModels.firstIndex(of: vm)!])))
}
For reference, the ViewBuilder.buildBlock idiom can be done in the root of the body element, but if you prefer, you can put this with an if.