View not refreshed when .onAppear runs SwiftUI - swift

I have some code inside .onAppear and it runs, the problem is that I have to go to another view on the menu and then come back for the UI view to refresh. The code is kind of lengthy, but the main components are below, where mealData comes from CoreData and has some objects:
Go to the updated comment at the bottom for a simpler code example
VStack(alignment: .leading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 2) {
ForEach(mealData, id: \.self) { meal in
VStack(alignment: .leading) { ... }
.onAppear {
// If not first button and count < total amt of objects
if((self.settingsData.count != 0) && (self.settingsData.count < self.mealData.count)){
let updateSettings = Settings(context: self.managedObjectContext)
// If will_eat time isn't nill and it's time is overdue and meal status isn't done
if ((meal.will_eat != nil) && (IsItOverDue(date: meal.will_eat!) == true) && (meal.status! != "done")){
self.mealData[self.settingsData.count].status = "overdue"
print(self.mealData[self.settingsData.count])
if(self.settingsData.count != self.mealData.count-1) {
// "Breakfast": "done" = active - Add active to next meal
self.mealData[self.settingsData.count+1].status = "active"
}
updateSettings.count += 1
if self.managedObjectContext.hasChanges {
// Save the context whenever is appropriate
do {
try self.managedObjectContext.save()
} catch let error as NSError {
print("Error loading: \(error.localizedDescription), \(error.userInfo)")
}
}
}
}
}
}
}
}
}
Most likely since the UI is not refreshing automatically I'm doing something wrong, but what?
UPDATE:
I made a little example replicating what's going on, if you run it, and click on set future date, and wit 5 seconds, you'll see that the box hasn't changed color, after that, click on Go to view 2 and go back to view 1 and you'll see how the box color changes... that's what's happening above too:
import SwiftUI
struct ContentView: View {
#State var past = Date()
#State var futuredate = Date()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView())
{ Text("Go to view 2") }
Button("set future date") {
self.futuredate = self.past.addingTimeInterval(5)
}
VStack {
if (past < futuredate) {
Button(action: {
}) {
Text("")
}
.padding()
.background(Color.blue)
} else {
Button(action: {
}) {
Text("")
}
.padding()
.background(Color.black)
}
}
}
.onAppear {
self.past = Date()
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) var presentationMode: Binding
var body: some View {
Text("View 2")
}
}

You need to understand that all you put in body{...} is just an instruction how to display this view. In runtime system creates this View using body closure, gets it in memory like picture and pin this picture to struct with all the stored properties you define in it. Body is not a stored property. Its only instruction how to create that picture.
In your code, there goes init first. You didn't write it but it runs and sets all the structs property to default values = Date(). After that runs .onAppear closure which changes past value. And only then system runs body closure to make an image to display with new past value. And that's all. It is not recreating itself every second. You need to trigger it to check if the condition past < futuredate changed.
When you go to another view and back you do exactly that - force system to recreate view and that's why it checks condition again.
If you want View to change automatically after some fixed time spend, use
DispatchQueue.main.asyncAfter(deadline: .now() + Double(2)) {
//change #State variable to force View to update
}
for more complex cases you can use Timer like this:
class MyTimer: ObservableObject {
#Published var currentTimePublisher: Timer.TimerPublisher
var cancellable: Cancellable?
let interval: Double
var isOn: Bool{
get{if self.cancellable == nil{
return false
}else{
return true
}
}
set{if newValue == false{
self.stop()
}else{
self.start()
}
}
}
init(interval: Double, autoStart: Bool = true) {
self.interval = interval
let publisher = Timer.TimerPublisher(interval: interval, runLoop: .main, mode: .default)
self.currentTimePublisher = publisher
if autoStart{
self.start()
}
}
func start(){
if self.cancellable == nil{
self.currentTimePublisher = Timer.TimerPublisher(interval: self.interval, runLoop: .main, mode: .default)
self.cancellable = self.currentTimePublisher.connect()
}else{
print("timer is already started")
}
}
func stop(){
if self.cancellable != nil{
self.cancellable!.cancel()
self.cancellable = nil
}else{
print("timer is not started (tried to stop)")
}
}
deinit {
if self.cancellable != nil{
self.cancellable!.cancel()
self.cancellable = nil
}
}
}
struct TimerView: View {
#State var counter: Double
let timerStep: Double
#ObservedObject var timer: TypeTimer
init(timerStep: Double = 0.1, counter: Double = 10.0){
self.timerStep = timerStep
self._counter = State<Double>(initialValue: counter)
self.timer = MyTimer(interval: timerStep, autoStart: false)
}
var body: some View {
Text("\(counter)")
.onReceive(timer.currentTimePublisher) {newTime in
print(newTime.description)
if self.counter > self.timerStep {
self.counter -= self.timerStep
}else{
self.timer.stop()
}
}
.onTapGesture {
if self.timer.isOn {
self.timer.stop()
}else{
self.timer.start()
}
}
}
}
See the idea? You start timer and it will force View to update by sending notification with the Publisher. View gets that notifications with .onReceive and changes #State variable counter and that cause View to update itself. You need to do something like that.

