How I can save picker data with UserDefaults in SwiftUI? - swiftui

I'm trying to save the user choice from picker component in UserDefault in SwiftUI but in contrary of a simple toggle, I'am blocked.
My View:
import SwiftUI
enum VibrationType: String, CaseIterable {
case low = "Faible"
case normal = "Normal"
case hight = "Fort"
}
struct CompassSettings: View {
#ObservedObject var settingsStore: SettingsStore = SettingsStore()
#State var vibrationIntensityIndex = 1
var body: some View {
VStack {
Toggle(isOn: $settingsStore.vibrationActivate) {
Text("Activer la vibration")
.font(.system(size: 18))
.foregroundColor(Color("TextDark"))
}
.padding(.top, 6)
Picker("Intesité de la vibration", selection: self.$settingsStore.vibrationIntensity) {
ForEach(0..<self.vibrationIntensity.count) { intensity in
Text(self.vibrationIntensity[intensity]).tag(intensity)
}
}
.pickerStyle(SegmentedPickerStyle())
}
}
}
SettingsStore class:
import SwiftUI
import Combine
final class SettingsStore: ObservableObject {
let vibrationIsActivate = PassthroughSubject<Void, Never>()
let intensityOfVibration = PassthroughSubject<Void, Never>()
// Vibration
var vibrationActivate: Bool = UserDefaults.vibrationActivated {
willSet {
UserDefaults.vibrationActivated = newValue
vibrationIsActivate.send()
}
}
// Vibration intensity
var vibrationIntensity: VibrationType = .normal {
willSet {
UserDefaults.vibrationIntensity = newValue
print(UserDefaults.vibrationIntensity)
intensityOfVibration.send()
}
}
}
The UserDefault extension:
extension UserDefaults {
private struct Keys {
static let vibrationActivated = "vibrationActivated"
static let vibrationIntensity = "vibrationIntensity"
}
// Vibration
static var vibrationActivated: Bool {
get { return UserDefaults.standard.bool(forKey: Keys.vibrationActivated) }
set { UserDefaults.standard.set(newValue, forKey: Keys.vibrationActivated) }
}
// Vibration intensity
static var vibrationIntensity: VibrationType {
get { return UserDefaults.standard.object(forKey: Keys.vibrationIntensity) as!
VibrationType ?? "normal" }
set { UserDefaults.standard.set(newValue, forKey: Keys.vibrationIntensity) }
}
}
So I have some errors in my UserDefaults extension. I don't know how I can save multiple string choices and how I can display a default choice.

Find below fixed code... tested with Xcode 11.2 / iOS 13.2.
enum VibrationType: String, CaseIterable {
case low = "Faible"
case normal = "Normal"
case hight = "Fort"
}
struct CompassSettings: View {
#ObservedObject var settingsStore: SettingsStore = SettingsStore()
#State var vibrationIntensityIndex = 1
#State var vibrationIntensity: [VibrationType] = [.low, .normal, .hight]
var body: some View {
VStack {
Toggle(isOn: $settingsStore.vibrationActivate) {
Text("Activer la vibration")
.font(.system(size: 18))
.foregroundColor(Color("TextDark"))
}
.padding(.top, 6)
Picker("Intesité de la vibration", selection: self.$settingsStore.vibrationIntensity) {
ForEach(self.vibrationIntensity, id: \.self) { intensity in
Text(intensity.rawValue).tag(intensity)
}
}
.pickerStyle(SegmentedPickerStyle())
}
}
}
final class SettingsStore: ObservableObject {
let vibrationIsActivate = PassthroughSubject<Void, Never>()
let intensityOfVibration = PassthroughSubject<Void, Never>()
// Vibration
var vibrationActivate: Bool = UserDefaults.vibrationActivated {
willSet {
UserDefaults.vibrationActivated = newValue
vibrationIsActivate.send()
}
}
// Vibration intensity
var vibrationIntensity: VibrationType = UserDefaults.vibrationIntensity {
willSet {
UserDefaults.vibrationIntensity = newValue
print(UserDefaults.vibrationIntensity)
intensityOfVibration.send()
}
}
}
extension UserDefaults {
private struct Keys {
static let vibrationActivated = "vibrationActivated"
static let vibrationIntensity = "vibrationIntensity"
}
// Vibration
static var vibrationActivated: Bool {
get { return UserDefaults.standard.bool(forKey: Keys.vibrationActivated) }
set { UserDefaults.standard.set(newValue, forKey: Keys.vibrationActivated) }
}
// Vibration intensity
static var vibrationIntensity: VibrationType {
get {
if let value = UserDefaults.standard.object(forKey: Keys.vibrationIntensity) as? String {
return VibrationType(rawValue: value)!
}
else {
return .normal
}
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: Keys.vibrationIntensity)
}
}
}

