SwiftUI: Why does #AppStorage work differently to my custom binding? - swift

I have a modal presented Sheet which should display based on a UserDefault bool.
I wrote a UI-Test which dismisses the sheet and I use launch arguments to control the defaults value.
However, when I tried using #AppStorage initially it didn't seem to persist the value, or was secretly not writing it? My test failed as after 'dismissing' the modal pops back up as the value is unchanged.
To work around this I wrote a custom binding. But i'm not sure what the behaviour difference is between the two implementations? The test passes this way.
Does anyone know what i'm not understanding sorry?
Cheers!
Simple Example
1. AppStorage
struct ContentView: View {
#AppStorage("should.show.sheet") private var binding: Bool = true
var body: some View {
Text("Content View")
.sheet(isPresented: $binding) {
Text("Sheet")
}
}
}
2. Custom Binding:
struct ContentView: View {
var body: some View {
let binding = Binding<Bool> {
UserDefaults.standard.bool(forKey: "should.show.sheet")
} set: {
UserDefaults.standard.set($0, forKey: "should.show.sheet")
}
Text("Content View")
.sheet(isPresented: binding) {
Text("Sheet")
}
}
}
Test Case:
final class SheetUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testDismiss() {
// Given 'true' userdefault value to show sheet on launch
let app = XCUIApplication()
app.launchArguments += ["-should.show.sheet", "<true/>"]
app.launch()
// When the user dismisses the modal view
app.swipeDown()
// Wait a second for animation (make sure it doesn't pop back)
sleep(1)
// Then the sheet should not be displayed
XCTAssertFalse(app.staticTexts["Sheet"].exists)
}
}

