#State willSet and didSet: depends on the way you change it - swiftui

why are willSet and didSet called while directly sets the variable and are not called when you change variable with += operator? The same problem with .toggle() for bool.
struct TestDidSet: View {
#State var usingWillSet = false
#State var text: String = ""{
willSet{
print("willSet")
}
didSet{
print("didSet")
}
}
var body: some View {
VStack{
Text(text)
Button(action: {
self.usingWillSet.toggle()
}){
Text(usingWillSet ? "willSet is active" : "willSet is not active")
}
Button(action: {
if self.usingWillSet{
let newValue = self.text
self.text = newValue + "a"
}else{
self.text += "a"
}
}){
Text("toggle from inside")
}
}
}
}
in this question it is said that its a known bug, but in that link I see this bug is marked as RESOLVED, but I still have this problem in Xcode 11.4 (11E146). So should we wait for fix or its not a bug?

Related

View Change in SwiftUi Using Enum

I have multiple views in a swift project I am trying to change Views My idea is to make an enum, and change state. I do not want to use navigationLinks.
Here is my code:
struct NightOutApp: App {
var body: some Scene {
WindowGroup {
ViewNavigator()
}
}
}
enum ViewState{
case LoginView
case UserProfileView
}
struct ViewNavigator: View {
var body: some View {
#State var ViewState = ViewState.LoginView
return Group{
switch ViewState{
case .LoginView:
LoginView()
case .UserProfileView:
UserProfileView()
}
}
}
}
I have a variable
#Binding var ViewState: ViewState at the top of the LoginView
some logic on the LoginView that would change ViewState from LoginView to UserProfileView:
self.ViewState = .UserProfileView
I tried using binding variables. this gave me a warning: Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.
Edit-
Here is what happens when I run it. I press a button to login, It takes me to this breakpoint. The code seems to process, but the view does not change.
Code
Ok I've edited my answer in an attempt to understand what you're looking for. It seems you are needing a login flow but I don't think I can understand your problem fully without seeing more of your code. Here is an example that you should be able to copy and paste and play around with while you figure out exactly what you're needing.
import SwiftUI
struct ViewNavigator: View {
#State var viewState = 0
#State var withEmail: String = ""
#State var withPassword: String = ""
#State var showAlert: Bool = false
#State var alertTitle: String = ""
#State var alertMessage: String = ""
var body: some View {
ZStack {
switch viewState {
case 0:
loginView
case 1:
profileView
default:
RoundedRectangle(cornerRadius: 25)
.foregroundColor(.red)
}
VStack {
Spacer()
bottomButton
}
.padding()
}
.alert(alertTitle, isPresented: $showAlert) { } message: {
Text(alertMessage)
}
}
func handleBottomButtonPressed() {
switch viewState {
case 0:
guard !withEmail.isEmpty else {
showAlert(title: "Wait!", message: "Your email is required.")
return
}
guard withPassword == "password" else {
showAlert(title: "Hold Up!", message: "Your password is incorrect.")
return
}
default:
break
}
if viewState == 0 {
viewState += 1
withPassword = ""
} else {
viewState -= 1
}
}
func showAlert(title: String, message: String) {
alertTitle = title
alertMessage = message
showAlert.toggle()
}
}
struct ViewNavigator_Previews: PreviewProvider {
static var previews: some View {
ViewNavigator()
}
}
extension ViewNavigator {
private var bottomButton: some View {
Button {
handleBottomButtonPressed()
} label: {
Text(viewState == 0 ? "LOGIN" : "LOGOUT")
.font(.headline)
.foregroundColor(.white)
.frame(height: 55)
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(15)
.shadow(radius: 10)
}
}
private var loginView: some View {
ZStack {
Color.green.ignoresSafeArea()
VStack {
Image(systemName: "1.square")
.font(.largeTitle)
Text("LOGIN")
.font(.largeTitle)
VStack {
TextField("email...", text: $withEmail)
.padding()
.background(Color(UIColor.systemGray5))
.cornerRadius(10)
.textInputAutocapitalization(.never)
SecureField("password...", text: $withPassword)
.padding()
.background(Color(UIColor.systemGray5))
.cornerRadius(10)
.textInputAutocapitalization(.never)
}
.padding()
}
}
}
private var profileView: some View {
ZStack {
Color.orange.ignoresSafeArea()
VStack {
Image(systemName: "2.square")
.font(.largeTitle)
Text("Profile View")
.font(.largeTitle)
}
}
}
}

SwiftUI - Passing bindings to sheet view

