22

I'm trying to figure out the best way to build a simple settings screen bound to UserDefaults.

Basically, I have a Toggle and I want:

  • the value a UserDefault to be saved any time this Toggle is changed (the UserDefault should be the source of truth)
  • the Toggle to always show the value of the UserDefault

Settings screen with Toggle

I have watched many of the SwiftUI WWDC sessions, but I'm still not sure exactly how I should set everything up with the different tools that are available within Combine and SwiftUI. My current thinking is that I should be using a BindableObject so I can use hat to encapsulate a number of different settings.

I think I am close, because it almost works as expected, but the behavior is inconsistent.

When I build and run this on a device, I open it and turn on the Toggle, then if I scroll the view up and down a little the switch toggles back off (as if it's not actually saving the value in UserDefaults).

However, if I turn on the switch, leave the app, and then come back later it is still on, like it remembered the setting.

Any suggestions? I'm posting this in hopes it will help other people who are new to SwiftUI and Combine, as I couldn't find any similar questions around this topic.

import SwiftUI
import Combine

struct ContentView : View {

    @ObjectBinding var settingsStore = SettingsStore()

    var body: some View {
        NavigationView {
            Form {
                Toggle(isOn: $settingsStore.settingActivated) {
                    Text("Setting Activated")
                }
            }
        }.navigationBarTitle(Text("Settings"))
    }
}

class SettingsStore: BindableObject {

    var didChange = NotificationCenter.default.publisher(for: .settingsUpdated).receive(on: RunLoop.main)

    var settingActivated: Bool {
        get {
            UserDefaults.settingActivated
        }
        set {
            UserDefaults.settingActivated = newValue
        }
    }
}

extension UserDefaults {

    private static var defaults: UserDefaults? {
        return UserDefaults.standard
    }

    private struct Keys {
        static let settingActivated = "SettingActivated"
    }

    static var settingActivated: Bool {
        get {
            return defaults?.value(forKey: Keys.settingActivated) as? Bool ?? false
        }
        set {
            defaults?.setValue(newValue, forKey: Keys.settingActivated)
        }
    }
}

extension Notification.Name {
    public static let settingsUpdated = Notification.Name("SettingsUpdated")
}
gohnjanotis
  • 6,513
  • 6
  • 37
  • 57
  • Possible duplicate of [How do I use UserDefaults with SwiftUI?](https://stackoverflow.com/questions/56822195/how-do-i-use-userdefaults-with-swiftui) – Sudara Jul 01 '19 at 03:00

8 Answers8

35

Update

------- iOS 14 & above: -------

Starting iOS 14, there is now a very very simple way to read and write to UserDefaults.

Using a new property wrapper called @AppStorage

Here is how it could be used:

import SwiftUI

struct ContentView : View {

    @AppStorage("settingActivated") var settingActivated = false

    var body: some View {
        NavigationView {
            Form {
                Toggle(isOn: $settingActivated) {
                    Text("Setting Activated")
                }
            }.navigationBarTitle(Text("Settings"))
        }
    }
}

That's it! It is so easy and really straight forward. All your information is being saved and read from UserDefaults.

-------- iOS 13: ---------

A lot has changed in Swift 5.1. BindableObject has been completely deprecated. Also, there has been significant changes in PassthroughSubject.

For anyone wondering to get this to work, below is the working example for the same. I have reused the code of 'gohnjanotis' to make it simple.

import SwiftUI
import Combine

struct ContentView : View {

    @ObservedObject var settingsStore: SettingsStore

    var body: some View {
        NavigationView {
            Form {
                Toggle(isOn: $settingsStore.settingActivated) {
                    Text("Setting Activated")
                }
            }.navigationBarTitle(Text("Settings"))
        }
    }
}

class SettingsStore: ObservableObject {

    let willChange = PassthroughSubject<Void, Never>()

    var settingActivated: Bool = UserDefaults.settingActivated {
        willSet {

            UserDefaults.settingActivated = newValue

            willChange.send()
        }
    }
}

extension UserDefaults {

    private struct Keys {
        static let settingActivated = "SettingActivated"
    }

    static var settingActivated: Bool {
        get {
            return UserDefaults.standard.bool(forKey: Keys.settingActivated)
        }
        set {
            UserDefaults.standard.set(newValue, forKey: Keys.settingActivated)
        }
    }
}
atulkhatri
  • 10,896
  • 3
  • 53
  • 89
  • 2
    It seems neither `@AppStorage` nor your iOS 13 solution works when the user defaults key is not actually bound to anything on screen, but instead used to recompute some other value that's displayed on screen. – Abhijit Sarkar Jul 19 '20 at 05:41
  • Sorry, I don't follow how you are doing it. Could you please provide a sample so I can also check? @AshleyMills – atulkhatri Jul 22 '20 at 14:05
  • How to pass the @AppStorage var to other subviews? or into the environment of the app? – Super Noob Sep 29 '20 at 07:50
  • @SuperNoob you don't need to pass this to subviews. Each individual view can use separate AppStorage and they all will share the data. – atulkhatri Oct 20 '20 at 14:32
  • @atulkhatri even if you set a different default value for it in each sub view? – Super Noob Oct 21 '20 at 01:31
  • @SuperNoob, I don't think the value could be different for each subview because they are making use of the same var. If different values are required, you might need to use separate var for it. – atulkhatri Oct 21 '20 at 15:30
7

With help both from this video by azamsharp and this tutorial by Paul Hudson, I've been able to produce a toggle that binds to UserDefaults and shows whichever change you've assigned to it instantaneously.

  • Scene Delegate:

Add this line of code under 'window' variable

var settingsStore = SettingsStore()

And modify window.rootViewController to show this

window.rootViewController = UIHostingController(rootView: contentView.environmentObject(settingsStore))
  • SettingsStore:
import Foundation

class SettingsStore: ObservableObject {
    @Published var isOn: Bool = UserDefaults.standard.bool(forKey: "isOn") {
        didSet {
            UserDefaults.standard.set(self.isOn, forKey: "isOn")
        }
    }
}
  • SettingsStoreMenu

If so you wish, create a SwiftUI View called this and paste:

import SwiftUI

struct SettingsStoreMenu: View {
    
    @ObservedObject var settingsStore: SettingsStore
    
    var body: some View {
        Toggle(isOn: self.$settingsStore.isOn) {
            Text("")
        }
    }
}
  • Last but not least

Don't forget to inject SettingsStore to SettingsStoreMenu from whichever Main View you have, such as

import SwiftUI

struct MainView: View {
        
    @EnvironmentObject var settingsStore: SettingsStore

    @State var showingSettingsStoreMenu: Bool = false

    
    var body: some View {
        HStack {
            Button("Go to Settings Store Menu") {
                    self.showingSettingsStoreMenu.toggle()
            }
            .sheet(isPresented: self.$showingSettingsStoreMenu) {
                    SettingsStoreMenu(settingsStore: self.settingsStore)
            }
        }
    }
}

(Or whichever other way you desire.)

esedege
  • 71
  • 2
  • 5
  • This was very helpful for my project. Thank you. – Dan O'Leary Jun 14 '20 at 12:51
  • Should you `import Combine` into your class for `SettingsStore`? If that is correct, might be worth updating your answer. – andrewbuilder Aug 05 '20 at 12:24
  • @andrewbuilder Sorry about the previous answer, it's been some time since I coded this –or anything, 'cause I'm waiting for Core Data integration in SwiftUI App Life Cycle–, so at first glance I got confused when checking my own app code. There's no need for `import Combine` anywhere for this solution, I just created a mockup app from scratch with just the info I wrote and it works –Xcode-Beta 12.0 b2 and iPhone SE 2020 with iOS14 b3 (real device), but it worked as well with previous versions when I had them available at the time–. – esedege Aug 06 '20 at 13:41
  • @esedege I didn’t see a previous answer so no need to apologise. I thought we needed to import Combine to make publishers work, so I’ll do some more research. PS You don’t need to wait for Core Data integration into SwiftUI... check out this Apple Developer Forum thread titled [“Using Core Data with SwiftUI App protocol”](https://developer.apple.com/forums/thread/650876) and in particular [my implementation](https://developer.apple.com/forums/thread/650876?answerId=620207022#620207022). – andrewbuilder Aug 06 '20 at 14:28
  • @andrewbuilder Wow, neat! Time to investigate. – esedege Aug 06 '20 at 19:03
4

This seam to work well :

enum BackupLocalisations: String, CaseIterable, Hashable, Identifiable {
    case iPhone = "iPhone"
    case iCloud = "iCloud"
    
    var name: String {
        return self.rawValue
    }
    var id: BackupLocalisations {self}
}

enum Keys {
    static let iCloudIsOn = "iCloudIsOn"
    static let backupLocalisation = "backupLocalisation"
    static let backupsNumber = "backupsNumber"
}
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    var window: UIWindow?
    var settings = Settings()

…/…
    let contentView = ContentView()
            .environmentObject(settings)
… }
class Settings: ObservableObject {
    @Published var iCloudIsOn: Bool = UserDefaults.standard.bool(forKey: Keys.iCloudIsOn) {
        didSet { UserDefaults.standard.set(self.iCloudIsOn, forKey: Keys.iCloudIsOn) }
    }
    
    @Published var backupLocalisation: String = UserDefaults.standard.object(forKey: Keys.backupLocalisation) as? String ?? "iPhone" {
        didSet { UserDefaults.standard.set(self.backupLocalisation, forKey: Keys.backupLocalisation) }
    }
    
    @Published var backupsNumber: Int = UserDefaults.standard.integer(forKey: Keys.backupsNumber) {
        didSet { UserDefaults.standard.set(self.backupsNumber, forKey: Keys.backupsNumber) }
    }
}
struct ContentView: View {
    @ObservedObject var settings: Settings

    var body: some View {
        NavigationView {
            Form {
                Section(footer: Text("iCloud is \(UserDefaults.standard.bool(forKey: Keys.iCloudIsOn) ? "on" : "off")")) {
                    Toggle(isOn: self.$settings.iCloudIsOn) { Text("Use iCloud") }
                }
                Section {
                    Picker(selection: $settings.backupLocalisation, label: Text("\(self.settings.backupsNumber) sauvegarde\(self.settings.backupsNumber > 1 ? "s" : "") sur").foregroundColor(Color(.label))) {
                        ForEach(BackupLocalisations.allCases) { b in
                            Text(b.name).tag(b.rawValue)
                        }
                    }
                    
                    Stepper(value: self.$settings.backupsNumber) {
                        Text("Nombre de sauvegardes")
                    }
                }
            }.navigationBarTitle(Text("Settings"))
            
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environmentObject(Settings())
    }
}

Xcode 11.3.1

u0cram
  • 89
  • 6
1

Try something like this. You may also consider using EnvironmentObject instead of ObjectBinding per this answer.

import Foundation

@propertyWrapper
struct UserDefault<Value: Codable> {
    let key: String
    let defaultValue: Value

    var value: Value {
        get {
            return UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

Using the object binding, the toggle will set the user default with the key myBoolSetting to true / false. You can see the current value reflected in the Text view's text.

import Combine
import SwiftUI

final class SettingsStore: BindableObject {
    let didChange = PassthroughSubject<Void, Never>()

    @UserDefault(key: "myBoolSetting", defaultValue: false)
    var myBoolSetting: Bool {
        didSet {
            didChange.send()
        }
    }
}


struct ContentView : View {
    @ObjectBinding var settingsStore = SettingsStore()

    var body: some View {
        Toggle(isOn: $settingsStore.myBoolSetting) {
            Text("\($settingsStore.myBoolSetting.value.description)")
        }
    }
}
JWK
  • 3,345
  • 2
  • 19
  • 27
  • @gohnjanotis Just noticed that the persisting of `myBoolSetting` may be inconsistent with this approach too. Sorry about that. Sometimes it works. Sometimes not. As you said ;) I'm using Simulator to test because I don't have an iOS 13 device. Have you tried testing on a device at all? – JWK Jul 01 '19 at 07:51
  • Yes. The results I’m seeing are on a real device. – gohnjanotis Jul 01 '19 at 10:37
1

You can extend the @Published property wrapper to store values in UserDefaults (as proposed in this answer):

private var cancellables = [String: AnyCancellable]()

extension Published {
    init(defaultValue: Value, key: String) {
        let value = UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue
        self.init(initialValue: value)
        cancellables[key] = projectedValue.sink { val in
            UserDefaults.standard.set(val, forKey: key)
        }
    }
}

And here is the example based on the posted question:

import SwiftUI
import Combine

struct ContentView : View {
    @ObservedObject var settingsStore = SettingsStore()

    var body: some View {
        NavigationView {
            Form {
                Toggle(isOn: $settingsStore.settingActivated) {
                    Text("Setting Activated")
                }
            }.navigationBarTitle(Text("Settings"))
        }
    }
}

class SettingsStore: ObservableObject {
    @Published(defaultValue: false, key: "SettingActivated")
    var settingActivated: Bool
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
0

One issue I see is that you're using the wrong APIs for setting/getting a value from UserDefaults. You should use:

static var settingActivated: Bool {
    get {
        defaults?.bool(forKey: Keys.settingActivated) ?? false
    }
    set {
        defaults?.set(newValue, forKey: Keys.settingActivated)
    }
}
dalton_c
  • 6,876
  • 1
  • 33
  • 42
0

Here's what I came up with after some experimentation, using PassthroughSubject instead of trying to do something with notifications. It seems to work consistently and as expected.

I'm guessing there are probably some Swift or SwiftUI techniques to make this simpler, so please point out any other ideas for how to do something like this.

import SwiftUI
import Combine

struct ContentView : View {

    @ObjectBinding var settingsStore: SettingsStore

    var body: some View {
        NavigationView {
            Form {
                Toggle(isOn: $settingsStore.settingActivated) {
                    Text("Setting Activated")
                }
            }.navigationBarTitle(Text("Settings"))
        }
    }
}

class SettingsStore: BindableObject {

    let didChange = PassthroughSubject<Void, Never>()

    var settingActivated: Bool = UserDefaults.settingActivated {
        didSet {

            UserDefaults.settingActivated = settingActivated

            didChange.send()
        }
    }
}

extension UserDefaults {

    private struct Keys {
        static let settingActivated = "SettingActivated"
    }

    static var settingActivated: Bool {
        get {
            return UserDefaults.standard.bool(forKey: Keys.settingActivated)
        }
        set {
            UserDefaults.standard.set(newValue, forKey: Keys.settingActivated)
        }
    }
}
gohnjanotis
  • 6,513
  • 6
  • 37
  • 57
0

None of the above options worked for me. After spending hours, here is my work around trick:

@AppStorage("YourKey") private var usedefaultVaribaleToSaveToggle = false
@State private var stateVariableToUpdateView = false
var body: some View {
VStack{
 HStack{}.opacity(stateVariableToUpdateView ? 1:0)//This View will update on toggle
 Toggle(isOn: $stateVariableToUpdateView){
  Text("Your Text")
 }
  .onChange(of: stateVariableToUpdateView) { value in
    downloadStatus = value
  }
}.onAppear(){
  stateVariableToUpdateView = usedefaultVaribaleToSaveToggle// To update state according to UserDefaults
}
Naveen Kumawat
  • 467
  • 5
  • 12