It does not work even when running app, because of that "." in key name (looks like this is AppStorage limitation, so use simple notation, like isSheet.
IMO the test-case is not correct, because it overrides defaults by arguments domain, but it is read-only, so writing is tried into persistent domain, but there might be same value there, so no change (read didSet) event is generated, so there no update in UI.
To test this it should be paired events inside app, ie. one gives AppStorage value true, other one gives false
*Note: boolean value is presented in arguments as 0/1 (not XML), lie -isSheet 1

Related

SwiftUI: Set a Published value in an ObservableObject from the UI (Picker, etc.)

Update:
This question is already solved (see responses below). The correct way to do this is to get your Binding by projecting the
ObservableObject For example, $options.refreshRate.
TLDR version:
How do I get a SwiftUI Picker (or other API that relies on a local Binding) to immediately update my ObservedObject/EnvironmentObject. Here is more context...
The scenario:
Here is something I consistently need to do in every SwiftUI app I create...
I always make some class that stores any user preference (let's call this class Options and I make it an ObservableObject.
Any setting that needs to be consumed is marked with #Published
Any view that consumes this brings it in as a #ObservedObject or #EnvironmentObject and subscribes to changes.
This all works quite nicely. The trouble I always face is how to set this from the UI. From the UI, here is usually what I'm doing (and this should all sound quite normal):
I have some SwiftUI view like OptionsPanel that drives the Options class above and allows the user to choose their options.
Let's say we have some option defined by an enum:
enum RefreshRate {
case low, medium, high
}
Naturally, I'd choose a Picker in SwiftUI to set this... and the Picker API requires that my selection param be a Binding. This is where I find the issue...
The issue:
To make the Picker work, I usually have some local Binding that is used for this purpose. But, ultimately, I don't care about that local value. What I care about is immediately and instantaneously broadcasting that new value to the rest of the app. The moment I select a new refresh rate, I'd like immediately know that instant about the change. The ObservableObject (the Options class) object does this quite nicely. But, I'm just updating a local Binding. What I need to figure out is how to immediately translate the Picker's state to the ObservableObject every time it's changed.
I have a solution that works... but I don't like it. Here is my non-ideal solution:
The non-ideal solution:
The first part of the solution is quite actually fine, but runs into a snag...
Within my SwiftUI view, rather than do the simplest way to set a Binding with #State I can use an alternate initializer...
// Rather than this...
#ObservedObject var options: Options
#State var refreshRate: RefreshRate = .medium
// Do this...
#ObservedObject var options: Options
var refreshRate: Binding<RefreshRate>(
get: { self.options.refreshRate },
set: { self.options.refreshRate = $0 }
)
So far, this is great (in theory)! Now, my local Binding is directly linked to the ObservableObject. All changes to the Picker are immediately broadcast to the entire app.
But this doesn't actually work. And this is where I have to do something very messy and non-ideal to get it to work.
The code above produces the following error:
Cannot use instance member 'options' within property initializer; property initializers run before 'self' is available
Here my my (bad) workaround. It works, but it's awful...
The Options class provides a shared instance as a static property. So, in my options panel view, I do this:
#ObservedObject var options: Options = .shared // <-- This is still needed to tell SwiftUI to listen for updates
var refreshRate: Binding<RefreshRate>(
get: { Options.shared.refreshRate },
set: { Options.shared.refreshRate = $0 }
)
In practice, this actually kinda works in this case. I don't really need to have multiple instances... just that one. So, as long as I always reference that shared instance, everything works. But it doesn't feel well architected.
So... does anyone have a better solution? This seems like a scenario EVERY app on the face of the planet has to tackle, so it seems like someone must have a better way.
(I am aware some use an .onDisapear to sync local state to the ObservedObject but this isn't ideal either. This is non-ideal because I value having immediate updates for the rest of the app.)
The good news is you're trying way, way, way too hard.
The ObservedObject property wrapper can create this Binding for you. All you need to say is $options.refreshRate.
Here's a test playground for you to try out:
import SwiftUI
enum RefreshRate {
case low, medium, high
}
class Options: ObservableObject {
#Published var refreshRate = RefreshRate.medium
}
struct RefreshRateEditor: View {
#ObservedObject var options: Options
var body: some View {
// vvvvvvvvvvvvvvvvvvvv
Picker("Refresh Rate", selection: $options.refreshRate) {
// ^^^^^^^^^^^^^^^^^^^^
Text("Low").tag(RefreshRate.low)
Text("Medium").tag(RefreshRate.medium)
Text("High").tag(RefreshRate.high)
}
.pickerStyle(.segmented)
}
}
struct ContentView: View {
#StateObject var options = Options()
var body: some View {
VStack {
RefreshRateEditor(options: options)
Text("Refresh rate: \(options.refreshRate)" as String)
}
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())
It's also worth noting that if you want to create a custom Binding, the code you wrote almost works. Just change it to be a computed property instead of a stored property:
var refreshRate: Binding<RefreshRate> {
.init(
get: { self.options.refreshRate },
set: { self.options.refreshRate = $0 }
)
}
If I understand your question correctly, you want
to Set a Published value in an ObservableObject from the UI (Picker, etc.) in SwiftUI.
There are many ways to do that, I suggest you use a ObservableObject class, and use it directly wherever you need a binding in a view, such as in a Picker.
The following example code shows one way of setting up your code to do that:
import Foundation
import SwiftUI
// declare your ObservableObject class
class Options: ObservableObject {
#Published var name = "Mickey"
}
struct ContentView: View {
#StateObject var optionModel = Options() // <-- initialise the model
let selectionSet = ["Mickey", "Mouse", "Goofy", "Donald"]
#State var showSheet = false
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.red)
Picker("names", selection: $optionModel.name) { // <-- use the model directly as a $binding
ForEach (selectionSet, id: \.self) { value in
Text(value).tag(value)
}
}
Button("Show other view") { showSheet = true }
}
.sheet(isPresented: $showSheet) {
SheetView(optionModel: optionModel) // <-- pass the model to other view, see also #EnvironmentObject
}
}
}
struct SheetView: View {
#ObservedObject var optionModel: Options // <-- receive the model
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.green) // <-- show updated value
}
}
}
If you really want to have a "useless" intermediate local variable, then use this approach:
struct ContentView: View {
#StateObject var optionModel = Options() // <-- initialise the model
let selectionSet = ["Mickey", "Mouse", "Goofy", "Donald"]
#State var showSheet = false
#State var localVar = "" // <-- the local var
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.red)
Picker("names", selection: $localVar) { // <-- using the localVar
ForEach (selectionSet, id: \.self) { value in
Text(value).tag(value)
}
}
.onChange(of: localVar) { newValue in
optionModel.name = newValue // <-- update the model
}
Button("Show other view") { showSheet = true }
}
.sheet(isPresented: $showSheet) {
SheetView(optionModel: optionModel) // <-- pass the model to other view, see also #EnvironmentObject
}
}
}