I need to pass some bindings to a sheet that can be written to. What I've come up with works but seems a really inefficient way of doing it.
I have recreated a very simplified version of my code to use as an example.
I have a custom LocationStruct...
struct LocationStruct {
var id = UUID()
var name: String
var location: CLLocationCoordinate2D?
var haveVisited = false
}
I then have the parent view that displays a number of the LocationStruct's - the origin, an array of waypoints and the destination...
struct ContentView: View {
#State var origin = LocationStruct(name: "Paris")
#State var waypoints = [LocationStruct(name: "Berlin"), LocationStruct(name: "Milan")]
#State var destination = LocationStruct(name: "Venice")
#State var selectedLocation: Int?
#State var showSheet = false
var body: some View {
VStack{
HStack{
Text("Origin:")
Spacer()
Text(origin.name)
}
.onTapGesture{
selectedLocation = 1000
showSheet = true
}
ForEach(waypoints.indices, id:\.self){ i in
HStack{
Text("Waypoint \(i + 1):")
Spacer()
Text(waypoints[i].name)
}
.onTapGesture{
selectedLocation = i
showSheet = true
}
}
HStack{
Text("Destination:")
Spacer()
Text(destination.name)
}
.onTapGesture{
selectedLocation = 2000
showSheet = true
}
}
.padding()
.sheet(isPresented: $showSheet){
LocationSheet(origin: $origin, waypoints: $waypoints, destination: $destination, selectedLocation: $selectedLocation)
}
}
}
I then need to read and write to the location object that was tapped on the ContentView. I'm setting selectedLocation value to 1000 or 2000 for origin and destination otherwise its set to the waypoint array index (waypoints are limited in number so will not reach 1000).
I'm having to repeating "if let selectedLocation = ..." in quite a few places. Is there a better way of doing this, maybe some sort of computed binding or something?
struct LocationSheet: View {
#Binding var origin: LocationStruct
#Binding var waypoints: [LocationStruct]
#Binding var destination: LocationStruct
#Binding var selectedLocation: Int?
var body: some View {
VStack{
if let selectedLocation = selectedLocation {
switch selectedLocation {
case 1000:
TextField("", text: $origin.name).textFieldStyle(.roundedBorder)
case 2000:
TextField("", text: $destination.name).textFieldStyle(.roundedBorder)
default:
TextField("", text: $waypoints[selectedLocation].name).textFieldStyle(.roundedBorder)
}
}
Button(action: { markAsVisted() }){
Text("Visited")
}
}
.padding()
}
func markAsVisted(){
if let selectedLocation = selectedLocation {
switch selectedLocation {
case 1000:
origin.haveVisited = true
case 2000:
destination.haveVisited = true
default:
waypoints[selectedLocation].haveVisited = true
}
}
}
}
Thanks in advance
The trick is to use a non-optional custom state struct that holds both isPresented and the data the sheet needs, which is only valid when the sheet is showing.
Apple provide an example of this in Data Essentials in SwiftUI WWDC 2020 at 4:18.
EditorConfig can maintain invariants on its properties and be tested
independently. And because EditorConfig is a value type, any change to
a property of EditorConfig, like its progress, is visible as a change
to EditorConfig itself.
In your case it would be done as follows:
struct LocationSheetConfig {
var selectedLocation: Int = 0 // this is usually a struct, e.g. Location, or an id, e.g. UUID.
var showSheet = false
mutating func selectLocation(id: Int){
selectedLocation = id
showSheet = true
}
// usually there is another mutating func for closing the sheet.
}
#State var config = LocationSheetConfig()
.onTapGesture{
config.selectLocation(id: 1000)
}
.sheet(isPresented: $config.showSheet){
LocationSheet(origin: $origin, waypoints: $waypoints, destination: $destination, config: $config)
}
Also I noticed your Location struct is missing Identifiable protocol conformance. And you must not use give indices to the ForEach View, it has to be an array of Identifiable structs, otherwise it'll crash when there is a change (because it cannot track indices, only IDs).
For your question, instead of binding a lots variables like that you can create an EnvironmentObject to store and just pass screen with only that EnvironmentObject will make your view be simpler.
Making an enum to store location type where you tap instead of making a number const like 1000, 2000 like that - this will make you code not clean and not easily extendable.
Code of enum and ObservableObject will be like this
enum LocationType {
case origin
case waypoint
case destination
}
struct LocationStruct {
var id = UUID()
var name: String
var location: CLLocationCoordinate2D?
var haveVisited = false
}
class Location : ObservableObject {
#Published var origin = LocationStruct(name: "Paris")
#Published var waypoints = [LocationStruct(name: "Berlin"), LocationStruct(name: "Milan")]
#Published var destination = LocationStruct(name: "Venice")
#Published var tapLocationType : LocationType = .origin
#Published var tapIndex : Int?
func didTapLocaiton(type: LocationType, tapIndex: Int? = nil) {
self.tapLocationType = type
self.tapIndex = tapIndex
}
func didVisit() {
switch tapLocationType {
case .origin:
origin.haveVisited = true
case .waypoint:
waypoints[tapIndex ?? 0].haveVisited = true
case .destination:
destination.haveVisited = true
}
}
func getName() -> String {
switch tapLocationType {
case .origin:
return origin.name
case .waypoint:
return waypoints[tapIndex ?? 0].name
case .destination:
return destination.name
}
}
}
And then, come to your view just take the variable from Location and appear and when you need to change any variables you should call from Location object. Will make your code easier to handle
struct LocationSheet: View {
#ObservedObject var location : Location
var body: some View {
VStack{
Text(location.getName()).textFieldStyle(.roundedBorder)
Button(action: { markAsVisted() }){
Text("Visited")
}
}
.padding()
}
func markAsVisted(){
location.didVisit()
}
}
struct ContentView: View {
// make location state for storing
#StateObject var location = Location()
#State var showSheet = false
var body: some View {
VStack{
HStack{
Text("Origin:")
Spacer()
Text(location.origin.name)
Spacer()
Text(verbatim: "Visit: \(location.origin.haveVisited)")
}
.onTapGesture{
location.didTapLocaiton(type: .origin)
showSheet = true
}
ForEach(location.waypoints.indices, id:\.self){ i in
HStack{
Text("Waypoint \(i + 1):")
Spacer()
Text(location.waypoints[i].name)
Spacer()
Text(verbatim: "Visit: \(location.waypoints[i].haveVisited)")
}
.onTapGesture{
location.didTapLocaiton(type: .waypoint, tapIndex: i)
showSheet = true
}
}
HStack{
Text("Destination:")
Spacer()
Text(location.destination.name)
Spacer()
Text(verbatim: "Visit: \(location.destination.haveVisited)")
}
.onTapGesture{
location.didTapLocaiton(type: .destination)
showSheet = true
}
}
.padding()
.sheet(isPresented: $showSheet){
LocationSheet(location: location)
}
}
}
The result

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
}
}
}

