SwiftUI: Text with timer style doesn't update after provided date changes - swift

I'm using the new timer style (Text.DateStyle) that was introduced at WWDC 2020 in SwiftUI to display a countdown timer. This works initially. However, as soon as the date that is provided by my view model changes (e.g. click reset), the view doesn't update. The timer does indeed get the new date but it isn't displayed. You can check this by rotating the device / simulator or press+hold the reset button to see the updated timer work just fine. So it must be something related to the view lifecycle.
The code:
import SwiftUI
import Combine
class ViewModel: ObservableObject {
#Published var date: Date = Date().addingTimeInterval(1000)
func reload() {
date = Date().addingTimeInterval(1000)
}
}
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Text(viewModel.date, style: .timer)
.padding()
Button("Reset") {
NotificationCenter.default.post(name: NSNotification.Name("TEST"), object: nil)
}
Spacer()
}
.frame(width: 500.0)
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("TEST")), perform: { _ in
viewModel.reload()
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Is this a know problem (and if so, is there a workaround?) or am I doing something wrong here?
Thanks for your help!

Seem like a bug with the button press effect. You can use Text and adding a tap gesture and make it a button then it will work fine.
struct ContentViewTimer: View {
#StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Text(viewModel.date, style: .timer)
.padding()
Text("Reset") //<-- Here
.onTapGesture {
NotificationCenter.default.post(name: NSNotification.Name("TEST"), object: nil)
}
Spacer()
}
.frame(width: 500.0)
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("TEST")), perform: { _ in
viewModel.reload()
})
}
}

Just add a .id(UUID()) modifier to your VStack and it will work as expected. Making view identifiable makes it reset its state when proxy value (date in your case) changes.

Related

Calling a Function stored in a class from a SubView is not updating the values on main ContentView

I’m developing an iOS app using SwiftUI and I’ve hit a road block and wondering if anyone can help me.
I have a main view(ContentView) with 4 subviews within it. The main view has info coming in from a class where all the data is stored and the data is updated from the subviews.
On one of the subviews, I have a button that calls a function updating the data in the class, though the main view is not picking it up. The function is being called and if I reset the app the data has been updated and shows on the main view.
It’s just not picking it up. I have tried having the data as an ObservableObject but still not picking up the changes when the function is run.
Please see the code below and please help, it’s driving me nuts.
This is the main ContentView
import SwiftUI
struct ContentView: View {
#ObservedObject var gameState = GameState()
var body: some View {
ZStack {
VStack {
Text(String(gameState.points))
// Other view content
TabView {
UpgradeView() // This is where the subview appears and looks to be fine
.tabItem {
Image(systemName: "rectangle.and.hand.point.up.left.fill")
Text("Upgrades")
}
// Other tab content
.tabItem {
Image(systemName: "cpu")
Text("Bots")
}
// Other tab content
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
}
}
} // ZStack
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This is the class (GameState)
import Foundation
class GameState: ObservableObject {
//This number is showing on the main ContentView but when the function is called from the subview.
It doesn't update on ContentView. It does print in console though when ran so it is working.
#Published var points = 5
#Published var pointsPerSecond = 10
init(){
}
func doublePoints() {
var runCount = 0
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { timerEnd in
self.points += self.pointsPerSecond
print(self.points)
runCount += 1
if runCount == 30 {
timerEnd.invalidate()
}
})
}
}
This is the Subview
import SwiftUI
struct UpgradesView: View {
#ObservedObject var gameState = GameState()
var body: some View {
HStack {
Button(action: {
gameState.doublePoints() // This is the function called in the from the gameState class, this works
}) {Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 40))
}
}
}
}
struct UpgradesView_Previews: PreviewProvider {
static var previews: some View {
UpgradesView()
}
}
Your subview is observing a different instance of your class. Note how, in both views, you're doing:
#ObservedObject var gameState = GameState()
This is creating a separate instance each time.
What you want is for your subview to use the same instance as your parent view.
One option is to inject the instance from the parent view into the environment of its view:
UpgradeView()
.environmentObject(gameState)
And then, in UpgradeView, change it from an observed object to
#EnvironmentObject var gameState: GameState
This will now get the instance out of the environment, which will be the same instance as the parent.