is possible to change #State property's value through its structure's method?

at Xcode's play ground I run code below:
import SwiftUI
struct Test {
#State var count: Int = 0 //to avoid "mutating" keyword, I use "#State" property wrapper
func countUp() {
self.count += 1
}
}
var test = Test()
test.countUp() // I want "count" property to be count up. but it did not
print(test.count) // print 0
Q) why test.count is still 0??
This may not be the intent of the question, but I will explain why the code in question does not work.(I believe this is the same thing the comment.)
You are using SwiftUI in a playground and not View.
The #State conforms to DynamicProperty.
https://developer.apple.com/documentation/swiftui/state
https://developer.apple.com/documentation/swiftui/dynamicproperty
The Documents say:
You should only access a state property from inside the view’s body, or from methods called by it.
An interface for a stored variable that updates an external property of a view.
and
The view gives values to these properties prior to recomputing the view’s body.
It has been suggested to use it with View and view’s body.
As mentioned above, your code does not use View or view’s body.
Since #State can only be used in a struct, if you want to do something similar, you can use a class and use #Published as shown below(simple use Combine, not SwiftUI):
import Combine
class Test {
#Published var count: Int = 0
func countUp() {
self.count += 1
}
}
var test = Test()
test.countUp()
print(test.count) // 1
However, your code has no one to subscribe to the changes, so this is just an experimental code.
Expressions are not allowed at the top level.
To test your case I created a View struct called Test1, and within its initialiser I created object for Test view. Check code below.
import SwiftUI
struct Test1:View {
init() {
let test = Test() // concentrate on this
test.countUp()
}
var body: some View {
Test() // Ignore for now
}
}
Test View-:
import SwiftUI
#propertyWrapper struct TestCount:DynamicProperty {
#State private var count = 0
var wrappedValue: Int {
get {
count
}
nonmutating set {
count = newValue
}
}
init(_ count: Int) {
self.count = count
}
}
struct Test: View {
#TestCount var count:Int
init(count:Int) {
_count = TestCount(count)
print(_count)
}
var body: some View {
Button {
countUp()
} label: {
Text("\(count)")
}
}
func countUp() {
count += 1
print(count)
}
}
For better understanding I created a custom propertyWrapper and we will initialise that inside Test view.
Now, when you run this code in simulator Test1 view is called first, and in it’s initialiser it will create object for Test() view.
Test view upon initialisation will create object for it’s property wrapper called TestCount, once created it will print description for TestCount instance, and notice the print output now-:
TestCount(_count: SwiftUI.State<Swift.Int>(_value: 0, _location: nil))
You can clearly see State variable count inside TestCount wrapper was assigned default value 0, but wasn’t allocated any SwiftUI.StoredLocation yet.
Now, this line is executed next test.countUp() in Test1 view, and because we are calling it from outside of body property and Test1 is also initialised outside of body property , compiler is smart enough to detect that and it will not provide SwiftUI storage location for this change to count variable, because there is no view update required for this change, hence you always see Output as 0 (default saved value).
To prove my point, now make following change in Test1 view.
import SwiftUI
struct Test1:View {
// init() {
// let test = Test(count: 0)
// test.countUp() // I want "count" property to be count up. but it did not
// }
var body: some View {
Test(count: 0)
}
}
Now again the initial print statement inside Test view is same -:
TestCount(_count: SwiftUI.State<Swift.Int>(_value: 0, _location: nil))
But, now you have a view with Button to change the count variable value on tap.
When you tap the button, again countUp() will be called, and this time check the output of print statement again, SwiftUI storage location is no more nil.
TestCount(_count: SwiftUI.State<Swift.Int>(_value: 0, _location:
Optional(SwiftUI.StoredLocation<Swift.Int>))).
You can debug the code further to get more better understanding, and I would also suggest you to read about PropertyWrappers in more detail, and how they work internally.

Capture the Very Last Change of a Binding in SwiftUI / Combine

