Metronome SwiftUI, Change timer interval whenever the #State var is changed - timer

I'm building the a metronome with swiftUI
I have a #State var that connect to a slider.
#State private var period: Double
Slider(value: period, in: 0.25...5, step: 0.25)
Right now I can only reset the time interval if I restart the metronome.
Button(action: { startStop = !startStop
self.timer = Timer.publish(every: self.period, on: .main, in:.default).autoconnect()
} ){
Text("START/STOP")
}
However, I want to trigger the "self.timer = Timer.publish(...." whenever the slider is moved. Is there a way to trigger an ObservableObject #Published var whenever a #State var is changed? So that I can use ".onReceive()" function to trigger "self.timer = Timer.publish(...."
Thank you

The easiest way to perform code when a #State value changes is to add an onChange modifier to a child view of the view where you defined your #State, e.g.:
struct ContentView: View {
#State private var period: Double = 0.5
var body: some View {
Slider(value: period, in: 0.25...5, step: 0.25)
.onChange(of: period) { newValue in
// perform your code here
}
}
}
See also Apple's documentation and an example on Hacking With Swift.

Related

SwiftUI: How to pass arguments to class from view?

I have a class that conforms to ObservableObject, which takes some arguments. When I then use #ObservedObject var someName = className() in my view to access all the functions and data in the class, I get an error saying:
Missing arguments for parameters 'pickedVideo', 'pickedImage', 'retrievedImages', 'retrievedVideos' in call
I am aware that I somehow have to pass the arguments from my view to the class.
But how do I pass variables from my view to my class?
Class:
class DBFunctions : ObservableObject {
init(pickedVideo: [String], pickedImage: [UIImage], retrievedImages: [UIImage], retrievedVideos: [AVPlayer]) {
self.pickedVideo = pickedVideo
self.pickedImage = pickedImage
self.retrievedImages = retrievedImages
self.retrievedVideos = retrievedVideos
}
var pickedVideo : [String]
var pickedImage : [UIImage]
var retrievedImages : [UIImage]
var retrievedVideos : [AVPlayer]
func somefunc() {
}
}
View:
struct ContentView: View {
#ObservedObject var helpFuncs = DBFunctions()
#State var showPicker: Bool = false
#State var pickedImage: [UIImage] = []
#State var retrievedImages = [UIImage]()
#State var player : AVPlayer?
#State var width : CGFloat = 0
#State var retrievedVideos = [AVPlayer]()
#State var pickedVideo: [String] = []
#State var isPaused = false
var body: some View {
VStack{
Button(action: {
helpFuncs.uploadImage()
}) {
Text("Upload Image")
}
}
}
What you are doing is something like an anti-pattern... The ObservableObject should be getting their "truth" from the Model, and not from the view. So, normally speaking, or those values initialize themselves, or you get their from the Model.
To be clear, you should create an object first, from model data, then pass its instance to the View. That's what MVVM is all about.
Fell free to increase your question if something that I answered was not clear enough.
Edit1: If you need to communicate something (like a choice) from the View to the ObservableObject, you should do this via "Intents"... that is normally a func in the ObservableObject class. Those Intents (in my way to architect this is failable), if the Intent goes well, ObservableObject change the Model and Publish their new results to the View, which redraws itself. If the "Intent" not go through, or I launch an Alert (if it's critical) or I simply ignore.
As mentioned, you are not using ObservableObject as it is meant to be used.
Look at this link, it gives you some good examples of how to use ObservableObject and manage data in your app
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
Try something like this example code:
class DBFunctions : ObservableObject {
#Published var pickedVideo : [String] // <-- here #Published
#Published var pickedImage : [UIImage]
#Published var retrievedImages : [UIImage]
#Published var retrievedVideos : [AVPlayer]
init() {
self.pickedVideo = []
self.pickedImage = []
self.retrievedImages = []
self.retrievedVideos = []
}
func uploadImage() { }
}
struct ContentView: View {
#StateObject var helpFuncs = DBFunctions() // <-- here
#State var showPicker: Bool = false
#State var player : AVPlayer?
#State var width : CGFloat = 0
#State var isPaused = false
// do not use these, use the ones in your helpFuncs model directly
// like: helpFuncs.pickedImage = ...
// #State var pickedImage: [UIImage] = []
// #State var retrievedImages = [UIImage]()
// #State var retrievedVideos = [AVPlayer]()
// #State var pickedVideo: [String] = []
var body: some View {
VStack{
Button(action: {
helpFuncs.uploadImage()
}) {
Text("Upload Image")
}
}
.onAppear {
// here do some initialisation of your helpFuncs, for example
}
}
}
With your original code (and question), You can of course pass some initial values, like this:
#ObservedObject var helpFuncs = DBFunctions(pickedVideo: [], pickedImage: [], retrievedImages: [], retrievedVideos: [])

SwiftUI - List of Timers, getting "index out of range" when deleting timer from list

I'm really struggling with removing a timer from an array of timers, that are being displayed in a list.
I have a Model that stores the timer information. In this simplified example, it is just storing the timeRemaining.
I have a ViewModel that has 2 arrays, one storing Timer Models, and another storing Timers. Also has delete timer, create timer, and append timer array functions in the View Model.
Content View is displaying the timers in a List, using ForEach. It also has a Navigation Bar with an "Add" button at the top for going to another view for setting up the timers.
The User Added Timers View allows users to select the numbers of seconds, then add the timer. The Add button appends the seconds to the Timer Model Array, and adds a Timer to the Timer Array in the view model.
Everything works fine, except when I delete a timer from the list, I get an index out of range in the createTimer() function of the view model. I know what the problem is... I am hard coding the timer index when adding a timer to the Timer array in the User Added Timers view. I just have no idea how to fix it. I have tried for days to find a way of accessing the index of the ForEach and using that as the index for each timer in the Timers Array... but I don't know if it is even possible to do that?
Anyway, I am completely stuck... any insight would be greatly appreciated.
Thanks in advance!
This code below is very simplified compared to the actual code, but it pinpoints the problem.
Model:
import Foundation
import SwiftUI
struct TimerModel: Identifiable {
let id = UUID()
var timeRemainingSeconds: Int
}
ViewModel:
import Foundation
import SwiftUI
class TimerViewModel: ObservableObject {
#Published var timerModelArray = [TimerModel]()
#Published var timersArray = [Timer]()
func deleteItem(indexSet: IndexSet) {
timersArray.remove(atOffsets: indexSet)
timerModelArray.remove(atOffsets: indexSet)
}
func createTimer(timerNumber: Int) -> Timer {
return Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
self.timerModelArray[timerNumber].timeRemainingSeconds -= 1
}
}
func appendTimerArray(timeRemainingSeconds: Int) {
timerModelArray.append(TimerModel(timeRemainingSeconds: timeRemainingSeconds))
}
}
ContentView:
import SwiftUI
struct ContentView: View {
#StateObject var viewModel = TimerViewModel()
#State var arrayCount = 0
var body: some View {
NavigationView {
VStack {
List {
ForEach(viewModel.timerModelArray.indices, id: \.self) { time in
Section {
Text("\(viewModel.timerModelArray[time].timeRemainingSeconds)")
}
}
.onDelete { indexSet in
viewModel.timersArray[viewModel.timersArray.count - 1].invalidate()
viewModel.deleteItem(indexSet: indexSet)
}
}
}
.background(.ultraThinMaterial)
.listStyle(InsetGroupedListStyle())
.navigationBarItems(trailing: NavigationLink("Add", destination: UserAddedTimer()))
}
.environmentObject(viewModel)
}
}
UserAddedTimerView:
import SwiftUI
struct UserAddedTimer: View {
#EnvironmentObject var viewModel: TimerViewModel
#State var timerSeconds: Int = 0
var body: some View {
VStack {
Spacer()
Stepper(value: $timerSeconds, in: 0...59, step: 1) {
Text("Seconds: \(timerSeconds)")
.font(.title2)
}
.padding()
.padding(.horizontal, 15)
Divider()
Button("Add Timer") {
viewModel.appendTimerArray(timeRemainingSeconds: timerSeconds)
viewModel.timersArray.append(viewModel.createTimer(timerNumber: viewModel.timerModelArray.count - 1))
}
.font(.title2)
.frame(width: 200, height: 60, alignment: .center)
.background(.secondary)
.cornerRadius(10)
.padding(.top, 40)
.padding(.bottom, 15)
}
.background(.ultraThinMaterial)
}
}

Give #State private var a variable Array as initial value

I have this #State private var which is an Array of strings and changes values when buttons are tapped. But as an initial value, I want it to take an array of strings that is variable.
#ObservedObject var News = getNews()
#State private var newssitearray : Array<String>
init() {
_newssitearray = State(initialValue: News.data.map {$0.newsSite})
}
What I did above gives the error:
self was used before all stored properties are initialized.
Do everything in init, like
#ObservedObject var News: YourTypeOfNewsContainer // << declare only
#State private var newssitearray : Array<String>
init() {
let news = getNews() // << assuming this is synchronous
// use local var for both properties initialization
self.News = news
self._newssitearray = State(initialValue: news.data.map {$0.newsSite})
}
Important: if you getNews is asynchronous, then it cannot be used either in property initialisation or init itself - think to do this in .onAppear

SwiftUI: String property of an object is not displaying in Text, and how would you edit a string in an object?

I am very new to programming in Swift. So I'm trying to come up with a time management program. I have posted some code that have been derived from my project that is a work in progress, and I'm trying to troubleshoot some issues that I'm having that come from my lack of knowledge regarding Swift and SwiftUI. I would like to ask two questions here, but if you only have the answer to just one of them, I would greatly appreciate it.
So in my ContentView, I'm trying to display the taskName of the object with ID 0 using a Text in a VStack -- however, it is not displaying, and I'm not sure of the reason why. I can display the taskLength by putting it inside the String method, but taskName is not coming up when I attempt to display it.
Also I'm attempting to change the taskName of Task(id: 0) that is being passed into display2 directly from the display2, but I'm not sure if the taskName of Task(id: 0) is actually being changed, or it's only the taskName of #State var task:Task in display2 that is being changed -- based on my intuitions, I would think the latter case is actually happening. In that case, is there a way to directly edit the taskName of Task(id: 0) from display2?
import SwiftUI
import Foundation
import Combine
struct Task: Hashable, Codable, Identifiable {
var id: Int
var taskName: String = ""
var taskLength: Int = 0
var isBreak : Bool = false
}
class ModelData : ObservableObject{
#Published var tasks: [Task] = [
Task(id: 0,taskName: "Test", taskLength: 34, isBreak: false),
Task(id: 1,taskName: "Math", taskLength: 30, isBreak: false),
Task(id: 2,taskName: "Science", taskLength: 40, isBreak: false)
]
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
var body: some View {
VStack{
Text(Task(id: 0).taskName)
display2(task:Task(id: 0))
}
}
}
struct display2: View{
#State var task:Task
var body: some View {
TextField("New task",text: $task.taskName)
}
}
The problem is here:
Text(Task(id: 0).taskName)
Here, you're creating a new Task, with an id of 0. This is not the first task inside your ModelData's tasks array.
Instead, reference the first task via subscript []:
Text(modelData.tasks[ /* index of task */ ].taskName)
Normally you can just put 0 here to get the first Task. However, you said you actually want the Task with an id of 0. You can do this via firstIndex(where:).
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
var body: some View {
VStack{
Text(
modelData.tasks[getTaskIndexFrom(id: 0)] /// access
.taskName
)
Display2( /// see https://stackoverflow.com/a/67064699/14351818
task: $modelData.tasks[getTaskIndexFrom(id: 0)]
)
}
}
func getTaskIndexFrom(id: Int) -> Int {
/// get first index of a task with the specified `id`
if let firstIndex = modelData.tasks.firstIndex(where: { $0.id == 0 }) {
return firstIndex
} else {
return 0
}
}
}
struct Display2: View{
#Binding var task: Task /// need a Binding here
var body: some View {
TextField("New task", text: $task.taskName)
}
}
Ok, your second question:
In that case, is there a way to directly edit the taskName of Task(id: 0) from display2?
Yep! Just use #Binding on Display2's task. This way, all changes will be synced back to your modelData.
In ContentView you used just Task(), but you have to use modelData for #Published var tasks in ModelData.
Task(id: 0).taskName -> modelData.tasks[1].taskName
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
var body: some View {
VStack{
Text(modelData.tasks[1].taskName)
.foregroundColor(.blue)
display2(task:Task(id: 0))
}
}
}
Also, as long as you use #EnvironmentObject, you need to add .environmentObject to the main as well.
(The code below is an example of the SwiftUI life cycle)
import SwiftUI
#main
struct ReplyToStackoverflowApp: App {
var modelData: ModelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}

