SwiftUI set state variables through another view instance - swiftui

In SwiftUI I've created a struct that should create different overlay views depending on some state variables. If any of the state booleans is true, then it should return custom view (either ErrorOverlay or LoadingOverlay or else an EmptyView) like this:
struct OverlayContainer: View {
#State var isLoading: Bool = false
#State var isErrorShown: Bool = false
func setIsLoading(isLoading: Bool) {
self.isLoading = isLoading
}
func setIsErrorShown(isErrorShown: Bool) {
self.isErrorShown = isErrorShown
}
var body: some View {
Group {
if(isErrorShown) {
ErrorOverlay()
}
else if(isLoading) {
LoadingOverlay()
}
else {
EmptyView()
}
}
}
}
Now I've implemented the overlay on some content in the Home view with buttons that should change the state and show the correct overlay, like this:
struct Home: View {
var body: some View {
let overlayContainer = OverlayContainer()
return HStack {
// Some more content here
Button(action: {
overlayContainer.setIsLoading(isLoading: true)
}) {
Text("Start loading")
}
Button(action: {
overlayContainer.setIsErrorShown(isErrorShown: true)
}) {
Text("Show error")
}
}.overlay(overlayContainer)
}
}
This isn't working: when I click the button nothing happens. Why and how to solve this? (without using binding, see below)
ps. I've been able to get a working solution by doing the following:
extracting the state booleans to the Home view
pass these through the constructor of the OverlayContainer
change the state booleans instead of calling the set methods when clicking the buttons
change the OverlayContainer so it implements an init method with both booleans
change the state booleans in the OverlayContainer to bindings.
However, I'd like to implement the states in the OverlayContainer to be able to re-use that in different screens, without implementing state variables in all of these screens. Firstly because there will probably be more cases than just these 2. Secondly because not all screens will need to access all states and I haven't found out a simple way to implement optional bindings through the init method.
To me it feels that all these states belong to the OverlayContainer, and changing the state should be as short and clean as possible. Defining states everywhere feels like code duplication. Maybe I need a completely different architecture?

It should be used Binding instead. Here is possible solution.
struct OverlayContainer: View {
#Binding var isLoading: Bool
#Binding var isErrorShown: Bool
var body: some View {
Group {
if(isErrorShown) {
ErrorOverlay()
}
else if(isLoading) {
LoadingOverlay()
}
else {
EmptyView()
}
}
}
}
struct Home: View {
#State var isLoading: Bool = false
#State var isErrorShown: Bool = false
var body: some View {
HStack {
// Some more content here
Button(action: {
self.isLoading = true
}) {
Text("Start loading")
}
Button(action: {
self.isErrorShown = true
}) {
Text("Show error")
}
}.overlay(OverlayContainer(isLoading: $isLoading, isErrorShown: $isErrorShown))
}
}

To make it the way you want, use Binding:
struct OverlayContainer: View {
#Binding var isLoading: Bool
#Binding var isErrorShown: Bool
func setIsLoading(isLoading: Bool) {
self.isLoading = isLoading
self.isErrorShown = !isLoading
}
func setIsErrorShown(isErrorShown: Bool) {
self.isErrorShown = isErrorShown
self.isLoading = !isErrorShown
}
var body: some View {
Group {
if(isErrorShown) {
ErrorOverlay()
}
else if(isLoading) {
LoadingOverlay()
}
else {
EmptyView()
}
}
}
}
struct Home: View {
#State var isLoading = false
#State var isErrorShown = false
var body: some View {
let overlayContainer = OverlayContainer(isLoading: $isLoading, isErrorShown: $isErrorShown)
return HStack {
// Some more content here
Button(action: {
overlayContainer.setIsLoading(isLoading: true)
}) {
Text("Start loading")
}
Button(action: {
overlayContainer.setIsErrorShown(isErrorShown: true)
}) {
Text("Show error")
}
}.overlay(overlayContainer)
}
}

Related

SwiftUI: Replacing window dismisses only topmost modal view