Related

How to assign value to #State in View from ViewModel?

I have a movie listing view with basic listing functionality, Once pagination reaches to the last page I want to show an alert for that I am using reachedLastPage property.
The viewModel.state is an enum, the case movies has associated value in which there is moreRemaining property which tells if there are more pages or not.
Once the moreRemaining property becomes false I want to make reachedLastPage to true so that I can show an alert.
How can I achieve this in best way?
import SwiftUI
import SwiftUIRefresh
struct MovieListingView<T>: View where T: BaseMoviesListViewModel {
#ObservedObject var viewModel: T
#State var title: String
#State var reachedLastPage: Bool = false
var body: some View {
NavigationView {
ZStack {
switch viewModel.state {
case .loading:
LoadingView(title: "Loading Movies...")
.onAppear {
fetchMovies()
}
case .error(let error):
ErrorView(message: error.localizedDescription, buttonTitle: "Retry") {
fetchMovies()
}
case .noData:
Text("No data")
.multilineTextAlignment(.center)
.font(.system(size: 20))
case .movies(let data):
List {
ForEach(data.movies) { movie in
NavigationLink(destination: LazyView(MovieDetailView(viewModel: MovieDetailViewModel(id: movie.id)))) {
MovieViewRow(movie: movie)
.onAppear {
if movie == data.movies.last && data.moreRemaining {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
fetchMovies()
}
}
}
}
if movie == data.movies.last && data.moreRemaining {
HStack {
Spacer()
ActivityIndicator(isAnimating: .constant(data.moreRemaining))
Spacer()
}
}
}
}.pullToRefresh(isShowing: .constant(data.isRefreshing)) {
print("Refresheeeee")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
refreshMovies()
}
}
}
}
.navigationViewStyle(.stack)
.navigationBarTitle("\(title)", displayMode: .inline)
.alert(isPresented: $reachedLastPage) {
Alert(title: Text("You have reached to the end of the list."))
}
}
}
private func fetchMovies() {
viewModel.trigger(.fetchMovies(false))
}
private func refreshMovies() {
viewModel.trigger(.fetchMovies(true))
}
}
you could try this approach, using .onReceive(...). Add this to your
ZStack or NavigationView:
.onReceive(Just(viewModel.moreRemaining)) { val in
reachedLastPage = !val
}
Also add: import Combine
(Ignoring "the best way" part, 'cause it's opinion-based,) one way to achieve that is to make your view model an observable object (which likely already is), adding the publisher of reachedLastPage there, and observe it directly from the view. Something like this:
final class ContentViewModel: ObservableObject {
#Published var reachedLastPage = false
init() {
// Just an example of changing the value.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { self.reachedLastPage = true }
}
}
struct ContentView: View {
var body: some View {
Text("Hello World")
.alert(isPresented: $viewModel.reachedLastPage) {
Alert(title: Text("Alert is triggered"))
}
}
#ObservedObject private var viewModel: ContentViewModel
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
}
Once reachedLastPage takes the true value, the alert will be presented.

What is causing a Swift UserDefault to not be set until the 2nd try?

