SwiftUI - How do you set a maximum number on a TextField? - swift

For my app I need the user to type in a number between 1 and 1000, and I would like to validate that it falls between those numbers. At first I thought of using a Stepper but then the user would have to press + a thousand times. Is there a better way to do this? I made it so that the user is prompted with a numberPad but that allows the user to input any number.
Here's my code:
#State var validityPeriod = ""
var body: some View {
NavigationView {
Form {
TextField("Validity Period (In Days)", text: $validityPeriod)
}
.navigationBarTitle("Validation")
}
}
I could check the number that they typed in before submitting the form, but I would prefer something that verifies the field while they're typing.
Note: I'm using Xcode 11.5 with Swift 5

You can check that the current value is valid via a computed Bool property. Then anywhere you want, you can place that check into your code.
SwiftUI recreates the ContentView body every time your #State variable changes (which is best to be made private, FYI). It has some intelligence in re-rendering only what's needed, but essentially your entire View is reevaluated, and your calculated bool periodIsValid retested with every keystroke.
Quite often the submit button is disabled while the form is invalid. Something like:
Button(...) {
...
}
.disabled(!periodIsValid)
To do it live, you can do a number of creative things with that live validation checking. Something really simple would be to make the text entered turn red if it is invalid:
struct ContentView: View {
#State private var validityPeriod = ""
var periodIsValid: Bool {
guard let days = Int(validityPeriod) else { return false}
guard days >= 1 && days <= 1000 else { return false }
return true
}
var body: some View {
NavigationView {
Form {
TextField("Validity Period (In Days)", text: $validityPeriod)
.foregroundColor(periodIsValid ? .primary : .red)
}
.navigationBarTitle("Validation")
}
}
}
Edit:
You can also limit the entry values...
Thinking more about this, you can actually perform instant-validation of the text field, which will prevent the entry of non-valid text.
To do this you use the didSet property wrapper. Only hitch is that it doesn't work with #State wrapper, so you'll have to use an #ObservedObject instead. Pretty easy...
One note is that you may want to help people understand what's going on. If your interface doesn't already make it clear what values are okay, you may want to provide a hint when you squash an invalid entry.
class ValidityPeriod: ObservableObject {
#Published var validityPeriod = "" {
didSet {
// Allow blanks in the field, or they can't delete
if validityPeriod == "" { return }
// Check the entire value is good, otherwise ignore the change
let days = Int(validityPeriod) ?? 0
if days < 1 || days > 1000 { validityPeriod = oldValue }
// Pop alert or trigger any other UI instruction here
}
}
}
struct ContentView: View {
#ObservedObject var vp = ValidityPeriod()
var body: some View {
NavigationView {
Form {
TextField("Validity Period (In Days)", text: $vp.validityPeriod)
}
.navigationBarTitle("Validation")
}
}
}

Related

SwiftUI: How to display an array of items and have them modifiable programmatically