I need to show a login screen when the user session is expired. I tried to achieve this by changing the current window:
#main
struct ResetViewHierarchyApp: App {
#StateObject private var state = appState
var body: some Scene {
WindowGroup {
if state.isLoggedIn {
ContentView()
} else {
LogInView()
}
}
}
}
When no modal views are presented then it works fine. If only one modal view is presented, it also works, the modal view is dismissed. But if there are more than one modal views are presented, then the root view is replaced, but only the topmost modal view is dismissed. Here is ContentView:
struct ContentView: View {
#State private var isPresentingSheet1 = false
#State private var isPresentingSheet2 = false
var body: some View {
Text("Hello, world!")
.padding()
Button(action: {
isPresentingSheet1 = true
}, label: {
Text("Present Sheet")
.padding()
}).sheet(isPresented: $isPresentingSheet1) {
sheetView1
}
}
}
private extension ContentView {
var sheetView1: some View {
VStack {
Text("Sheet 1")
.padding()
Button(action: {
isPresentingSheet2 = true
}, label: {
Text("Present Sheet")
.padding()
}).sheet(isPresented: $isPresentingSheet2) {
sheetView2
}
}
}
var sheetView2: some View {
VStack {
Text("Sheet 2")
.padding()
Button(action: {
appState.isLoggedIn = false
}, label: {
Text("Log Out")
.padding()
})
}
}
}
The same happens if I use fullScreenCover instead of sheet.
Does anybody know how to solve this issue, to dismiss all the presented modals at once?
I've solved this issue with UIKit windows:
#StateObject private var state = appState
#State private var contentWindow: UIWindow?
var body: some Scene {
WindowGroup {
EmptyView()
.onAppear {
updateContentWindow(isLoggedIn: state.isLoggedIn)
}.onReceive(state.$isLoggedIn) { isLoggedIn in
updateContentWindow(isLoggedIn: isLoggedIn)
}
}
}
var window: UIWindow? {
guard let scene = UIApplication.shared.connectedScenes.first,
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
let window = windowSceneDelegate.window else {
return nil
}
return window
}
func updateContentWindow(isLoggedIn: Bool) {
contentWindow?.isHidden = true
contentWindow = nil
if let windowScene = window?.windowScene {
contentWindow = UIWindow(windowScene: windowScene)
contentWindow?.windowLevel = UIWindow.Level.normal
if isLoggedIn {
contentWindow?.rootViewController = UIHostingController(rootView: ContentView())
} else {
contentWindow?.rootViewController = UIHostingController(rootView: LogInView())
}
contentWindow?.makeKeyAndVisible()
}
}
It is indeed a strange bug.. however I found a workaround for it.
You can keep your States of the modal View inside your Observable / Environment Object. When logging out, you have to make sure to close all your sheets.
Here is a example:
First adding showSheet as Published Value in the AppState
class AppState : ObservableObject {
#Published var isLoggedIn : Bool = true
#Published var showSheet1 : Bool = false
#Published var showSheet2 : Bool = false
}
When logging out, turn all your sheets to false.
Button(action: {
self.state.isLoggedIn = false
self.state.showSheet1 = false
self.state.showSheet2 = false
}, label: {
Text("Log Out")
.padding()
})
Of course you have to use these values in your Button for toggling sheet and in your sheet.
.sheet(isPresented: $state.showSheet2) {
Edit:
Even simpler, you don't have to manually set it to false in the LogOut action. Instead do it all in the appState
#Published var isLoggedIn : Bool = true {
willSet {
if newValue == false {
showSheet1 = false
showSheet2 = false
}
}
}

SwiftUI: How to combine .toggle() not Toggle(isOn..) with UserDefault

Currently I have
#Published var isVisited: Bool {
didSet{
UserDefaults.standard.set(isVisited,forKey: "isVisited")
}
}
init() {
self.isVisited = UserDefaults.standard.object(forKey: "isVisited") as? Bool ?? false
}
#State private var edit = false
#ObservedObject var userSettings = UserSettings()
Button(action: { self.edit.toggle()}) {
if self.edit {
Image(systemName: "heart.fill").font(.title)
} else {
Image(systemName: "heart").font(.title)
}
}.foregroundColor(.red)
Ideally, whenever the "heart" icon is tapped, it will be filled and save data to UserDefault database. I only know the uses of Toggle(isOn:..).
Any suggestions would be much appreciated!
Do you mean something like this?
class UserSettings: ObservableObject {
init() {
isVisited = UserDefaults.standard.bool(forKey: "isVisited")
}
#Published var isVisited: Bool {
didSet {
UserDefaults.standard.set(isVisited, forKey: "isVisited")
}
}
}
struct RootView: View {
#ObservedObject var userSettings = UserSettings()
var body: some View {
Button(action: { self.userSettings.isVisited.toggle() }) {
if self.userSettings.isVisited {
Image(systemName: "heart.fill").font(.title)
} else {
Image(systemName: "heart").font(.title)
}
}.foregroundColor(.red)
}
}
What I did here is that every time UserSettings loads I load isVisited from UserDefaults and then whenever that property is set it updates UserDefaults.
Then on the view instead of using #State to hold the data I'm getting the data directly from UserSettings, since it will have the most updated data.

