SwiftUI enum binding not refreshing view - swift

I'm trying to show different views (with the same base) depending on an enum value but depending on how to "inspect" the enum the behavior changes. This is the code (I'm using a "useSwitch" variable to be able to alternate between both behaviors)
import SwiftUI
enum ViewType: CaseIterable {
case type1
case type2
var text: String {
switch self {
case .type1:
return "Type 1"
case .type2:
return "Type 2"
}
}
}
final class BaseVM: ObservableObject {
let type: ViewType
#Published var requestingData = false
init(type: ViewType) {
self.type = type
}
#MainActor func getData() async {
requestingData = true
try! await Task.sleep(nanoseconds: 1_000_000_000)
requestingData = false
}
}
struct BaseView: View {
#StateObject var vm: BaseVM
var body: some View {
Group {
if vm.requestingData {
ProgressView("Getting data for \(vm.type.text)")
} else {
Text("\(vm.type.text)")
}
}
.onAppear {
Task {
await vm.getData()
}
}
}
}
struct TestZStackView: View {
private let types = ViewType.allCases
#State var currentType: ViewType = .type1
private var useSwitch = true
var body: some View {
VStack {
if useSwitch {
Group {
switch currentType {
case .type1:
BaseView(vm: BaseVM(type: currentType))
case .type2:
BaseView(vm: BaseVM(type: currentType))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
BaseView(vm: BaseVM(type: currentType))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Spacer()
Picker("", selection: $currentType) {
ForEach(types, id: \.self) {
Text($0.text)
}
}
.pickerStyle(.segmented)
.padding(.top, 20)
}
.padding()
}
}
struct TestZStackView_Previews: PreviewProvider {
static var previews: some View {
TestZStackView()
}
}
I don't understand why using a switch (useSwitch == true) refreshes the view but using the constructor passing the enum as parameter (useSwitch = false) doesn't refresh the view... It can't detect that the currentType has changed if used as parameter instead of checking it using a switch?

This is all about identity. If you need more information I would recommend watching WWDC Demystify SwiftUI.
If your #State var triggers when changing the Picker the TestZStackView rebuilds itself. When hitting the if/else clause there are two possibilities:
private var useSwitch = true. So it checks the currentType and builds the appropriate BaseView. These differ from each other in their id, so a new View gets build and you get what you expect.
the second case is less intuitive. I really recommend watching that WWDC session mentioned earlier. If private var useSwitch = false there is no switch statement and SwiftUI tries to find out if your BaseView has changed and needs to rerender. For SwiftUI your BaseView hasn´t changed even if you provided a new BaseVM. It does notify only changes on depending properties or structs (or #Published in ObservableObject).
In your case #StateObject var vm: BaseVM is the culprit. But removing #StateObject will create the new View but you loose the ObservableObject functionality.
Solution here would be to restructure your code. Use only one BaseVm instance that holds your state and pass that on into the environment.
E.g.:
final class BaseVM: ObservableObject {
// create a published var here
#Published var type: ViewType = .type1
#Published var requestingData = false
#MainActor func getData() async {
requestingData = true
try! await Task.sleep(nanoseconds: 1_000_000_000)
requestingData = false
}
}
struct BaseView: View {
// receive the viewmodel from the environment
#EnvironmentObject private var vm: BaseVM
var body: some View {
Group {
if vm.requestingData {
ProgressView("Getting data for \(vm.type.text)")
} else {
Text("\(vm.type.text)")
}
}
// change this also because the view will not apear multiple times it
// will just change depending on the type value
.onChange(of: vm.type) { newValue in
Task{
await vm.getData()
}
}.onAppear{
Task{
await vm.getData()
}
}
}
}
struct TestZStackView: View {
private let types = ViewType.allCases
#StateObject private var viewmodel = BaseVM()
private var useSwitch = false
var body: some View {
VStack {
if useSwitch {
//this group doesn´t really make sense but just for demonstration
Group {
switch viewmodel.type {
case .type1:
BaseView()
.environmentObject(viewmodel)
case .type2:
BaseView()
.environmentObject(viewmodel)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
BaseView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environmentObject(viewmodel)
}
Spacer()
Picker("", selection: $viewmodel.type) {
ForEach(types, id: \.self) {
Text($0.text)
}
}
.pickerStyle(.segmented)
.padding(.top, 20)
}
.padding()
}
}

Related

SwiftUI polymorphic behaviour not working for View

protocol BackgroundContent: View{
}
struct BlueDivider: BackgroundContent {
var body: some View {
Divider()
.frame(minHeight: 1)
.background(.blue)
}
}
struct RedDivider: BackgroundContent {
var body: some View {
Divider()
.frame(minHeight: 1)
.background(.red)
}
}
var p: BackgroundContent = BlueDivider()
// Use of protocol 'BackgroundContent' as a type must be written 'any BackgroundContent'
p = RedDivider()
This always ask me to use
var p: any BackgroundContent = BlueDivider()
Is there any way to use generic type which accept any kind view?
Actually, I want to use view as a state like #State private var bgView: BackgroundContent = BlueDivider() which i want to change at runtime like bgView = RedDivider()
I have made my custome view to place some other view at runtime by using this state.
For your specific problem you can do something like this here:
struct SwiftUIView: View {
#State var isRed = false
var body: some View {
Devider()
.frame(height: 1)
.background(isRed ? Color.red : Color.blue)
}
}
It is complicated but i have found a solution of this problem. First thing i have done with ObservableObject. Here is my example.
protocol BaseBackgroundContent {
var color: Color { get set }
}
class BlueContent: BaseBackgroundContent {
var color: Color = .blue
}
class RedContent: BaseBackgroundContent {
var color: Color = .red
}
And i created a custom view for Divider in this case.
struct CustomDivider: View {
var backgroundContent: any BaseBackgroundContent
var body: some View {
Divider()
.background(backgroundContent.color)
}
}
And now i used a viewModel which can be observable, and the protocol has to be Published.
class ExampleViewModel: ObservableObject {
#Published var backgroundContent: any BaseBackgroundContent = RedContent()
func change() {
backgroundContent = BlueContent()
}
}
Final step is the view. This is a exampleView. If you click the button you will see the BlueContent which was RedContent
struct Example: View {
#ObservedObject var viewModel = ExampleViewModel()
init() {
}
var body: some View {
VStack {
Text("Test")
CustomDivider(backgroundContent: viewModel.backgroundContent)
Button("Change") {
viewModel.change()
}
}
}
}

SwiftUI: UserDefaults Binding

I have a settings view that has a button which toggles a binding that's stored with UserDefaults.
struct Settings: View {
#ObservedObject var settingsVM = SetttingsViewModel()
var body: some View {
if settingsVM.settingActivated {
Text("Setting activated")
} else {
Text("Setting deactivated")
}
Button("Activate") {
settingsVM.settingActivated.toggle()
}
}
}
SettingsViewModel
class SetttingsViewModel: ObservableObject {
#Published var settingActivated: Bool = UserDefaults.standard.bool(forKey: "settingActivated") {
didSet {
UserDefaults.standard.set(self.settingActivated, forKey: "settingActivated")
}
}
}
The text("Setting activated/ Setting deactivated")in the Settings view update instantly when i press the button but the text in ContentView doesn't change unless i restart the app & i have no idea why.
struct ContentView: View {
#ObservedObject var settingsVM = SetttingsViewModel()
#State private var showsettings = false
var body: some View {
if settingsVM.settingActivated {
Text("Setting Activated")
.padding(.top)
} else {
Text("Setting Deactivated")
.padding(.top)
}
Button("Show Settings") {
showsettings.toggle()
}
.sheet(isPresented: $showsettings) {
Settings()
}
.frame(width: 300, height: 300)
}
}
This is for a macOS 10.15 app so i can't use #AppStorage
Right now, you don't have any code in you view model to react to a change in UserDefaults. Meaning, if UserDefaults gets a new value set, it won't know about it. And, since you're using a different instance of SettingsViewModel in your two different views, they can easily become out-of-sync.
The easiest change would be to pass the same instance of SettingsViewModel to Settings:
struct Settings: View {
#ObservedObject var settingsVM: SettingsViewModel //<-- Here
var body: some View {
if settingsVM.settingActivated {
Text("Setting activated")
} else {
Text("Setting deactivated")
}
Button("Activate") {
settingsVM.settingActivated.toggle()
}
}
}
struct ContentView: View {
#ObservedObject var settingsVM = SetttingsViewModel()
#State private var showsettings = false
var body: some View {
if settingsVM.settingActivated {
Text("Setting Activated")
.padding(.top)
} else {
Text("Setting Deactivated")
.padding(.top)
}
Button("Show Settings") {
showsettings.toggle()
}
.sheet(isPresented: $showsettings) {
Settings(settingsVM: settingsVM) //<-- Here
}
.frame(width: 300, height: 300)
}
}
Another option would be to use a custom property wrapper (like AppStorage, but available to earlier targets): https://xavierlowmiller.github.io/blog/2020/09/04/iOS-13-AppStorage
Also, #vadian's comment is important -- if you had access to it, you'd want to use #StateObject. But, since you don't, it's important to store your ObservableObject at the top level so it doesn't get recreated.

How to assign value to #State in View from ViewModel?

I have a movie listing view with basic listing functionality, Once pagination reaches to the last page I want to show an alert for that I am using reachedLastPage property.
The viewModel.state is an enum, the case movies has associated value in which there is moreRemaining property which tells if there are more pages or not.
Once the moreRemaining property becomes false I want to make reachedLastPage to true so that I can show an alert.
How can I achieve this in best way?
import SwiftUI
import SwiftUIRefresh
struct MovieListingView<T>: View where T: BaseMoviesListViewModel {
#ObservedObject var viewModel: T
#State var title: String
#State var reachedLastPage: Bool = false
var body: some View {
NavigationView {
ZStack {
switch viewModel.state {
case .loading:
LoadingView(title: "Loading Movies...")
.onAppear {
fetchMovies()
}
case .error(let error):
ErrorView(message: error.localizedDescription, buttonTitle: "Retry") {
fetchMovies()
}
case .noData:
Text("No data")
.multilineTextAlignment(.center)
.font(.system(size: 20))
case .movies(let data):
List {
ForEach(data.movies) { movie in
NavigationLink(destination: LazyView(MovieDetailView(viewModel: MovieDetailViewModel(id: movie.id)))) {
MovieViewRow(movie: movie)
.onAppear {
if movie == data.movies.last && data.moreRemaining {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
fetchMovies()
}
}
}
}
if movie == data.movies.last && data.moreRemaining {
HStack {
Spacer()
ActivityIndicator(isAnimating: .constant(data.moreRemaining))
Spacer()
}
}
}
}.pullToRefresh(isShowing: .constant(data.isRefreshing)) {
print("Refresheeeee")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
refreshMovies()
}
}
}
}
.navigationViewStyle(.stack)
.navigationBarTitle("\(title)", displayMode: .inline)
.alert(isPresented: $reachedLastPage) {
Alert(title: Text("You have reached to the end of the list."))
}
}
}
private func fetchMovies() {
viewModel.trigger(.fetchMovies(false))
}
private func refreshMovies() {
viewModel.trigger(.fetchMovies(true))
}
}
you could try this approach, using .onReceive(...). Add this to your
ZStack or NavigationView:
.onReceive(Just(viewModel.moreRemaining)) { val in
reachedLastPage = !val
}
Also add: import Combine
(Ignoring "the best way" part, 'cause it's opinion-based,) one way to achieve that is to make your view model an observable object (which likely already is), adding the publisher of reachedLastPage there, and observe it directly from the view. Something like this:
final class ContentViewModel: ObservableObject {
#Published var reachedLastPage = false
init() {
// Just an example of changing the value.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { self.reachedLastPage = true }
}
}
struct ContentView: View {
var body: some View {
Text("Hello World")
.alert(isPresented: $viewModel.reachedLastPage) {
Alert(title: Text("Alert is triggered"))
}
}
#ObservedObject private var viewModel: ContentViewModel
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
}
Once reachedLastPage takes the true value, the alert will be presented.

How to create a NavigationManager that would change tab within view models?

I have a NavigationManager to handle changing SwiftUI tab bar selection.
It work if it is set as a #EnvironmentObject in my SwiftUI views, but not when the NavigationManager is called as a service in my view models. The thing is that I would like to use a simpler solution than passing around #EnvironmentObject var navigationManager around and pass them inside view model initializer as I have a lot of them and I am looking for a cleaner approach.
How can I use my NavigationManager to change tabs from inside my view models without passing it in init()?
import SwiftUI
struct ContentView: View {
#StateObject var navigationManager = NavigationManager()
var body: some View {
TabView(selection: $navigationManager.selection) {
AccountView()
.tabItem {
Text("Account")
Image(systemName: "person.crop.circle") }
.tag(NavigationItem.account)
SettingsView()
.tabItem {
Text("Settings")
Image(systemName: "gear") }
.tag(NavigationItem.settings)
.environmentObject(navigationManager)
}
}
}
The navigation manager that I would like to use within view models.
class NavigationManager: ObservableObject {
#Published var selection: NavigationItem = .account
}
enum NavigationItem {
case account
case settings
}
My AccountViewModel and Settings View Model:
class AccountViewModel: ObservableObject {
let navigationManager = NavigationManager()
}
struct AccountView: View {
#StateObject var viewModel = AccountViewModel()
var body: some View {
VStack(spacing: 16) {
Text("AccountView")
.font(.title3)
Button(action: {
viewModel.navigationManager.selection = .settings
}) {
Text("Go to Settings tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
class SettingsViewModel: ObservableObject {
let navigationManager = NavigationManager()
}
struct SettingsView: View {
#EnvironmentObject var navigationManager: NavigationManager
#StateObject var viewModel = SettingsViewModel()
var body: some View {
VStack(spacing: 16) {
Text("SettingsView")
.font(.title3)
Button(action: {
navigationManager.selection = .account
}) {
Text("Go to Account tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
I managed to successfully inject my navigationManager as a shared dependency using a property wrapper and change its selection variable using Combine.
So I have created a protocol to wrap NavigationManager to a property wrapper #Injection and make its value a CurrentValueSubject
import Combine
final class NavigationManager: NavigationManagerProtocol, ObservableObject {
var selection = CurrentValueSubject<NavigationItem, Never>(.settings)
}
protocol NavigationManagerProtocol {
var selection: CurrentValueSubject<NavigationItem, Never> { get set }
}
This is the #Injection property wrapper to pass my instance of NavigationManager between .swift files.
#propertyWrapper
struct Injection<T> {
private let keyPath: WritableKeyPath<InjectedDependency, T>
var wrappedValue: T {
get { InjectedDependency[keyPath] }
set { InjectedDependency[keyPath] = newValue }
}
init(_ keyPath: WritableKeyPath<InjectedDependency, T>) {
self.keyPath = keyPath
}
}
protocol InjectedKeyProtocol {
associatedtype Value
static var currentValue: Self.Value { get set }
}
struct InjectedDependency {
private static var current = InjectedDependency()
static subscript<K>(key: K.Type) -> K.Value where K: InjectedKeyProtocol {
get { key.currentValue }
set { key.currentValue = newValue }
}
static subscript<T>(_ keyPath: WritableKeyPath<InjectedDependency, T>) -> T {
get { current[keyPath: keyPath] }
set { current[keyPath: keyPath] = newValue }
}
}
extension InjectedDependency {
var navigationManager: NavigationManagerProtocol {
get { Self[NavigationManagerKey.self] }
set { Self[NavigationManagerKey.self] = newValue }
}
}
private struct NavigationManagerKey: InjectedKeyProtocol {
static var currentValue: NavigationManagerProtocol = NavigationManager()
}
With this in place, I can pass my NavigationManager between my view models and send new value using Combine on button tap:
class AccountViewModel: ObservableObject {
#Injection(\.navigationManager) var navigationManager
}
struct AccountView: View {
var viewModel = AccountViewModel()
var body: some View {
VStack(spacing: 16) {
Text("AccountView")
.font(.title3)
Button(action: {
viewModel.navigationManager.selection.send(.settings)
}) {
Text("Go to Settings tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
class SettingsViewModel: ObservableObject {
#Injection(\.navigationManager) var navigationManager
}
struct SettingsView: View {
#StateObject var viewModel = SettingsViewModel()
var body: some View {
VStack(spacing: 16) {
Text("SettingsView")
.font(.title3)
Button(action: {
viewModel.navigationManager.selection.send(.account)
}) {
Text("Go to Account tab")
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
}
}
To wrap things up, I inject NavigationManager in my ContentView and use the .onReceive(_:action:) modifier to keep track of the newly selected tab from anywhere in code.
struct ContentView: View {
#Injection(\.navigationManager) var navigationManager
#State var selection: NavigationItem = .account
var body: some View {
TabView(selection: $selection) {
AccountView()
.tabItem {
Text("Account")
Image(systemName: "person.crop.circle") }
.tag(NavigationItem.account)
SettingsView()
.tabItem {
Text("Settings")
Image(systemName: "gear") }
.tag(NavigationItem.settings)
}
.onReceive(navigationManager.selection) { newValue in
selection = newValue
}
}
}

Dismiss a View in SwiftUI when parent is re-rendered

Using iOS14.4, Swift5.3.2, XCode12.2,
I try to dismiss a SwiftUI's GridView (see below code).
The dismiss function is done by the \.presentationMode property of the #Environment as explained here.
Everything works until the moment where I introduced a #Binding property that mutates the parent-View at the very moment of the dismissal. (see dataStr = titles[idx] in code excerpt below).
I read that dismissal by \.presentationMode only works if the parent-View is not updated during the time the child-View is shown.
But I absolutely need to cause a mutation on the parent-View when the user taps on an element of the GridView at play here.
How can I re-write so that parent-View is updated AND dismissal of Child-View still work ?
struct GridView: View {
#Environment(\.presentationMode) private var presentationMode
#Binding var dataStr: String
#State private var titles = [String]()
let layout = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
ScrollView {
LazyVGrid(columns: layout, spacing: 10) {
ForEach(titles.indices, id: \.self) { idx in
VStack {
Text(titles[idx])
Image(titles[idx])
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: (UIScreen.main.bounds.width / 2) - 40)
}
.onTapGesture {
// WITHOUT THIS LINE OF CODE - EVERYTHING WORKS. WHY???????????????
dataStr = titles[idx]
self.presentationMode.wrappedValue.dismiss()
}
}
}
.padding()
}
}
}
As #jnpdx asked, here you can see the parent-View. Please find the GridView(dataStr: self.$dataStr) inside the .sheet() of the ToolBarItem()....
import SwiftUI
struct MainView: View {
#EnvironmentObject var mediaViewModel: MediaViewModel
#EnvironmentObject var commService: CommunicationService
#State private var dataStr = ""
#State private var connectionsLabel = ""
#State private var commumincationRole: THRole = .noMode
#State private var showingInfo = false
#State private var showingGrid = false
init() {
UINavigationBar.appearance().tintColor = UIColor(named: "title")
}
var body: some View {
NavigationView {
if mediaViewModel.mediaList.isEmpty {
LoadingAnimationView()
.navigationBarHidden(true)
.ignoresSafeArea()
} else {
if dataStr.isEmpty {
MainButtonView(dataStr: $dataStr,
commumincationRole: $commumincationRole,
connectionsLabel: $connectionsLabel
)
.navigationBarHidden(false)
.navigationTitle("Trihow Pocket")
.navigationBarColor(backgroundColor: UIColor(named: "btnInactive"), titleColor: UIColor(named: "title"))
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
showingInfo.toggle()
}) {
Image(systemName: "ellipsis")
}
.sheet(isPresented: $showingInfo) {
InfoView()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingGrid.toggle()
}) {
Image(systemName: "square.grid.3x3")
}
.sheet(isPresented: $showingGrid) {
// GRIDVIEW CALLING THE CHILD-VIEW IS HERE !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
GridView(dataStr: self.$dataStr)
}
}
}
} else {
let str = self.dataStr
#if os(iOS)
PageViewiOS(dataStr: self.$dataStr, commumincationRole: $commumincationRole)
.navigationBarHidden(true)
.onAppear() {
if commumincationRole == .moderatorMode {
commService.send(thCmd: THCmd(key: .tagID, sender: "", content: str))
}
}
.ignoresSafeArea()
#elseif os(macOS)
PageViewMacOS()
.ignoresSafeArea()
#endif
}
}
}
.onTHComm_PeerAction(service: commService) { (peers) in
let idsOrNames = peers.map { (peer) -> String in
if let id = peer.id {
return "\(id)"
} else if let name = peer.name {
return "\(name)"
} else {
return ""
}
}
connectionsLabel = "Connected devices: \n\(idsOrNames.lineFeedString)"
}
.onTHComm_ReceiveCmd(service: commService) { (thCmd) in
if (commumincationRole == .moderatorMode) || (commumincationRole == .discoveryMode) {
switch thCmd.key {
case .tagID:
dataStr = thCmd.content
case .closeID:
dataStr = ""
default:
break
}
}
}
.onTHComm_LastMessageLog(service: commService) { (log) in
print(log)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
.environmentObject(MediaViewModel())
.environmentObject(MultipeerConnectivityService())
}
}
With the help of #jnpdx, I found a workaround.
Wrap the binding-property (i.e. dataStr in my example) into a delayed block that executes after something like 50ms:
.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
dataStr = thumNames[idx]
}
self.presentationMode.wrappedValue.dismiss()
}
Of course, this workaround only works in my case, because I do no longer need to keep the Child-View open. There might be other situations where the Parent-View needs to be updated prior to closing the Child-View (i.e. here the update of dataStr can be done right at the closing moment of the Child-View).
I am still wondering how to deal with dismiss-problems for any case where the Child-View makes the Parent-View update prior to closing. These are situations where SwiftUI's dismiss function no longer work from then on. Any mutation of the Parent-View cause the Child-View to separate somehow and dismisss no longer works.
Any idea what to do in that case ?