@AppStorage и @Published - как скомбинировать?


#1

Я в замешательстве. Видел один обучающий ролик, где вроде бы серьёзный программист применяет @AppStorage обёртку и создаёт для той же переменной дубликат с обёрткой @Published, дабы данные не только сохранялись на следующую сессию, но и интерфейс перерисовывался в зависимости от значения переменной (что даёт обёртка @Published). Потом следовали пляски с бубном, чтобы одной переменной присвоить значение другой при инициализации и обратно. В общем, сложно и даже как-то тупо. В связи с чем у меня вопрос. Можно ли как-то создать одну переменную, объединяющую оба свойства? Если нет, то как проще всего добиться подобного результата? Вот конкретная задача. У меня на странице “Настройки” в приложении есть переключатель, допустим, переключения звука “вкл.” - “выкл.”. Хочу, чтобы значение привязанной переменной сохранялось с выключением приложения. А также хочу, чтобы на самом выключателе соответственно менялась надпись “вкл.” - “выкл.”. Есть ли какие-то здравые идеи, как это сделать? Заранее спасибо.


#2

Любые “обёртки” (property wrapper) при изменении значения автоматически перерисовывают интерфейс. Ничего дополнительно придумывать не надо.

struct ContentView: View {
    
    @AppStorage("sound") var sound: Bool = false
    
    var body: some View {
        Toggle(sound ? "ON" : "OFF", isOn: $sound)
            .padding()
    }
}

И обычная запись без Апстореджа и с вьюмоделью

class ViewModel: ObservableObject {
    
    init() { sound = UserDefaults.standard.bool(forKey: "sound1") }

    @Published var sound: Bool {
        didSet {
            UserDefaults.standard.set(sound, forKey: "sound1")
        }
    }
}

struct ContentView: View {

    @ObservedObject var vm = ViewModel()
    
    var body: some View {
        Toggle(vm.sound ? "ON" : "OFF", isOn: $vm.sound)
            .padding()
    }
}

#3

Спасибо за такой развёрнутый ответ. А вот в первом случае можно как-то получить доступ к этой переменной @AppStorage(“sound”) var sound из других классов? У меня, собственно, в этом загвоздка. То есть она должна быть доступна извне для разных калькуляций и т. д. Или саму эту переменную убрать в отдельный класс и вызывать её из ContentView (у меня не получалось, Toggle не показывал изменение “ON” : “OFF”). Или всё же придётся прибегать к более сложной записи, используя UserDefaults?


#4
  1. Если вам надо работать с данной переменной только в представлениях (вью), то @AppStorage отлично подходит - она автоматом загружает из дефолтс нужные данные по указаномму ключу при инициализации и перерисовке интерфейса. То есть данную переменную размещайте в разных вью и она получит данные из одного ключа из дефолтс.

Если вы хотите работать с данной переменной из одного класса (вьюмодель), то можно только инициализировать вью модель при старте приложения и обращаться через окружение (@EnvironmentObject) из любого вью

   @main
   struct TestApp: App {
    
    let vm = ViewModel()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(vm)
        }
    }
   }


   struct ContentView: View {
    
    @EnvironmentObject var vm: ViewModel

    var body: some View {
          ...
    }
   }
  1. Другой момент если вам нужно доступ из других классов/файлов (не вьюшек), то достаточно сделать синглтон и обращаться через него.

    class ViewModel1: ObservableObject {
     init() { sound = UserDefaults.standard.bool(forKey: "sound1") }
    
     static let shared = ViewModel1()
    
     @Published var sound: Bool {
         didSet {
             UserDefaults.standard.set(sound, forKey: "sound1")
         }
     }
    }
    
    
     @main
     struct TestApp: App {
     
     let vm = ViewModel1.shared
     
     var body: some Scene {
         WindowGroup {
             ContentView()
                 .environmentObject(vm)
         }
     }
    }
    
    
    class ViewModel2: ObservableObject {
         @Published var newSound = ViewModel1.shared.sound
    }

#5

Благодарю. Применил, работает. Снёс класс Settings, запихнул все настройки во ViewModel для простоты. Буду пытаться создавать по минимуму классов, пока нет понятия, что, откуда и куда.


#6

Воспользовался последним способом с созданием синглтона. Всё-таки сеттинг перенёс в отдельный класс. Передал объекты во вью таким образом:

@main
struct WeatherAppApp: App {

let viewModel = ViewModel.shared
let locationVM = LocationVM.shared
let settingsVM = SettingsVM.shared

var body: some Scene {
    WindowGroup {
        NavigationView() {
            if !LocationVM.shared.locationWasFoundOnAppFirstStart {
                FirstRunView()
                    .environmentObject(viewModel)
                    .environmentObject(locationVM)
                    .environmentObject(settingsVM)
            } else {
                MainView()
                    .environmentObject(viewModel)
                    .environmentObject(locationVM)
                    .environmentObject(settingsVM)
            }
        }
    }
}
}

Но возникает вопрос, как передать environmentObject-ы во вьюшку, которая появляется впоследствии. Попробовал сделать по аналогии:

.sheet(isPresented: $showSettingsView) {
                    SettingsView()
                        .environmentObject(ViewModel.shared)
                        .environmentObject(LocationVM.shared)
                        .environmentObject(SettingsVM.shared)
                    }

Теперь в самой вьюшке SettingsView он ругается на байндинги, что мол не может найти $SettingsVM in scope в том самом коде регулировки настройки вкл-выкл:

Toggle(SettingsVM.shared.isSensitiveToWind ? "Sensitive" : "Normal", isOn: $SettingsVM.shared.isSensitiveToWind)

Не подскажете, как с этой бедой разобраться?


#7

Вам нужно разобраться/почитать/посмотреть как работать с объектами в окружении. Они (объекты) подключаются в окружение один раз при старте приложения (передаётся инициализированный объект):

MainView()
       .environmentObject(ViewModel.shared)

Потом внутри вью достаются при помощи:

@EnvironmentObject var vm: ViewModel

Здесь вы повторно передали инициализированный объект из синглтона, а не забрали из окружения уже существующий. В целом да, вроде это класс и там и там ссылка на один объект, но при работе с вью и тем более окружением, требуется брать объекты, инициализированные внутри вью:

.sheet(isPresented: $showSettingsView) {
                    SettingsView()
                        .environmentObject(ViewModel.shared)
}

Из любых вью переменные из окружения достаются при помощи @EnvironmentObject с указанием типа необходимой переменной (переменную обозвать можно как угодно).

Но при работе с .sheet есть нюанс (читай баг):

SwiftUI 1.0 - действительно требовал отдельно подключать переменные к вью внутри шита, иначе всё падало.

SwiftUI 2.0 - хотели исправить, но не доработали: теперь шит появляется норм без специального повторного подключения переменных в окружение вью в шите, но при дисмисе всё упадёт, если не подключить. То есть в теории работает без подключения, если шит в последующем не будет дисмиснут. В реале это 0,1% всех ситуаций ))

SwiftUI 3.0 - вроде наконец починили (3 года! блин :smile: ), но надо больше тестить.

Ваша ошибка, что вы во вью в шите передаёте ВМ из синглтона (инициализирована не внутри окружения/вью), а не из окружения.

Надо в родительском вью забрать ВМ из окружения
@EnvironmentObject var vm: ViewModel
и передать в шит

.sheet(isPresented: $showSettingsView) {
                        SettingsView()
                            .environmentObject(vm)
 }