I am trying to let the user control settings for camera hardware so there is a Settings view and a different view where they can choose presets to apply by the click of a button. When navigating back to the Settings view (via a TabView), I expect the values to be shown according to whichever preset button was clicked. However, I am finding that the some of the UserDefault-stored values are not being changed unless the button was clicked twice.
Differences between the correctly updated UserDefaults and the non-working ones:
-The non-working (until 2nd click) values are presented (and able to be changed) in the Settings view in a TextField whereas the correctly working values are presented (and able to be changed) by a Slider.
-The non-working values are Ints by default and updated to be Ints, while the others are Floats.
Things I have tried:
UserDefaults.standard.setValue(values[0], forKey: "exposureISO")
UserDefaults.standard.set(Int(values[0], forKey: "exposureISO")
UserDefaults.standard.synchronize()
Here is my code that hopefully demonstrates where the issue might be:\
import SwiftUI
import Combine
struct Settings: View {
let minISO = UserDefaults.standard.integer(forKey: "minISO")
let maxISO = UserDefaults.standard.integer(forKey: "maxISO")
let maxGain = UserDefaults.standard.integer(forKey: "maxGain")
#AppStorage("exposureISO") var exposureISO = DefaultSettings.exposureISO
#State private var enteredExposureISO: String = ""
#AppStorage("redGain") var redGain = DefaultSettings.redGain
var body: some View {
ScrollView {
VStack {
TextField("", text: self.$enteredExposureISO)
.onReceive(Just(enteredExposureISO)) { typedValue in
if let newValue = Int(typedValue) {
if (newValue >= Int(minISO)) && (newValue <= Int(maxISO)) {
exposureISO = newValue
}
}
}
.textFieldStyle(.roundedBorder)
.padding()
.onAppear(perform:{self.enteredExposureISO = "\(exposureISO)"})
Slider(
value: $redGain,
in: 1...Double(maxGain),
step: 0.1
) {
Text("red Gain")
} minimumValueLabel: {
Text("1")
} maximumValueLabel: {
Text("4")
}
.padding([.leading, .trailing, .bottom])
}
}
}
}
enum DefaultSettings {
static let exposureISO = 300
static let redGain = 2.0
}
struct JobView: View {
#ObservedObject var job: Job //does this need to be observed...?
var body: some View {
VStack {
Text("Settings").fontWeight(.medium)
Text(job.settings ?? "settings not found")
.multilineTextAlignment(.trailing)
Button(action: applySettings) {
Text("Apply these settings")
}
}
}
func applySettings() {
//parse job.settings and apply the values to the camera hardware
var value = ""
var values : [Float] = []
for char in job.settings! {
if Int(String(char)) != nil || char == "." {
value.append(char)
} else if char == "\n" {
values.append(Float(value)!)
value = ""
}
}
UserDefaults.standard.set(values[0], forKey: "exposureISO")
UserDefaults.standard.set(values[1], forKey: "redGain")
}
}
I find it most strange that the UserDefaults are being properly set if the button is tapped twice. I appreciate any help diagnosing the issue and how to solve it!
Edit
Like described in comments and as ChrisR suggested, I changed .onReceive to .onSubmit to hack/solve the problem, but I don't yet entirely understand why the Just(...) publisher was emitting when the button was tapped. Here's the fix:\
TextField("", text: self.$enteredExposureISO)
.onSubmit {
if let newValue = Int(enteredExposureISO) {
if (newValue >= Int(minISO)) && (newValue <= Int(maxISO)) {
print("new value \(newValue)")
exposureISO = newValue
}
}
}
.textFieldStyle(.roundedBorder)
.padding()
.onAppear(perform:{self.enteredExposureISO = "\(exposureISO)"})

Extracting Observed Object into subview behaves differently than just one view struct. Why?

This doesn't work the way I intended it…
When an error occurs in MyClass instance the user is advised to check the settings app and then come back to my app. The next time my app is opened it should just retry by initializing MyClass all over again. If the error persists it will just again display above everything else. (Actually I would like to just fatalError() my app but that isn't best practice, is it?) So I thought I just initialize a new instance of MyClass…
class MyClass: ObservableObject {
static var shared = MyClass()
#Published var errorMsg: String? = nil
func handleError() -> Void {
DispatchQueue.main.async {
self.errorMsg = "Sample Error Message"
}
}
init() {
self.errorMsg = nil
}
}
struct ContentView: View {
#ObservedObject var theObj = MyClass.shared
#Environment(\.scenePhase) private var scenePhase
var body: some View {
ZStack {
VStack {
Text("App when everything is fine")
.onTapGesture {
MyClass.shared.handleError()
}
}
VStack {
if theObj.errorMsg != nil {
VStack {
Spacer()
HStack {
Spacer()
Text(theObj.errorMsg!)
.font(.footnote)
.onTapGesture {
print("theObj.errorMsg! = \(theObj.errorMsg!)")
}
Spacer()
}
Spacer()
}
.background(Color.red)
}
}
}
.onChange(of: scenePhase) { phase in
switch phase {
case .active, .inactive:
if (MyClass.shared.errorMsg != nil) {
MyClass.shared = MyClass()
print("Error cancelled. Retry at next launch...")
}
default:
()
}
}
}
}
As I said: this doesn't work.
Very surprisingly the following variation works… I thought it couldn't work like this but it does. My big question is: WHY does it work like this? Shouldn't this be the same thing? What's the difference that I don't see?
struct ContentView: View {
#Environment(\.scenePhase) private var scenePhase
var body: some View {
ZStack {
VStack {
Text("App when everything is fine")
.onTapGesture {
MyClass.shared.handleError()
}
}
ErrorMsgView()
}
.onChange(of: scenePhase) { phase in
switch phase {
case .active, .inactive:
if (MyClass.shared.errorMsg != nil) {
MyClass.shared = MyClass()
print("Error cancelled. Retry at next launch...")
}
default:
()
}
}
}
}
struct ErrorMsgView: View {
#ObservedObject var theObj = MyClass.shared
var body: some View {
VStack {
if theObj.errorMsg != nil {
VStack {
Spacer()
HStack {
Spacer()
Text(theObj.errorMsg!)
.font(.footnote)
.onTapGesture {
print("theObj.errorMsg! = \(theObj.errorMsg!)")
}
Spacer()
}
Spacer()
}
.background(Color.red)
}
}
}
}
Also I honestly don't understand how do I conclusively kill the MyClass instance I don't need anymore? I do know how to terminate the background tasks that MyClass is running, but is it sufficient to just assign a new instance to the static var shared and the old one is purged?

Dismiss a View in SwiftUI when parent is re-rendered

Using iOS14.4, Swift5.3.2, XCode12.2,
I try to dismiss a SwiftUI's GridView (see below code).
The dismiss function is done by the \.presentationMode property of the #Environment as explained here.
Everything works until the moment where I introduced a #Binding property that mutates the parent-View at the very moment of the dismissal. (see dataStr = titles[idx] in code excerpt below).
I read that dismissal by \.presentationMode only works if the parent-View is not updated during the time the child-View is shown.
But I absolutely need to cause a mutation on the parent-View when the user taps on an element of the GridView at play here.
How can I re-write so that parent-View is updated AND dismissal of Child-View still work ?
struct GridView: View {
#Environment(\.presentationMode) private var presentationMode
#Binding var dataStr: String
#State private var titles = [String]()
let layout = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
ScrollView {
LazyVGrid(columns: layout, spacing: 10) {
ForEach(titles.indices, id: \.self) { idx in
VStack {
Text(titles[idx])
Image(titles[idx])
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: (UIScreen.main.bounds.width / 2) - 40)
}
.onTapGesture {
// WITHOUT THIS LINE OF CODE - EVERYTHING WORKS. WHY???????????????
dataStr = titles[idx]
self.presentationMode.wrappedValue.dismiss()
}
}
}
.padding()
}
}
}
As #jnpdx asked, here you can see the parent-View. Please find the GridView(dataStr: self.$dataStr) inside the .sheet() of the ToolBarItem()....
import SwiftUI
struct MainView: View {
#EnvironmentObject var mediaViewModel: MediaViewModel
#EnvironmentObject var commService: CommunicationService
#State private var dataStr = ""
#State private var connectionsLabel = ""
#State private var commumincationRole: THRole = .noMode
#State private var showingInfo = false
#State private var showingGrid = false
init() {
UINavigationBar.appearance().tintColor = UIColor(named: "title")
}
var body: some View {
NavigationView {
if mediaViewModel.mediaList.isEmpty {
LoadingAnimationView()
.navigationBarHidden(true)
.ignoresSafeArea()
} else {
if dataStr.isEmpty {
MainButtonView(dataStr: $dataStr,
commumincationRole: $commumincationRole,
connectionsLabel: $connectionsLabel
)
.navigationBarHidden(false)
.navigationTitle("Trihow Pocket")
.navigationBarColor(backgroundColor: UIColor(named: "btnInactive"), titleColor: UIColor(named: "title"))
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
showingInfo.toggle()
}) {
Image(systemName: "ellipsis")
}
.sheet(isPresented: $showingInfo) {
InfoView()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingGrid.toggle()
}) {
Image(systemName: "square.grid.3x3")
}
.sheet(isPresented: $showingGrid) {
// GRIDVIEW CALLING THE CHILD-VIEW IS HERE !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
GridView(dataStr: self.$dataStr)
}
}
}
} else {
let str = self.dataStr
#if os(iOS)
PageViewiOS(dataStr: self.$dataStr, commumincationRole: $commumincationRole)
.navigationBarHidden(true)
.onAppear() {
if commumincationRole == .moderatorMode {
commService.send(thCmd: THCmd(key: .tagID, sender: "", content: str))
}
}
.ignoresSafeArea()
#elseif os(macOS)
PageViewMacOS()
.ignoresSafeArea()
#endif
}
}
}
.onTHComm_PeerAction(service: commService) { (peers) in
let idsOrNames = peers.map { (peer) -> String in
if let id = peer.id {
return "\(id)"
} else if let name = peer.name {
return "\(name)"
} else {
return ""
}
}
connectionsLabel = "Connected devices: \n\(idsOrNames.lineFeedString)"
}
.onTHComm_ReceiveCmd(service: commService) { (thCmd) in
if (commumincationRole == .moderatorMode) || (commumincationRole == .discoveryMode) {
switch thCmd.key {
case .tagID:
dataStr = thCmd.content
case .closeID:
dataStr = ""
default:
break
}
}
}
.onTHComm_LastMessageLog(service: commService) { (log) in
print(log)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
.environmentObject(MediaViewModel())
.environmentObject(MultipeerConnectivityService())
}
}
With the help of #jnpdx, I found a workaround.
Wrap the binding-property (i.e. dataStr in my example) into a delayed block that executes after something like 50ms:
.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
dataStr = thumNames[idx]
}
self.presentationMode.wrappedValue.dismiss()
}
Of course, this workaround only works in my case, because I do no longer need to keep the Child-View open. There might be other situations where the Parent-View needs to be updated prior to closing the Child-View (i.e. here the update of dataStr can be done right at the closing moment of the Child-View).
I am still wondering how to deal with dismiss-problems for any case where the Child-View makes the Parent-View update prior to closing. These are situations where SwiftUI's dismiss function no longer work from then on. Any mutation of the Parent-View cause the Child-View to separate somehow and dismisss no longer works.
Any idea what to do in that case ?