When I have a Binding in SwiftUI and I want to save whenever the binding changes I do (e.g. on a TextField)
var myText: String { /* value is derived */ }
func save(_ text: String) { /* doing the save stuff */ }
TextFiel(text: .init(get: { myText }, set: { save($0) })
Doing this, save() gets called whenever the binding changes. In some cases this might not be ideal, e.g. when save() makes a server call or some expensive computations. So what I'm looking for, is to get notified whenever the binding changes for the last time.
Maybe some kind of delayed observer that fires x seconds after the final change and get's invalidated if another change happens earlier than that threshold. Does Combine offer something like this?
Disclaimer: This question is about bindings in general and not just about TextFields in particular. The Textfield is only a coding example , so .onCommit is not the solution I'm looking for ;)
The debounce operator in Combine does this. It waits until a new value hasn't been pushed through the pipeline for X amount of time and then sends a signal.
In the case of a TextField, you'll still want a "normal" binding, because you'd want the user to see the characters appearing in real-time even if the data doesn't get sent to the server (or whatever other expensive operation) immediately.
import Combine
import SwiftUI
class ViewModel : ObservableObject {
#Published var text : String = ""
private var cancellables = Set<AnyCancellable>()
init() {
$text
.debounce(for: .seconds(2), scheduler: RunLoop.main)
.sink { (newValue) in
//do expensive operation
print(newValue)
}
.store(in: &cancellables)
}
}
struct ContentView : View {
#StateObject var vm = ViewModel()
var body: some View {
TextField("", text: $vm.text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
}
}
Depending on what your operation does, you may also want to add a .receive(on:) operator. To update the UI, you'd want to receive on the main thread. But, for other expensive operations, you can send things to a background queue this way.

What's best practice for programmatic movement a NavigationView in SwiftUI

I'm working on an app that needs to open on the users last used view even if the app is completly killed by the user or ios.
As a result I'm holding last view used in UserDefaults and automatically moving the user through each view in the stack until they reach their destination.
The code on each view is as follows:
#Binding var redirectionID: Int
VStack() {
List {
NavigationLink(destination: testView(data: data, moc: moc), tag: data.id, selection:
$redirectionId) {
DataRow(data: data)
}
}
}.onAppear() {
redirectionID = userData.lastActiveView
}
Is there a better / standard way to achieve this? This works reasonably on iOS 14.* but doesn't work very well on iOS 13.* On iOS 13.* The redirection regularly doesnt reach its destination page and non of the preceeding views in the stack seem to be created. Pressing back etc results in a crash.
Any help / advice would be greatly appreciated.
This sounds like the perfect use of if SceneStorage
"You use SceneStorage when you need automatic state restoration of the value. SceneStorage works very similar to State, except its initial value is restored by the system if it was previously saved, and the value is· shared with other SceneStorage variables in the same scene."
#SceneStorage("ContentView.selectedProduct") private var selectedProduct: String?
#SceneStorage("DetailView.selectedTab") private var selectedTab = Tabs.detail
It is only available in iOS 14+ though so something manual would have to be implemented. Maybe something in CoreData. An object that would have variables for each important state variable. It would work like an ObservedObject ViewModel with persistence.
Also. you can try...
"An NSUserActivity object captures the app’s state at the current moment in time. For example, include information about the data the app is currently displaying. The system saves the provided object and returns it to the app the next time it launches. The sample creates a new NSUserActivity object when the user closes the app or the app enters the background."
Here is some sample code that summarizes how to bring it all together. It isn't a minimum reproducible example because it is a part of the larger project called "Restoring Your App's State with SwiftUI" from Apple. But it gives a pretty good picture on how to implement it.
struct ContentView: View {
// The data model for storing all the products.
#EnvironmentObject var productsModel: ProductsModel
// Used for detecting when this scene is backgrounded and isn't currently visible.
#Environment(\.scenePhase) private var scenePhase
// The currently selected product, if any.
#SceneStorage("ContentView.selectedProduct") private var selectedProduct: String?
let columns = Array(repeating: GridItem(.adaptive(minimum: 94, maximum: 120)), count: 3)
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(productsModel.products) { product in
NavigationLink(destination: DetailView(product: product, selectedProductID: $selectedProduct),
tag: product.id.uuidString,
selection: $selectedProduct) {
StackItemView(itemName: product.name, imageName: product.imageName)
}
.padding(8)
.buttonStyle(PlainButtonStyle())
.onDrag {
/** Register the product user activity as part of the drag provider which
will create a new scene when dropped to the left or right of the iPad screen.
*/
let userActivity = NSUserActivity(activityType: DetailView.productUserActivityType)
let localizedString = NSLocalizedString("DroppedProductTitle", comment: "Activity title with product name")
userActivity.title = String(format: localizedString, product.name)
userActivity.targetContentIdentifier = product.id.uuidString
try? userActivity.setTypedPayload(product)
return NSItemProvider(object: userActivity)
}
}
}
.padding()
}
.navigationTitle("ProductsTitle")
}
.navigationViewStyle(StackNavigationViewStyle())
.onContinueUserActivity(DetailView.productUserActivityType) { userActivity in
if let product = try? userActivity.typedPayload(Product.self) {
selectedProduct = product.id.uuidString
}
}
.onChange(of: scenePhase) { newScenePhase in
if newScenePhase == .background {
// Make sure to save any unsaved changes to the products model.
productsModel.save()
}
}
}
}