SwifUI onAppear gets called twice

Q1: Why are onAppears called twice?
Q2: Alternatively, where can I make my network call?
I have placed onAppears at a few different place in my code and they are all called twice. Ultimately, I'm trying to make a network call before displaying the next view so if you know of a way to do that without using onAppear, I'm all ears.
I have also tried to place and remove a ForEach inside my Lists and it doesn't change anything.
Xcode 12 Beta 3 -> Target iOs 14
CoreData enabled but not used yet
struct ChannelListView: View {
#EnvironmentObject var channelStore: ChannelStore
#State private var searchText = ""
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List() {
ForEach(channelStore.allChannels) { channel in
NavigationLink(destination: VideoListView(channel: channel)
.onAppear(perform: {
print("PREVIOUS VIEW ON APPEAR")
})) {
ChannelRowView(channel: channel)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
struct VideoListView: View {
#EnvironmentObject var videoStore: VideoStore
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var channel: Channel
var body: some View {
List(videoStore.allVideos) { video in
VideoRowView(video: video)
}
.onAppear(perform: {
print("LIST ON APPEAR")
})
.navigationTitle("Videos")
.navigationBarItems(trailing: Button(action: {
networking.getTopVideos(channelID: channel.channelId) { (videos) in
var videoIdArray = [String]()
videoStore.allVideos = videos
for video in videoStore.allVideos {
videoIdArray.append(video.videoID)
}
for (index, var video) in videoStore.allVideos.enumerated() {
networking.getViewCount(videoID: videoIdArray[index]) { (viewCount) in
video.viewCount = viewCount
videoStore.allVideos[index] = video
networking.setVideoThumbnail(video: video) { (image) in
video.thumbnailImage = image
videoStore.allVideos[index] = video
}
}
}
}
}) {
Text("Button")
})
.onAppear(perform: {
print("BOTTOM ON APPEAR")
})
}
}
I had the same exact issue.
What I did was the following:
struct ContentView: View {
#State var didAppear = false
#State var appearCount = 0
var body: some View {
Text("Appeared Count: \(appearrCount)"
.onAppear(perform: onLoad)
}
func onLoad() {
if !didAppear {
appearCount += 1
//This is where I loaded my coreData information into normal arrays
}
didAppear = true
}
}
This solves it by making sure only what's inside the the if conditional inside of onLoad() will run once.
Update: Someone on the Apple Developer forums has filed a ticket and Apple is aware of the issue. My solution is a temporary hack until Apple addresses the problem.
I've been using something like this
import SwiftUI
struct OnFirstAppearModifier: ViewModifier {
let perform:() -> Void
#State private var firstTime: Bool = true
func body(content: Content) -> some View {
content
.onAppear{
if firstTime{
firstTime = false
self.perform()
}
}
}
}
extension View {
func onFirstAppear( perform: #escaping () -> Void ) -> some View {
return self.modifier(OnFirstAppearModifier(perform: perform))
}
}
and I use it instead of .onAppear()
.onFirstAppear{
self.vm.fetchData()
}
you can create a bool variable to check if first appear
struct VideoListView: View {
#State var firstAppear: Bool = true
var body: some View {
List {
Text("")
}
.onAppear(perform: {
if !self.firstAppear { return }
print("BOTTOM ON APPEAR")
self.firstAppear = false
})
}
}
Let us assume you are now designing a SwiftUI and your PM is also a physicist and philosopher. One day he tells you we should to unify UIView and UIViewController, like Quantum Mechanics and the Theory of Relativity. OK, you are like-minded with your leader, voting for "Simplicity is Tao", and create an atom named "View". Now you say: "View is everything, view is all". That sounds awesome and seems feasible. Well, you commit the code and tell the PM….
onAppear and onDisAppear exists in every view, but what you really need is a Page lifecycle callback. If you use onAppear like viewDidAppear, then you get two problems:
Being influenced by the parent, the child view will rebuild more than one time, causing onAppear to be called many times.
SwiftUI is closed source, but you should know this: view = f(view). So, onAppear will run to return a new View, which is why onAppear is called twice.
I want to tell you onAppear is right! You MUST CHANGE YOUR IDEAS. Don’t run lifecycle code in onAppear and onDisAppear! You should run that code in the "Behavior area". For example, in a button navigating to a new page.
You can create the first appear function for this bug
extension View {
/// Fix the SwiftUI bug for onAppear twice in subviews
/// - Parameters:
/// - perform: perform the action when appear
func onFirstAppear(perform: #escaping () -> Void) -> some View {
let kAppearAction = "appear_action"
let queue = OperationQueue.main
let delayOperation = BlockOperation {
Thread.sleep(forTimeInterval: 0.001)
}
let appearOperation = BlockOperation {
perform()
}
appearOperation.name = kAppearAction
appearOperation.addDependency(delayOperation)
return onAppear {
if !delayOperation.isFinished, !delayOperation.isExecuting {
queue.addOperation(delayOperation)
}
if !appearOperation.isFinished, !appearOperation.isExecuting {
queue.addOperation(appearOperation)
}
}
.onDisappear {
queue.operations
.first { $0.name == kAppearAction }?
.cancel()
}
}
}
For everyone still having this issue and using a NavigationView. Add this line to the root NavigationView() and it should fix the problem.
.navigationViewStyle(StackNavigationViewStyle())
From everything I have tried, this is the only thing that worked.
We don't have to do it on .onAppear(perform)
This can be done on init of View
In case someone else is in my boat, here is how I solved it for now:
struct ChannelListView: View {
#State private var searchText = ""
#State private var isNavLinkActive: Bool = false
#EnvironmentObject var channelStore: ChannelStore
#ObservedObject private var networking = Networking()
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText)
.padding(.top, 20)
List(channelStore.allChannels) { channel in
ZStack {
NavigationLink(destination: VideoListView(channel: channel)) {
ChannelRowView(channel: channel)
}
HStack {
Spacer()
Button {
isNavLinkActive = true
// Place action/network call here
} label: {
Image(systemName: "arrow.right")
}
.foregroundColor(.gray)
}
}
.listStyle(GroupedListStyle())
}
.navigationTitle("Channels")
}
}
}
}
I've got this app:
#main
struct StoriesApp: App {
var body: some Scene {
WindowGroup {
TabView {
NavigationView {
StoriesView()
}
}
}
}
}
And here is my StoriesView:
// ISSUE
struct StoriesView: View {
#State var items: [Int] = []
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
///////////////////////////////////
// Gets called 2 times on app start <--------
///////////////////////////////////
}
}
I've resolved the issue by measuring the diff time between onAppear() calls. According to my observations double calls of onAppear() happen between 0.02 and 0.45 seconds:
// SOLUTION
struct StoriesView: View {
#State var items: [Int] = []
#State private var didAppearTimeInterval: TimeInterval = 0
var body: some View {
List {
ForEach(items, id: \.self) { id in
StoryCellView(id: id)
}
}
.onAppear(perform: onAppear)
}
private func onAppear() {
if Date().timeIntervalSince1970 - didAppearTimeInterval > 0.5 {
///////////////////////////////////////
// Gets called only once in 0.5 seconds <-----------
///////////////////////////////////////
}
didAppearTimeInterval = Date().timeIntervalSince1970
}
}
In my case, I found that a few views up the hierarchy, .onAppear() (and .onDisappear()) was only being called once, as expected. I used that to post notifications that I listen to down in the views that need to take action on those events. It’s a gross hack, and I’ve verified that the bug is fixed in iOS 15b1, but Apple really needs to backport the fix.