From iOS 14, using onChange()
#State private var index = UserDefaults.standard.integer(forKey: "YourKey")
Picker(...)
.onChange(of: index) { newValue in
// Run code to save
UserDefaults.standard.set(newValue, forKey: "YourKey")
}

You have to save the raw value of the enum, the string.
The getter is a bit extensive but safe
// Vibration intensity
static var vibrationIntensity: VibrationType {
get {
if let vibrationRawValue = UserDefaults.standard.string(forKey: Keys.vibrationIntensity),
let type = VibrationType(rawValue: vibrationRawValue) {
return type
} else {
return VibrationType(rawValue: "Normal")!
}
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: Keys.vibrationIntensity) }
}

Related

Why can't I write to an #ObservedObject?

I have a struct called Activity which has an id (UUID), name (String), description (String) and timesCompleted (Int).
I also have a class called Activities that contains an array of Activity structs called activityList. Activities is marked with ObservableObject.
I have activities declared as a #StateObject in my ContentView and I pass it to my ActivityDetailView where it is declared as an #ObservedObject.
However I can only partially write to activities.activityList in the child view. I can append, but I can't overwrite, update or remove an element from the array. No error is thrown but the view immediately crashes and the app returns to the main ContentView.
How do you update/write to an #ObservedObject? As you can see from the comments in my updateTimesCompleted() function I've tried all kinds of things to update/overwrite an existing element. All crash silently and return to ContentView. Append does not fail, but isn't the behavior I want, I want to update/overwrite an array element, not append a new copy.
Activity Struct:
struct Activity : Codable, Identifiable, Equatable {
var id = UUID()
var name: String
var description: String
var timesCompleted: Int
}
Activities Class:
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
ContentView:
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach(activities.activityList) { activity in
NavigationLink {
ActivityDetailView(activity: activity, activities: activities)
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
let _ = print("add activity")
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
}
}
ActivityDetailView:
struct ActivityDetailView: View {
#State private var timesCompleted = 0
let activity: Activity
#ObservedObject var activities: Activities
var body: some View {
NavigationView {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(timesCompleted)")
} onIncrement: {
timesCompleted += 1
updateTimesCompleted()
} onDecrement: {
if timesCompleted > 0 {
timesCompleted -= 1
updateTimesCompleted()
}
}
}
.navigationTitle("Activity Details")
}
}
func updateTimesCompleted() {
let newActivity = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
let _ = print("count: \(activities.activityList.count)")
let index = activities.activityList.firstIndex(of: activity)
let _ = print(index ?? -666)
if let index = index {
activities.activityList[index] = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
//activities.activityList.swapAt(index, activities.activityList.count - 1)
//activities.activityList[index].incrementTimesCompleted()
//activities.activityList.append(newActivity)
//activities.activityList.remove(at: index)
//activities.activityList.removeAll()
//activities.activityList.append(newActivity)
}
}
}
You could try this approach, where the activity is passed to the ActivityDetailView
as a binding.
In addition, #ObservedObject var activities: Activities is used directly in AddActivityView to add an Activity to the list.
struct Activity : Codable, Identifiable, Equatable {
let id = UUID() // <-- here
var name: String
var description: String
var timesCompleted: Int
enum CodingKeys: String, CodingKey { // <-- here
case name,description,timesCompleted
}
}
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach($activities.activityList) { $activity in // <-- here
NavigationLink {
ActivityDetailView(activity: $activity) // <-- here
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
.onAppear {
// for testing
if activities.activityList.isEmpty {
activities.activityList.append(Activity(name: "activity-1", description: "activity-1", timesCompleted: 1))
activities.activityList.append(Activity(name: "activity-2", description: "activity-2", timesCompleted: 2))
activities.activityList.append(Activity(name: "activity-3", description: "activity-3", timesCompleted: 3))
}
}
}
}
// -- here for testing
struct AddActivityView: View {
#ObservedObject var activities: Activities
var body: some View {
Text("AddActivityView")
Button("add activity") {
activities.activityList.append(Activity(name: "workingDog", description: "workingDog", timesCompleted: 5))
}
}
}
struct ActivityDetailView: View {
#Binding var activity: Activity // <-- here
var body: some View {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(activity.timesCompleted)")
} onIncrement: {
activity.timesCompleted += 1 // <-- here
} onDecrement: {
if activity.timesCompleted > 0 {
activity.timesCompleted -= 1 // <-- here
}
}
}
}
}