I have a list of SwiftUI Text instances, and I want to be able to alter their characteristics (in this example, font weight) after initial creation of the body. It looks like SwiftUI doesn't want you to perform modifying functions on View elements after placing them in the body (i.e. Text().fontWeight() doesn't change the weight of the text in the instance Text() gave you, but returns a different instance which has the font weight you want, which means that these modifying methods only make sense when applied within your view's body, I guess).
My attempts to work with this paradigm have failed, however. Consider the following code to display a line of digits and a "Click Me" button which is intended to bold a different digit with every click. It initializes an array of boldable Text instances. In order to make ForEach work, because Text doesn't conform to Hashable or Identifiable, I needed to make a struct BoldableText which has an identifier, a Text instance, and the intended weight of that Text instance.
A function, highlight(), is intended to advance the bolded text to the next digit, wrapping around to 0 when it reaches the end.
struct BoldableText: Identifiable {
var id: Int // So that it works with ForEach in the View
var theWeight: Font.Weight // The weight of this Text item
var theText:Text // The actual Text item
}
let DIGITS = 6 // How many digits to display
var idx_of_bolded:Int = 0 // Which of them is currently bold?
// A view to show an array of Text items, each boldable after user interaction
struct ArrayView: View {
#State var numbers:[BoldableText] = []
// Set up the numbers array with digit texts with regular font weight
init() {
numbers = []
for i in 0..<DIGITS {
numbers.append(BoldableText(id: i, theWeight:Font.Weight.regular, theText: Text(String(i))))
}
}
// Advance the highlighted number and highlight that text
func highlight() {
numbers[idx_of_bolded].theWeight = Font.Weight.regular
idx_of_bolded = (idx_of_bolded + 1) % DIGITS
numbers[idx_of_bolded].theWeight = Font.Weight.heavy
}
var body: some View {
HStack {
// Display the numbers in a line
ForEach(numbers) { number in
Text(String(number.id)).fontWeight(number.theWeight)
}
Button(action: highlight) {
Text("Click to advance the number")
}
}
}
}
The problem is, this won't even build. The two lines in highlight() which try to change the .theWeight property get flagged for trying to mutate numbers[].
If I fix this by marking func highlight() as mutating, Xcode flags the call to it from the Button() action.
Alternately, I can fix it by moving var numbers ... outside of the struct, but then I can't use #State on it.
That gets it to build, but clicking the button doesn't do anything, I'm guessing because nothing is bound to the font weights, Swift thinks they're just static values.
Meanwhile, if I try binding by using fontWeight($number.theWeight), Xcode complains that it "can't find $number in scope". Same goes for ForEach($numbers)
If I bring #State var numbers... back into the struct and keep ForEach($numbers) the error at fontWeight($number.theWeight) changes to a very intriguing "Cannot convert value of type 'Binding<Font.Weight>' to expected argument type 'Font.Weight?'" (which suggests to me that fontWeight doesn't even accept bound parameters, which seems odd).
Is it possible to even do this with SwiftUI, or must I go back to using a ViewController?
Many changes - conceptual mistakes, see inline. (I recommend to take several SwiftUI full-scale tutorials)
Tested with Xcode 13.4 / iOS 15.5
struct BoldableText: Identifiable {
var id: Int
var theWeight: Font.Weight
var theText: String // << Text is a view, so store just string here !!
}
let DIGITS = 6
struct ArrayView: View {
#State private var numbers:[BoldableText] // << initialized once so will do this in init
#State private var idx_of_bolded:Int = 0 // << affects view, so needs to be state
init() {
// << prepare initial model
let numbers = (0..<DIGITS).map {
BoldableText(id: $0, theWeight:Font.Weight.regular, theText: String($0))
}
_numbers = State(initialValue: numbers) // << state initialization !!
}
func highlight() {
numbers[idx_of_bolded].theWeight = Font.Weight.regular
idx_of_bolded = (idx_of_bolded + 1) % DIGITS // << now updates view !!
numbers[idx_of_bolded].theWeight = Font.Weight.heavy
}
var body: some View {
HStack {
// Display the numbers in a line
ForEach(numbers) { number in
Text(String(number.id)).fontWeight(number.theWeight)
}
Button(action: highlight) {
Text("Click to advance the 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 / Combine subscribe to updates in multiple nested collections

I have a SummaryView with a Report as #State.
A Report is a protocol which includes some changes a user might want to make:
protocol Report {
var changeGroups: [ChangeGroup] { get set }
}
There are several kinds of reports; individual reports are implemented as a struct:
struct RealEstateReport: Report {
static let name = "Real Estate Report"
var changeGroups = [ChangeGroup]()
}
A ChangeGroup is a struct with (among other stuff) a human-readable summary and a handful of proposed changes:
struct ChangeGroup: Identifiable {
var summary: String
var proposedChanges = [ProposedChange]()
}
A ProposedChange is a class that represents one discrete change the app proposes to the user, which is enabled by default:
class ProposedChange: ObservableObject, Identifiable {
#Published var enabled = true
let summary: String
(In a detail view, enabled is bound to a Toggle so a user can flip each proposed change on and off.)
So a Report has many ChangeGroups which themselves have many ProposedChanges.
I'm trying to include some high level details on the SummaryView:
struct SummaryView: View {
#State var report: Report
var body: some View {
Text("Summary")
.foregroundColor(…) // ???
}
I want foregroundColor to be red, yellow, or green:
Red if enabled is false for all ProposedChanges in this Report
Green if enabled is true for all ProposedChanges in this Report
Yellow if enabled is mixed for different ProposedChanges in this Report
I've read a bit about Combine, and I think I need to create a new Combine subscription for each ChangeGroup, and map that to a new Combine subscription for each ProposedChange's enabled property, flatten the values when one changes, and check if they're all the same.
I'm a little lost on the exact syntax I'd use. And also it seems like structs don't publish changes in the same way (I guess since the structs are value vs. reference types).
How can I set the foregroundColor of the Text view based on the above logic?
Your issue is immediately solved if ProposedChange is a struct and not a class. Unless its instances have their own life cycle, then they are just holders of value, so should be semantically a struct.
The reason your issue is solved is because mutating a property of a struct mutates the struct, so SwiftUI knows to recompute the view, whereas with a class you need to subscribe to changes.
Assuming ProposedChange is a struct:
struct ProposedChange {
var enabled = true
var summary: String
}
the following should work:
struct SummaryView: View {
#State var report: Report
var body: some View {
Text("Summary")
.foregroundColor(summaryColor)
}
var summaryColor: Color {
let count = report.changeGroups.flatMap { $0.proposedChanges }
.map { ($0.enabled ? 1 : 0, 1) }
.reduce((0, 0), { ($0.0 + $1.0, $0.1 + $1.1) })
if count.0 == count.1 { return Color.green }
else if count.0 == 0 { return Color.red }
else { return Color.yellow }
}
}
I ended up mapping all the enabled flags to their publisher, combining them all using the CombineLatest operator, and then recalculating when the value changes:
class ViewModel: ObservableObject {
enum BoolState {
case allTrue, allFalse, mixed
}
#Published var boolState: BoolState?
private var report: Report
init(report: Report) {
self.report = report
report
.changeGroups // [ChangeGroup]
.map { $0.proposedChanges } // [[ProposedChange]]
.flatMap { $0 } // [ProposedChange]
.map { $0.$enabled } // [AnyPublisher<Bool, Never>]
.combineLatest() // AnyPublisher<[Bool], Never>
.map { Set($0) } // AnyPublisher<Set<Bool>, Never>
.map { boolSet -> BoolState in
switch boolSet {
case [false]:
return .allFalse
case [true]:
return .allTrue
default:
return .mixed
}
} // AnyPublisher<BoolState, Never>
.assign(to: &$boolState)
}
}
Note: .combineLatest() is not part of Combine but it's just an extension I wrote that iterates each pair of publishers in the array and calls them iteratively, like first.combineLatest(second).combineLatest(third) etc. If you need something more robust than this, it looks like the CombineExt project has a CombineLatestMany extension with several options.
At this point my view just does a #ObservedObject var viewModel: ViewModel and then uses viewModel.boolState in the body. Whenever any of the enabled flags change for any reason, the view updates successfully!

How to reuse a view in SwiftUI and fill it different data?

noob here.
So Im coding a basic To-Do App and I need the user to be able to edit a task he previously entered. To do this, I wish to reuse my AddTaskView in the following way: once I tap "Edit" on the Task, the AddTaskView form gets presented and populated with all the Tasks data and pressing "Save" actually updates the data, instead of adding a new Task.
Currently, my AddTaskView stores all the forms variables in #State variables which have a value by default, such as:
#State private var taskTitle : String = ""
How can I send the data to AddTaskView that is already available since the task already exists to be able to populate the appropiate fields? My understanding is I cant just set #State variables values from outside the view.
Current AddTaskView.swift
struct AddTaskView: View {
//CoreDatas managedObjectEnvironment
#StateObject var vm : viewModel
#Environment(\.managedObjectContext) var moc
#Environment(\.presentationMode) var presentationMode
var categories : FetchedResults<Category>
#State private var taskTitle : String = ""
#State private var taskPriority : String = "Low"
#State private var chosenCategory : String? = ""
#State private var taskDate : Date = Date()
let priorities = ["Low", "High"]
var body: some View {
NavigationView {
Form {
Section(header: Text("Task info")) {
TextField("Task title", text: $taskTitle)
}
Section {
DatePicker("Date", selection: $taskDate)
}
Section {
Picker(
selection: $taskPriority,
label: Text("Priority")
) {
ForEach(self.priorities, id: \.self) {
Text($0).tag($0)
}
}
}
Section {
Picker(
selection: $chosenCategory,
label: Text("Category")
) {
ForEach(self.categories, id: \.categoryName) { cat in
Text(cat.categoryName ?? "Unknown").tag(cat.id)
}
}
}
Section {
Button("Save") {
self.vm.addTask(moc: self.moc, title: self.taskTitle, date: self.taskDate, priority: self.taskPriority, chosenCategory: chosenCategory!)
self.presentationMode.wrappedValue.dismiss()
}
}
}
.navigationTitle("Add Task")
}
}
I want to call and present this AddTaskView from another view where I have all the task info. As far as I understand, I cant just present the view such as:
AddTaskView(taskTitle: aTitle, taskPriority: aPriority, etc)
Any pointers are welcomed. thanks!
You did not ask a small question. I am going to recommend you go through Apple's Introducing SwiftUI. It will give you the foundation to understand what is going on, however the TLDR is every time you change an #State var in a struct, you are getting an entirely new struct. The values in a struct are immutable, regardless of what var might imply, and you literally make a new struct and put it in the old one's place.
And to answer your question as to presenting the view struct, yes, that would work. So would AddTaskView() the way you have your struct set up, though it wouldn't show much. Go hit the tutorial. It is well worth the time.

Change the options in a Picker dynamically using distinct arrays

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