How can I update a TextField value and have its associated index in an array update as well?

I am having trouble updating values in an array that are displayed via a for each loop. These values are displayed in a text field.
The code in question
struct EditItemView: View {
let entity: RecipeEntity
#StateObject var viewModel = ViewModelEdit()
#State var imageToUpload: Data
#StateObject var vm = CoreDataRelationshipViewModel()
#Environment(\.presentationMode) var presentationMode
#State var stepInfo: String = ""
#State var textFieldCount: Int = 1
#State var stepNumber: [Int]
#State var recipeName: String = ""
#State var recipeArray: [RecipeStepModel]
var body: some View {
//some code between here and the problem code
List {
ForEach(recipeArray, id: \.id) { index in
HStack {
CustomTextField(item: index)
}
}.onDelete { (indexSet) in
recipeArray.remove(atOffsets: indexSet)
}
CustomTextField I am using that allows me to pass my Identifiable model into a foreach. This can be seen referenced in the for each above as CustomTextField(item: index)
struct CustomTextField : View {
#State var item : RecipeStepModel
var body : some View {
Text(String(item.stepNumber) + ".")
TextField("", text: $item.stepName)
}
}
Lastly, here is the model for the array referenced in the last #State variable declared #State var recipeArray: [RecipeStepModel]:
struct RecipeStepModel: Identifiable {
var id = UUID()
var stepName: String
var stepNumber: Int
}
The Question
How can I make a change to a textfield in a foreach loop and have its value in #State var recipeArray: [RecipeStepModel] be updated accordingly? For example, I edit the first TextField in the for each loop - how can I update index 0 in the array with its new value?
Additional Information
I posted a similar question the other day. I was able to get it working with .indices in the for each loop. The version of the question asked here is an attempt at restructuring my code using MVVM. Previous question and answer can be found here - I hope this helps!
You need to follow a architecture like MVVM. Make a model where you have your struct RecipeStepModel and then make a seperate ViewModel to add and delete recipes in your array. You should study the Combine framework in SwiftUI, it is used to get input from textfield and then store it in an array.
Check this tutorial from Peter Freise https://github.com/peterfriese/MakeItSo/tree/firebase-combine/final/MakeItSo/MakeItSo for reference.
I ended up solving the issue by converting #State var item: RecipeStepModel in my view model to #Binding var item: RecipeStepModel
struct CustomTextField : View {
#Binding var item : RecipeStepModel
var body : some View {
Text(String(item.stepNumber) + ".")
TextField("", text: $item.stepName)
}
Once this change was made, I had to alter the code in my ForEach to pass a binding to the CustomTextField view model. Additionally, I had to change ForEach(recipeArray, id: \.id) to ForEach(recipeArray.indices, id: \.self) :
List {
ForEach(recipeArray.indices, id: \.self) { index in
HStack {
CustomTextField(item: $recipeArray[index]) //<--change made here
}
}.onDelete { (indexSet) in
recipeArray.remove(atOffsets: indexSet)
}
I am now able to both successfully delete items from the list, and update items in the array simply by changing the value in the appropriate TextField

Resources