Link #Binding to #Published with SwiftUI

I'm trying to figure out how to link the #Binding passed into a custom View to an #Published from that view's model. Essentially I'm trying to create a reusable integer only TextField. I'm using the below code, which works to set the integer value into the text field, but what I can't figure out is how to update the binding when the text changes.
private class IntegerTextFieldValue: ObservableObject {
#Published var value = "" {
didSet {
let numbersOnly = value.filter { $0.isNumber }
if value != numbersOnly {
value = numbersOnly
}
}
}
}
struct IntegerTextField: View {
#Binding var value: Int?
#StateObject private var fieldValue = IntegerTextFieldValue()
var placeholder = ""
var body: some View {
TextField(placeholder, text: $fieldValue.value)
.keyboardType(.numberPad)
.onAppear {
if let value = value {
fieldValue.value = "\(value)"
}
}
}
}
If I understand you correctly
.onChange (of: fieldValue.value) { vl in
value = vl
}
this modifier updates the binding value to $fieldValue.value
Here is modified code to demo a possible approach (tested with Xcode 12.1 / iOS 14.1):
private class IntegerTextFieldValue: ObservableObject {
#Published var value = "" {
didSet {
let numbersOnly = value.filter { $0.isNumber }
if value != numbersOnly {
value = numbersOnly
}
if let number = Int(value) {
numberValue = number
}
}
}
#Published var numberValue: Int = 0
}
struct IntegerTextField: View {
#Binding var value: Int?
#StateObject private var fieldValue = IntegerTextFieldValue()
var placeholder = ""
var body: some View {
TextField(placeholder, text: $fieldValue.value)
.keyboardType(.numberPad)
.onAppear {
if let value = value {
fieldValue.value = "\(value)"
}
}
.onChange(of: fieldValue.numberValue) {
if $0 != self.value {
self.value = $0
}
}
}
}

SwiftUI: How to publish a variable in a class member object (another instance of a class) and update UI in View