How to pass Data multiple Sheet in SwiftUI?

This code works very well for me. But I need to add 2 more .sheet in this page. When I try to other solutions list cell doesnt pass to data correctly. How do I improve this code for 3 sheet?
#State var selectedUser: User?
List...
UserCell(user: user)
.onTapGesture {
self.selectedUser = user
}
.sheet(item: self.$selectedUser) { user in
DetailView(user: user)
}
NavigationView has only one sheet per view, so data in sheet instead multiple sheets as in your list;
add one sheet to your view change data on tap, like in below code;
enum SheetType {
case preview
case edit
case yourAnyChoice
}
struct ContentView:View{
#State var selectedUser:String = ""
#State var showingDetail = false
#State var sheetType:SheetType = SheetType.preview
var body: some View {
List(userList){in user
HStack{
Button(action: {
self.selectedUser = user.name
self.sheetType = .preview
self.showingDetail.toggle()
}){
Text("name")
}
Button(action: {
self.selectedUser = user.name
self.sheetType = .edit
self.showingDetail.toggle()
}){
Text("edit")
}
Button(action: {
self.selectedUser = user.name
self.sheetType = .yourAnyChoice
self.showingDetail.toggle()
}){
Text("yourAction")
}
}
}
.sheet(isPresented: self.$showingDetail){
detailView(text:self.$selectedUser,type:self.$sheetType)
}
}
struct detailView:View {
#Binding var text:String
#Binding var type:SheetType
var body:some View{
if type == SheetType.preview{
Text(text)
}
if type == .edit {
yourEditingView() // as per your requirements
}
if type == SheetType.yourAnyChoice{
yourChoiceViews()
}
}

Conditionally present ActionSheet SwiftUI

I created an update sheet to inform my users about updates, but I don't want it to display every time I push an update because sometimes it's just bug fixes, so I created a constant to toggle the sheet. I'm calling the sheet below:
VStack {
Text(" ")
}
.sheet(isPresented: $isShowingAppStoreUpdateNotification) {
UpdatesView()
}
How can I conditionally check for the constant? This is what I tried:
if(generalConstants.shouldShowUpdateSheet) {
.sheet(isPresented: $isShowingAppStoreUpdateNotification) {
UpdatesView()
}
}
But I get this error: Cannot infer contextual base in reference to member 'sheet'
.sheet is an instance method VStack, so you can't do what you did - it's not a legal Swift syntax.
The simplest approach is to have the condition over the VStack view:
if(generalConstants.shouldShowUpdateSheet) {
VStack {
Text(" ")
}
.sheet(isPresented: $isShowingAppStoreUpdateNotification) {
UpdatesView()
}
} else {
VStack {
Text(" ")
}
}
but, of course, this isn't very DRY.
Instead, keep the logic of how the view behaves in the view model / state, and let the View just react to data changes. What I mean is, only set isShowingAppStoreUpdateNotification to true when all the conditions that you want are satisfied, and keep the view as-is
#State var isShowingAppStoreUpdateNotification = generalConstants.shouldShowUpdateSheet
var body: some View {
VStack {
Text(" ")
}
.sheet(isPresented: $isShowingAppStoreUpdateNotification) {
UpdatesView()
}
}
Here is my sample code.
struct ContentView: View {
#State private var showSheet = false
#State private var toggle = false {
didSet {
self.showSheet = toggle && sheet
}
}
#State private var sheet = false {
didSet {
self.showSheet = toggle && sheet
}
}
var body: some View {
VStack {
Toggle(isOn: $toggle) {
Text("Allow to show sheet")
}
Button(action: {
self.sheet.toggle()
}) {
Text("Show sheet")
}
}.sheet(isPresented: $showSheet, content: {
Text("Sheet")
})
}
}