How to force List to redraw by another View's toggle Button?

I fetched JSON data from Google Sheet and populate into a List using ForEach. I used struct HeaderView located in another View and place a Button to serve as a toggle. However, the List will not redraw when I press the toggle button even I use #State ascd variable.
Below is some of my code, is there anything I miss?
struct HeaderView: View {
// #State var asc: Bool = true
var holding: String = "持倉"
var earning: String = "賺蝕"
// #State var tog_value: Bool = ContentView().ascd
var body: some View {
HStack {
Button(action: {
ContentView().ascd.toggle()
}
) {
Text("Button")
}
Text(holding)
Text(earning)
}
}
}
struct ContentView: View {
#ObservedObject var viewModel = ContentViewModel()
#ObservedObject var viewModelTotal = ContentViewModelTotal()
#State var ascd: Bool = false
var totalss = ContentViewModelTotal.fetchDatasTotal
var body: some View {
List {
Section(header: HeaderView()) {
ForEach(viewModel.rows, id: \.stockname) { rows in
// Text(user.stock_name)
ListRow(name: rows.stockname, code: rows.stockcode, cur_price: rows.currentprice, mkt_value: rows.marketvalue, amnt: rows.amount, avg_cost: rows.averagecost, pft: rows.profit, pft_pcnt: rows.profitpercent)
}
}
.onAppear {
self.viewModel.fetchDatas()
self.ascd.toggle()
if self.ascd {
self.viewModel.rows.sort { $0.stockname < $1.stockname }
} else {
self.viewModel.rows.sort { $0.stockname > $1.stockname }
}
}
}
}
}
For changing another View's variable you can use a #Binding variable:
struct HeaderView: View {
...
#Binding var ascd: Bool
var body: some View {
HStack {
Button(action: {
self.ascd.toggle()
}) {
Text("Button")
}
Text(holding)
Text(earning)
}
}
}
I'd recommend moving sorting logic to your ViewModel.
class ContentViewModel: ObservableObject {
#Published var ascd: Bool = false {
didSet {
if ascd {
rows.sort { $0.hashValue < $1.hashValue }
} else {
rows.sort { $0.hashValue > $1.hashValue }
}
}
}
...
}
If it's in the .onAppear in the ContentView it will be executed only when your View is shown on the screen.
And you will have to initialise your HeaderView with your ViewModel's ascd variable:
HeaderView(ascd: $viewModel.ascd)