I have a class PlayAudio to read an audio file and play. In PlayAudio, I have #objc updateUI function to add to CADisplayLink. I have another class Updater where I initialize and control isPaused of CADisplayLink. I've instantiated #Published var playAudio: PlayAudio so I can call it from View as updater.playAudio. My question is, although I can print playAudio.positionSliderValue real time in active CADisplayLink, playAudio.positionSliderValue does not update the UI in View. How can I achieve it? I want to activate and deActivate CADisplayLink from a separate class to maintain weak ownership (If I'm not mistaken...).
When #State var volume is updated, volume slider also updates, so I think I'm successfully updating the value itself, but I can't figure it out that update to trigger updates in UI. Any thoughts or suggestions are appreciated. Thanks.
import SwiftUI
import AVKit
struct ContentView: View {
#ObservedObject var updater = Updater()
#State var volume = 0.0
var body: some View {
Text("\(volume)")
VStack {
Slider(value:
// in order to get continuous value changes, I do this instead of $updater.playAudio.volumeSliderValue
Binding(get: {
updater.playAudio.volumeSliderValue
}, set: { (newValue) in
updater.playAudio.volumeSliderValue = newValue
updater.playAudio.setVolume()
volume = newValue
})
, in: 0...1)
Button(action: {
updater.playAudio.play()
// activate CADisplayLink
updater.activate()
// run CADisplayLink
updater.updater?.isPaused = false
}, label: {
Text("Play File")
})
Slider(value:
// in order to get continuous value changes, I do this instead of $playAudio.positionSliderValue
Binding(get: {
updater.playAudio.positionSliderValue
}, set: { (newValue) in
updater.playAudio.positionSliderValue = newValue
updater.playAudio.seek()
})
, in: 0.0...updater.playAudio.positionSliderTotal) { _ in
updater.playAudio.seek()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class Updater: ObservableObject {
var updater: CADisplayLink?
#Published var playAudio: PlayAudio
init(){
self.playAudio = PlayAudio()
self.updater = CADisplayLink(target: playAudio, selector: #selector(playAudio.updateUI))
}
func activate() {
self.updater?.add(to: .main, forMode: .default)
}
func deActivate() {
self.updater?.invalidate()
}
}
class PlayAudio: ObservableObject {
var sampleRate = Double()
var totalFrame = AVAudioFramePosition()
var startTime = AVAudioTime()
var newFramePosition = AVAudioFramePosition()
let url = Bundle.main.urls(forResourcesWithExtension: "mp4", subdirectory: nil)?.first
var audioFile = AVAudioFile()
var engine = AVAudioEngine()
var avAudioPlayerNode = AVAudioPlayerNode()
#Published var volumeSliderValue: Double = 0.7
#Published var positionSliderTotal: Double = 0.0
#Published var positionSliderValue: Double = 0.0
#objc func updateUI() {
positionSliderValue = Double(currentFrame)
// this prints ok, but I want it to update the UI in the View
print(positionSliderValue)
}
init () {
readFile()
schedulePlayer()
getTotalFrameDouble()
}
var currentFrame: AVAudioFramePosition {
guard let lastRenderTime = avAudioPlayerNode.lastRenderTime,
let playerTime = avAudioPlayerNode.playerTime(forNodeTime: lastRenderTime)
else {
return 0
}
return playerTime.sampleTime + newFramePosition
}
func getTotalFrameDouble() {
positionSliderTotal = Double(totalFrame)
print(positionSliderValue)
}
func readFile() {
guard let url = url else {
return
}
do {
self.audioFile = try AVAudioFile(forReading: url)
} catch let error {
print(error)
}
self.sampleRate = audioFile.processingFormat.sampleRate
self.totalFrame = audioFile.length
}
func setupEngine() {
engine.attach(avAudioPlayerNode)
engine.connect(avAudioPlayerNode, to: engine.mainMixerNode, format: audioFile.processingFormat)
engine.prepare()
do {
try engine.start()
} catch let error {
print(error)
}
}
func schedulePlayer() {
newFramePosition = 0
engine.reset()
setupEngine()
avAudioPlayerNode.scheduleFile(audioFile, at: nil, completionHandler: nil)
}
func play() {
let outputFormat = avAudioPlayerNode.outputFormat(forBus: AVAudioNodeBus(0))
let lastRenderTime = avAudioPlayerNode.lastRenderTime?.sampleTime ?? 0
// need to convert from AVAudioFramePosition to AVAudioTime
startTime = AVAudioTime(sampleTime: AVAudioFramePosition(Double(lastRenderTime)), atRate: Double(outputFormat.sampleRate))
avAudioPlayerNode.play(at: startTime)
}
func seek() {
// player time (needs to be converted to player node time
newFramePosition = AVAudioFramePosition(positionSliderValue)
let framesToPlay = totalFrame - newFramePosition
avAudioPlayerNode.stop()
if framesToPlay > 100 {
avAudioPlayerNode.scheduleSegment(audioFile, startingFrame: newFramePosition, frameCount: AVAudioFrameCount(framesToPlay), at: nil, completionHandler: nil)
}
play()
}
func setVolume() {
avAudioPlayerNode.volume = Float(volumeSliderValue)
}
}

SwiftUI Picker desn't bind with ObservedObject

I'm trying to fill up a Picker with data fetched asynchronously from external API.
This is my model:
struct AppModel: Identifiable {
var id = UUID()
var appId: String
var appBundleId : String
var appName: String
var appSKU: String
}
The class that fetches data and publish is:
class AppViewModel: ObservableObject {
private var appStoreProvider: AppProvider? = AppProvider()
#Published private(set) var listOfApps: [AppModel] = []
#Published private(set) var loading = false
fileprivate func fetchAppList() {
self.loading = true
appStoreProvider?.dataProviderAppList { [weak self] (appList: [AppModel]) in
guard let self = self else {return}
DispatchQueue.main.async() {
self.listOfApps = appList
self.loading = false
}
}
}
init() {
fetchAppList()
}
}
The View is:
struct AppView: View {
#ObservedObject var appViewModel: AppViewModel = AppViewModel()
#State private var selectedApp = 0
var body: some View {
ActivityIndicatorView(isShowing: self.appViewModel.loading) {
VStack{
// The Picker doesn't bind with appViewModel
Picker(selection: self.$selectedApp, label: Text("")) {
ForEach(self.appViewModel.listOfApps){ app in
Text(app.appName).tag(app.appName)
}
}
// The List correctly binds with appViewModel
List {
ForEach(self.appViewModel.listOfApps){ app in
Text(app.appName.capitalized)
}
}
}
}
}
}
While the List view binds with the observed object appViewModel, the Picker doesn't behave in the same way. I can't realize why. Any help ?
I filed bug report, FB7670992. Apple responded yesterday, suggesting that I confirm this behavior in iOS 14, beta 1. It appears to now have been resolved.
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
Picker("", selection: $viewModel.wheelPickerValue) {
ForEach(viewModel.objects) { object in
Text(object.string)
}
}
.pickerStyle(WheelPickerStyle())
.labelsHidden()
}
}
Where
struct Object: Identifiable {
let id = UUID().uuidString
let string: String
}
class ViewModel: ObservableObject {
private var counter = 0
#Published private(set) var objects: [Object] = []
#Published var segmentedPickerValue: String = ""
#Published var wheelPickerValue: String = ""
fileprivate func nextSetOfValues() {
let newCounter = counter + 3
objects = (counter..<newCounter).map { value in Object(string: "\(value)") }
let id = objects.first?.id ?? ""
segmentedPickerValue = id
wheelPickerValue = id
counter = newCounter
}
init() {
let timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] timer in
guard let self = self else { timer.invalidate(); return }
self.nextSetOfValues()
}
timer.fire()
}
}
Results in:
I can't put this into your code because it is incomplete but here is a sample.
Pickers aren't meant to be dynamic. They have to be completely reloaded.
class DynamicPickerViewModel: ObservableObject {
#Published private(set) var listOfApps: [YourModel] = []
#Published private(set) var loading = false
fileprivate func fetchAppList() {
loading = true
DispatchQueue.main.async() {
self.listOfApps.append(YourModel.addSample())
self.loading = false
}
}
init() {
fetchAppList()
}
}
struct DynamicPicker: View {
#ObservedObject var vm = DynamicPickerViewModel()
#State private var selectedApp = ""
var body: some View {
VStack{
//Use your loading var to reload the picker when it is done
if !vm.loading{
//Picker is not meant to be dynamic, it needs to be completly reloaded
Picker(selection: self.$selectedApp, label: Text("")) {
ForEach(self.vm.listOfApps){ app in
Text(app.name!).tag(app.name!)
}
}
}//else - needs a view while the list is being loaded/loading = true
List {
ForEach(self.vm.listOfApps){ app in
Text(app.name!.capitalized)
}
}
Button(action: {
self.vm.fetchAppList()
}, label: {Text("fetch")})
}
}
}
struct DynamicPicker_Previews: PreviewProvider {
static var previews: some View {
DynamicPicker()
}
}