SwiftUI detect edit mode

I've returned to iOS development after a while and I'm rebuilding my Objective-C app from scratch in SwiftUI.
One of the things I want to do is use the default Edit Mode to allow entries in a List (backed by Core Data on CloudKit) to switch between a NavigationLink to a detail view and an edit view.
The main approach seems to be to handle it through a if statement that detects edit mode. The Apple documentation provides the following snippet for this approach on this developer page: https://developer.apple.com/documentation/swiftui/editmode
#Environment(\.editMode) private var editMode
#State private var name = "Maria Ruiz"
var body: some View {
Form {
if editMode?.wrappedValue.isEditing == true {
TextField("Name", text: $name)
} else {
Text(name)
}
}
.animation(nil, value: editMode?.wrappedValue)
.toolbar { // Assumes embedding this view in a NavigationView.
EditButton()
}
}
However, this does not work (I've embedded the snippet in a NavigationView as assumed).
Is this a bug in Xcode 13.4.1? iOS 15.5? Or am I doing something wrong?
Update1:
Based on Asperi's answer I came up with the following generic view to handle my situation:
import SwiftUI
struct EditableRow: View {
#if os(iOS)
#Environment(\.editMode) private var editMode
#endif
#State var rowView: AnyView
#State var detailView: AnyView
#State var editView: AnyView
var body: some View {
NavigationLink{
if(editMode?.wrappedValue.isEditing == true){
editView
}
else{
detailView
}
}label: {
rowView
}
}
}
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
VStack {
EditButton()
EditableRow(rowView: AnyView(Text("Row")), detailView: AnyView(Text("Detail")), editView: AnyView(Text("Edit")))
}
}
}
The preview works as expected, but this works partially in my real app. When I implement this the NavigationLink works when not in Edit Mode, but doesn't do anything when in Edit Mode. I also tried putting the whole NavigationLink in the if statement but that had the same result.
Any idea why this isn't working?
Update2:
Something happens when it's inside a List. When I change the preview to this is shows the behavior I'm getting:
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
List {
EditableRow(rowView: AnyView(GroupRow(title: "Title", subTitle: "Subtitle", type: GroupType.personal)), detailView: AnyView(EntryList()), editView: AnyView(Text("Edit")))
}
.navigationBarItems(trailing:
HStack{
#if os(iOS)
EditButton()
#endif
}
)
}
}
}
Update3:
Found this answer: SwiftUI - EditMode and PresentationMode Environment
This claims the default EditButton is broken, which seems to be true. Replacing the default button with a custom one works (be sure to add a withAnimation{} block to get all the behavior from the stock button.
But it still doesn't work for my NavigationLink...
Update4:
Ok, tried passing an "isEditing" Bool to the above View, not to depend on the Environment variable being available. This works as long as the View (a ForEach within a List in my case) isn't in "Editing Mode" whatever happens at that point breaks any NavigationLink it seems.
Update5:
Basically my conclusion is that the default Edit Mode is meant to edit the "List Object" as a whole enabling moving and deleting of rows. In this mode Apple feels that editing the rows themselves isn't something you'd want to do. I can see this perspective.
If, however, you still want to enable a NavigationLink from a row in Edit Mode, this answer should help:
How to make SwiftUI NavigationLink work in edit mode?
Asperi's answer does cover why the detection doesn't work. I did find that Edit Mode detection does work better when setting the edit mode manually and not using the default EditButton, see the answer above for details.
It is on same level so environment is not visible, because it is activated for sub-views.
A possible solution is to separate dependent part into standalone view, like
Form {
InternalView()
}
.toolbar {
EditButton()
}
Tested with Xcode 13.4 / iOS 15.5
Test module on GitHub
#Asperi's answer worked well for me. However I wanted to still be able to access the editMode in the same hierarchy. As a workaround I created the following:
Usage
struct ContentView: View {
#State
private var editMode: EditMode = .inactive
var body: some View {
NavigationView {
Form {
if editMode.isEditing == true {
Color.red
} else {
Color.blue
}
}
.editModeFix($editMode)
.toolbar {
EditButton()
}
}
}
}
Implementation
extension View {
func editModeFix(_ editMode: Binding<EditMode>) -> some View {
modifier(EditModeFixViewModifier(editMode: editMode))
}
}
private struct EditModeFixView: View {
#Environment(\.editMode)
private var editModeEnvironment
#Binding
var editMode: EditMode
var body: some View {
Color.clear
.onChange(of: editModeEnvironment?.wrappedValue) { editModeEnvironment in
if let editModeEnvironment = editModeEnvironment {
editMode = editModeEnvironment
}
}
.onChange(of: editMode) {
editModeEnvironment?.wrappedValue = $0
}
}
}
private struct EditModeFixViewModifier: ViewModifier {
#Binding
var editMode: EditMode
func body(content: Content) -> some View {
content
.overlay {
EditModeFixView(editMode: $editMode)
}
}
}
I've got it to work by using a .simultaneousGesture on the EditButton and playing with a #State wrapper.
struct EditingFix: View {
#Environment(\.editMode) var editMode
#State var showDeleteButton = false
var body: some View {
Text("hello")
.toolbar(content: {
if showDeleteButton {
ToolbarItem(placement: .navigationBarLeading, content: {
Label("Remove selected", systemImage: "trash")
.foregroundColor(.red)
})
}
ToolbarItem(placement: .navigationBarTrailing, content: {
EditButton()
.simultaneousGesture(TapGesture().onEnded({
showDeleteButton.toggle()
}))
})
})
.onChange(of: showDeleteButton, perform: { isEditing in
editMode?.wrappedValue = isEditing ? .active : .inactive
})
.animation(.default, value: editMode?.wrappedValue) // Restore the default smooth animation for list selection and others
}
I can definitly say that EditButton is not using the same EditMode environment as what we get when invoking #Environment(\.editMode) var editMode. So we have to do it all ourselves if we want to get the benefit of the EditButton. Mainly the localized Edit text that it displays in my case.
Alternatively
The above method led to some weird behavior where the EditButton editMode seemed to conflict in some situation with the #Environment(\.editMode) var editMode. I'd advise you use your own logic for editing using the reliable .environment(\.editMode, $editMode). This way you can do whatever you want with the binding that control editing.
struct EditingFix: View {
#State var editMode: EditMode = .inactive
#State var isEditing = false
var body: some View {
VStack {
if editMode.isEditing {
Text("Hello")
}
Text("World")
Button("Toggle hello", action: {
isEditing.toggle()
})
}
.environment(\.editMode, $editMode)
.onChange(of: isEditing, perform: { isEditing in
editMode = isEditing ? .active : .inactive
})
.animation(.default, value: editMode)
}
}

Use Swift ObservableObject to change view label when UserSettings change

I’ve created a small sample project in Swift Playgrounds to debug an issue I’ve encountered. This sample project contains the a primary ContentView with a single Text field and a button that opens Settings in a modal view.
When I open Settings and change the a setting via a picker, I would like to see the corresponding Text label change in my ContentView. In the current project, I’m using the #ObservableObject Type Alias to track the change, and I see that the setting changes correctly, but the view is not updated. If I restart the preview in Playgrounds, the view is updated with the changed setting. I would expect the Text label to change in real-time.
The code is as follows:
ContentView.swift
import SwiftUI
struct ContentView: View {
#ObservedObject var userSettings = UserSettings()
#State var isModal: Bool = false
var body: some View {
VStack {
Text("Setting: " + userSettings.pickerSetting)
.fontWeight(.semibold)
.font(.title)
Button(action: {
self.isModal = true
}) {
Image(systemName: "gear")
.font(.title)
}
.padding()
.foregroundColor(.white)
.background(Color.gray)
.cornerRadius(40)
.sheet(isPresented: $isModal, content: {
UserSettingsView()
})
.environmentObject(userSettings)
}
}
}
UserSettings.swift
import Foundation
class UserSettings: ObservableObject {
#Published var pickerSetting: String {
didSet {
UserDefaults.standard.set(pickerSetting, forKey: "pickerSetting")
}
}
public var pickerSettings = ["Setting 1", "Setting 2", "Setting 3"]
init() {
self.pickerSetting = UserDefaults.standard.object(forKey: "pickerSetting") as? String ?? "Setting 1"
}
}
UserSettingsView.swift
import SwiftUI
struct UserSettingsView: View {
#ObservedObject var userSettings = UserSettings()
var body: some View {
NavigationView {
Form {
Section(header: Text("")) {
Picker(selection: $userSettings.pickerSetting, label: Text("Picker Setting")) {
ForEach(userSettings.pickerSettings, id: \.self) { setting in
Text(setting)
}
}
}
}
.navigationBarTitle("Settings")
}
}
}
This happening because you have created two instances of UserSettings. One each in ContentView and UserSettingsView.
If you want to keep using .environmentObject(userSettings) the you need to use #EnvironmentObject var userSettings: UserSettings in UserSettingsView.
Otherwise you can drop the .environmentObject and use an #ObservedObject in UserSettingsView.

Is it possible to perform an action on NavigationLink tap?

I have a simple View showing a list of 3 items. When the user taps on an item, it navigates to the next view. This works fine. However, I would like to also perform an action (set a variable in a View Model) when a list item is tapped.
Is this possible? Here's the code:
import SwiftUI
struct SportSelectionView: View {
#EnvironmentObject var workoutSession: WorkoutManager
let sports = ["Swim", "Bike", "Run"]
var body: some View {
List(sports, id: \.self) { sport in
NavigationLink(destination: ContentView().environmentObject(workoutSession)) {
Text(sport)
}
}.onAppear() {
// Request HealthKit store authorization.
self.workoutSession.requestAuthorization()
}
}
}
struct DisciplineSelectionView_Previews: PreviewProvider {
static var previews: some View {
SportSelectionView().environmentObject(WorkoutManager())
}
}
The easiest way I've found to get around this issue is to add an .onAppear call to the destination view of the NavigationLink. Technically, the action will happen when the ContentView() appears and not when the NavigationLink is clicked.. but the difference will be milliseconds and probably irrelevant.
NavigationLink(destination:
ContentView()
.environmentObject(workoutSession)
.onAppear {
// add action here
}
)
Here's a solution that is a little different than the onAppear approach. By creating your own Binding for isActive in the NavigationLink, you can introduce a side effect when it's set. I've implemented it here all within the view, but I would probably do this in an ObservableObject if I were really putting it into practice:
struct ContentView: View {
#State var _navLinkActive = false
var navLinkBinding : Binding<Bool> {
Binding<Bool> { () -> Bool in
return _navLinkActive
} set: { (newValue) in
if newValue {
print("Side effect")
}
_navLinkActive = newValue
}
}
var body: some View {
NavigationView {
NavigationLink(
destination: Text("Dest"),
isActive: navLinkBinding,
label: {
Text("Navigate")
})
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

Picker delegate scrolling method in SwiftUI

I have simple Picker object in my SwiftUI hierarchy:
Picker(selection: $pickerSelection, label: Text("Select your item")) {
ForEach(0 ..< items.count) {
Text("\(self.items[$0].valueCode)")
.tag($0)
}
}
I'm using a scrollable Picker in WatchOS app and it works just fine. I'm even getting a Digital Crown rotation capability for free.
What I want to do is to detect when the scrolling started and especially ended (to get last selected value and execute and action with it)
I figure I need to implement sort of Delegate method to read the changes happening to the Picker but I'm not sure how, nor I'm able to find any in the documentation for WKInterfacePicker or just Picker
Any suggestions on how to detect the beginning and end of the scrolling event?
If its about the last value you can use Combine and subscribe to pickerSelection.
class ViewModel: ObservableObject {
private var disposables = Set<AnyCancellable>()
#Published var pickerSelection = 0
init() {
let cc = $pickerSelection
.sink(receiveValue: { value in
print(value)
})
cc.store(in: &disposables)
}
}
struct ContentView: View {
#ObservedObject var mm = ViewModel()
var items = [1,2,3,4,5,6,7,8,9,10]
var body: some View {
VStack {
Text("Hello, World!")
Picker(selection: self.$mm.pickerSelection, label: Text("Item:")) {
ForEach(0 ..< items.count) {
Text("Item \($0)")
.tag($0)
}
}
}
}
}