SwiftUI: Published string changes inside view model are not updating view

I have a timer inside my view model class which every second changes two #Published strings inside the view model. View model class is an Observable Object which Observed by the view but the changes to these string objects are not updating my view.
I have a very similar structure in many other views(Published variables inside a ObservableObject which is observed by view) and it always worked. I can't seem to find what am I doing wrong?
ViewModel
final class QWMeasurementViewModel: ObservableObject {
#Published var measurementCountDownDetails: String = ""
#Published var measurementCountDown: String = ""
private var timer: Timer?
private var scheduleTime = 0
func setTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
DispatchQueue.main.async {
self.scheduleTime += 1
if self.scheduleTime == 1 {
self.measurementCountDownDetails = "Get ready"
self.measurementCountDown = "1"
}
else if self.scheduleTime == 2 {
self.measurementCountDownDetails = "Relax your arm"
self.measurementCountDown = "2"
}
else if self.scheduleTime == 3 {
self.measurementCountDownDetails = "Breathe"
self.measurementCountDown = "3"
}
else if self.scheduleTime == 4 {
self.measurementCountDownDetails = ""
self.measurementCountDown = ""
timer.invalidate()
}
}
}
}
}
View
struct QWMeasurementView: View {
#ObservedObject var viewModel: QWMeasurementViewModel
var body: some View {
VStack {
Text(viewModel.measurementCountDownDetails)
.font(.body)
Text(viewModel.measurementCountDown)
.font(.title)
}
.onAppear {
viewModel.setTimer()
}
}
}
Edit
After investigation, this seems to be related to how it is being presented. Cause if it's a single view this code works but I am actually presenting this as a sheet. (Still cannot understand why would it make a difference..)
struct QWBPDStartButtonView: View {
#ObservedObject private var viewModel: QWBPDStartButtonViewModel
#State private var startButtonPressed: Bool = false
init(viewModel: QWBPDStartButtonViewModel) {
self.viewModel = viewModel
}
var body: some View {
Button(action: {
self.startButtonPressed = true
}) {
ZStack {
Circle()
.foregroundColor(Color("midGreen"))
Text("Start")
.font(.title)
}
}
.buttonStyle(PlainButtonStyle())
.sheet(isPresented: $startButtonPressed) {
QWMeasurementView(viewModel: QWMeasurementViewModel())
}
}
}
You’re passing in a brand new viewmodel to the sheet’s view.
Try passing in the instance from line 3