Refreshing NavigationView Environment Object

Im trying to create an environment object that is editable and putting it in a list.
The Variables are only refreshing when I switch the tab for example (so whenever I leave the NavigationView) and then come back.
The same worked with a ModalView before. Is it a bug maybe? Or am I doing something wrong?
import SwiftUI
import Combine
struct TestView: View {
#State var showSheet: Bool = false
#EnvironmentObject var feed: TestObject
func addObjects() {
var strings = ["one","two","three","four","five","six"]
for s in strings {
var testItem = TestItem(text: s)
self.feed.items.append(testItem)
}
}
var body: some View {
TabView {
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: detailView(feed: self._feed, i: i)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}
}
}
}
.tabItem({ Text("Test") })
.tag(0)
Text("Blank")
.tabItem({ Text("Test") })
.tag(0)
}.onAppear {
self.addObjects()
}
}
}
struct detailView: View {
#EnvironmentObject var feed: TestObject
var i: Int
var body: some View {
VStack {
Text(feed.items[i].text)
Text(feed.items[i].read.description)
Button(action: { self.feed.items[self.i].isRead.toggle() }) {
Text("Toggle read")
}
}
}
}
final class TestItem: ObservableObject {
init(text: String) {
self.text = text
self.isRead = false
}
static func == (lhs: TestItem, rhs: TestItem) -> Bool {
lhs.text < rhs.text
}
var text: String
var isRead: Bool
let willChange = PassthroughSubject<TestItem, Never>()
var read: Bool {
set {
self.isRead = newValue
}
get {
self.isRead
}
}
}
class TestObject: ObservableObject {
var willChange = PassthroughSubject<TestObject, Never>()
#Published var items: [TestItem] = [] {
didSet {
willChange.send(self)
}
}
}
try passing .environmentObject on your destination:
NavigationLink(destination: detailView(feed: self._feed, i: i).environmentObject(x))
You have to use willSet instead of didSet.
TestItem should be a value type: struct or enum. SwiftUI's observation system properly works only with value types.