SwiftUI not being updated with manual publish

I have a class, a “clock face” with regular updates; it should display an array of metrics that change over time.
Because I’d like the clock to also be displayed in a widget, I’ve found that I had to put the class into a framework (perhaps there’s another way, but I’m too far down the road now). This appears to have caused a problem with SwiftUI and observable objects.
In my View I have:
#ObservedObject var clockFace: myClock
In the clock face I have:
class myClock: ObservableObject, Identifiable {
var id: Int
#Publish public var metric:[metricObject] = []
....
// at some point the array is mutated and the display updates
}
I don’t know if Identifiable is needed but it’s doesn’t make any difference to the outcome. The public is demanded by the compiler, but it’s always been like that anyway.
With these lines I get a runtime error as the app starts:
objc[31175] no class for metaclass
So I took off the #Published and changed to a manual update:
public var metric:[metricObject] = [] {
didSet {
self.objectWillChange.send()`
}
}
And now I get a display and by setting a breakpoint I can see the send() is being called at regular intervals. But the display won’t update unless I add/remove from the array. I’m guessing the computed variables (which make up the bulk of the metricObject change isn’t being seen by SwiftUI. I’ve subsequently tried adding a “dummy” Int to the myClock class and setting that to a random value to trying to trigger a manual refresh via a send() on it’s didSet with no luck.
So how can I force a periodic redraw of the display?
What is MetricObject and can you make it a struct so you get Equatable for free?
When I do this with an Int it works:
class PeriodicUpdater: ObservableObject {
#Published var time = 0
var subscriptions = Set<AnyCancellable>()
init() {
Timer
.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink(receiveValue: { _ in
self.time = self.time + 1
})
.store(in: &subscriptions)
}
}
struct ContentView: View {
#ObservedObject var updater = PeriodicUpdater()
var body: some View {
Text("\(self.updater.time)")
}
}
So it's taken a while but I've finally got it working. The problem seemed to be two-fold.
I had a class defined in my framework which controls the SwiftUI file. This class is sub-classed in both the main app and the widget.
Firstly I couldn't use #Published in the main class within the framework. That seemed to cause the error:
objc[31175] no class for metaclass
So I used #JoshHomman's idea of an iVar that's periodically updated but that didn't quite work for me. With my SwiftUI file, I had:
struct FRMWRKShape: Shape {
func drawShape(in rect: CGRect) -> Path {
// draw and return a shape
}
}
struct ContentView: View {
#ObservedObject var updater = PeriodicUpdater()
var body: some View {
FRMWRKShape()
//....
FRMWRKShape() //slightly different parameters are passed in
}
}
The ContentView was executed every second as I wanted, however the FRMWRKShape code was called but not executed(?!) - except on first starting up - so the view doesn't update. When I changed to something far less D.R.Y. such as:
struct ContentView: View {
#ObservedObject var updater = PeriodicUpdater()
var body: some View {
Path { path in
// same code as was in FRMWRKShape()
}
//....
Path { path in
// same code as was in FRMWRKShape()
// but slightly different parameters
}
}
}
Magically, the View was updated as I wanted it to be. I don't know if this is expected behaviour, perhaps someone can say whether I should file a Radar....