SwiftUI - depend on multiple conditions

Is it possible to depend on multiple conditions in SwiftUI? For example to show a sheet:
.sheet(isPresented: $stateA && $stateB, content: { ... }) // this is not working
Or is a different approach known?
no, it is not possible! isPresented accept Binding, that means the state is updated if sheet will be dismissed. Which of stateA, stateB have to be changed? or both of them? Even though someone will try to define && operator where left and right side is Binding, that is very bad idea. Don't try to do it!
Move the logic to your model, better outside of any View.
UPDATE (for Asperi)
this is valid code (with your extension)
struct ContentView: View {
#State private var isFirst = true
#State private var isSecond = false
var body: some View {
VStack {
Button("TestIt") {
self.isSecond = true
}
.sheet(isPresented: $isFirst && $isSecond) {
Text("A")
}
}
}
}
Try it! Pressing TestIt will open the sheet. There is no Button to "go back", but you can dismiss it with well known gesture. And try to press TestIt again ...
"I can only show you the door..." (c) Morpheus
Today is a day of overloaded operators :^) - previous was here, here is for your case (tested with Xcode 11.3+)
extension Binding where Value == Bool {
static func &&(_ lhs: Binding<Bool>, _ rhs: Binding<Bool>) -> Binding<Bool> {
return Binding<Bool>( get: { lhs.wrappedValue && rhs.wrappedValue },
set: {_ in })
}
}
struct TestCustomBinding: View {
#State private var isFirst = true
#State private var isSecond = false
var body: some View {
VStack {
Button("TestIt") {
self.isSecond = true
}
.sheet(isPresented: $isFirst && $isSecond) {
Button("CloseMe") {
// sheet MUST be closed explicitly via one of states !
self.isSecond = false
}
}
}
}
}
It is possible to get different conditions from a variable.
struct ChangingButton: View {
var text: String
var onButton: String
var offButton: String
var changeButton: Bool
var buttonCondition: String {
if isOn {
return isOnImage
} else {
return isOffImage
}
}
var body: some View {
Button(action: {
action()
}
, label: {
VStack {
Image(systemName: buttonCondition)
Text(text)
}
})
}
}
struct ChangingButton_Previews: PreviewProvider {
static var previews: some View {
ChangingButton(text: "My Button", onButton: "on", offButton: "off", changeButton: true, action: {
}).background(Color.black)
}