SwiftUI allow Toggle only on certain conditions - swift

My app has paid features. I have a Toggle that the user can switch. If the user has not paid, then when the user tries to toggle the Toggle a sheet should be brought up (and the toggle should not be activated). If the user has paid, then the toggle can be toggled on and off without problems.
I can't understand how a simple struct (PaidFeature) can have access to an observable object (Model). How do I code that in SwiftUI?
class Model: ObservableObject {
#Published var hasUserPaid: Bool = false
}
struct PaidFeature {
var isEnabled = false
}
struct ContentView: View {
#State private var feature = PaidFeature()
#EnvironmentObject var model: Model
var body: some View {
Toggle(isOn: self.$feature.isEnabled) {
Text("Hello, World!")
}
}
}

Using Xcode 12, you can listen for changes of the toggle button using .onChange modifier, and whenever user toggles the button, you can toggle it back to the last state, and instead show a purchase this sheet if the user is not a premium user.
.onChange(self.feature.isEnabled) { isEnabled in
if isEnabled && shouldShowPurchaseSheet {
// toggle back if you want the button to go to the inactive state
// show the sheet
}
}

You could add a disabled modifier to the Toggle.
struct ContentView: View {
#State private var feature = PaidFeature()
#EnvironmentObject var model: Model
var body: some View {
Toggle(isOn: self.$feature.isEnabled) {
Text("Hello, World!")
}.disabled(!model.hasUserPaid)
}
}

I assume it should be as
var body: some View {
Toggle(isOn: self.$feature.isEnabled) {
Text("Hello, World!")
}
.disabled(!model.hasUserPaid) // << here !!
}
Update: demo of alternate variant with showing sheet. Tested with Xcode 12 / iOS 14
For simplicity all of demo all states are kept in view
struct DemoView: View {
#State private var feature = false
#State private var paid = false
#State private var showPurchase = false
var body: some View {
Toggle(isOn: $feature) {
Text("Hello, World!")
}
.allowsHitTesting(paid)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
if !self.paid {
self.showPurchase = true
}
})
.sheet(isPresented: $showPurchase) {
Text("Purchase this")
.onDisappear {
self.paid = true
}
}
}
}

Related

SwiftUI detect edit mode

I've returned to iOS development after a while and I'm rebuilding my Objective-C app from scratch in SwiftUI.
One of the things I want to do is use the default Edit Mode to allow entries in a List (backed by Core Data on CloudKit) to switch between a NavigationLink to a detail view and an edit view.
The main approach seems to be to handle it through a if statement that detects edit mode. The Apple documentation provides the following snippet for this approach on this developer page: https://developer.apple.com/documentation/swiftui/editmode
#Environment(\.editMode) private var editMode
#State private var name = "Maria Ruiz"
var body: some View {
Form {
if editMode?.wrappedValue.isEditing == true {
TextField("Name", text: $name)
} else {
Text(name)
}
}
.animation(nil, value: editMode?.wrappedValue)
.toolbar { // Assumes embedding this view in a NavigationView.
EditButton()
}
}
However, this does not work (I've embedded the snippet in a NavigationView as assumed).
Is this a bug in Xcode 13.4.1? iOS 15.5? Or am I doing something wrong?
Update1:
Based on Asperi's answer I came up with the following generic view to handle my situation:
import SwiftUI
struct EditableRow: View {
#if os(iOS)
#Environment(\.editMode) private var editMode
#endif
#State var rowView: AnyView
#State var detailView: AnyView
#State var editView: AnyView
var body: some View {
NavigationLink{
if(editMode?.wrappedValue.isEditing == true){
editView
}
else{
detailView
}
}label: {
rowView
}
}
}
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
VStack {
EditButton()
EditableRow(rowView: AnyView(Text("Row")), detailView: AnyView(Text("Detail")), editView: AnyView(Text("Edit")))
}
}
}
The preview works as expected, but this works partially in my real app. When I implement this the NavigationLink works when not in Edit Mode, but doesn't do anything when in Edit Mode. I also tried putting the whole NavigationLink in the if statement but that had the same result.
Any idea why this isn't working?
Update2:
Something happens when it's inside a List. When I change the preview to this is shows the behavior I'm getting:
struct EditableRow_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
List {
EditableRow(rowView: AnyView(GroupRow(title: "Title", subTitle: "Subtitle", type: GroupType.personal)), detailView: AnyView(EntryList()), editView: AnyView(Text("Edit")))
}
.navigationBarItems(trailing:
HStack{
#if os(iOS)
EditButton()
#endif
}
)
}
}
}
Update3:
Found this answer: SwiftUI - EditMode and PresentationMode Environment
This claims the default EditButton is broken, which seems to be true. Replacing the default button with a custom one works (be sure to add a withAnimation{} block to get all the behavior from the stock button.
But it still doesn't work for my NavigationLink...
Update4:
Ok, tried passing an "isEditing" Bool to the above View, not to depend on the Environment variable being available. This works as long as the View (a ForEach within a List in my case) isn't in "Editing Mode" whatever happens at that point breaks any NavigationLink it seems.
Update5:
Basically my conclusion is that the default Edit Mode is meant to edit the "List Object" as a whole enabling moving and deleting of rows. In this mode Apple feels that editing the rows themselves isn't something you'd want to do. I can see this perspective.
If, however, you still want to enable a NavigationLink from a row in Edit Mode, this answer should help:
How to make SwiftUI NavigationLink work in edit mode?
Asperi's answer does cover why the detection doesn't work. I did find that Edit Mode detection does work better when setting the edit mode manually and not using the default EditButton, see the answer above for details.
It is on same level so environment is not visible, because it is activated for sub-views.
A possible solution is to separate dependent part into standalone view, like
Form {
InternalView()
}
.toolbar {
EditButton()
}
Tested with Xcode 13.4 / iOS 15.5
Test module on GitHub
#Asperi's answer worked well for me. However I wanted to still be able to access the editMode in the same hierarchy. As a workaround I created the following:
Usage
struct ContentView: View {
#State
private var editMode: EditMode = .inactive
var body: some View {
NavigationView {
Form {
if editMode.isEditing == true {
Color.red
} else {
Color.blue
}
}
.editModeFix($editMode)
.toolbar {
EditButton()
}
}
}
}
Implementation
extension View {
func editModeFix(_ editMode: Binding<EditMode>) -> some View {
modifier(EditModeFixViewModifier(editMode: editMode))
}
}
private struct EditModeFixView: View {
#Environment(\.editMode)
private var editModeEnvironment
#Binding
var editMode: EditMode
var body: some View {
Color.clear
.onChange(of: editModeEnvironment?.wrappedValue) { editModeEnvironment in
if let editModeEnvironment = editModeEnvironment {
editMode = editModeEnvironment
}
}
.onChange(of: editMode) {
editModeEnvironment?.wrappedValue = $0
}
}
}
private struct EditModeFixViewModifier: ViewModifier {
#Binding
var editMode: EditMode
func body(content: Content) -> some View {
content
.overlay {
EditModeFixView(editMode: $editMode)
}
}
}
I've got it to work by using a .simultaneousGesture on the EditButton and playing with a #State wrapper.
struct EditingFix: View {
#Environment(\.editMode) var editMode
#State var showDeleteButton = false
var body: some View {
Text("hello")
.toolbar(content: {
if showDeleteButton {
ToolbarItem(placement: .navigationBarLeading, content: {
Label("Remove selected", systemImage: "trash")
.foregroundColor(.red)
})
}
ToolbarItem(placement: .navigationBarTrailing, content: {
EditButton()
.simultaneousGesture(TapGesture().onEnded({
showDeleteButton.toggle()
}))
})
})
.onChange(of: showDeleteButton, perform: { isEditing in
editMode?.wrappedValue = isEditing ? .active : .inactive
})
.animation(.default, value: editMode?.wrappedValue) // Restore the default smooth animation for list selection and others
}
I can definitly say that EditButton is not using the same EditMode environment as what we get when invoking #Environment(\.editMode) var editMode. So we have to do it all ourselves if we want to get the benefit of the EditButton. Mainly the localized Edit text that it displays in my case.
Alternatively
The above method led to some weird behavior where the EditButton editMode seemed to conflict in some situation with the #Environment(\.editMode) var editMode. I'd advise you use your own logic for editing using the reliable .environment(\.editMode, $editMode). This way you can do whatever you want with the binding that control editing.
struct EditingFix: View {
#State var editMode: EditMode = .inactive
#State var isEditing = false
var body: some View {
VStack {
if editMode.isEditing {
Text("Hello")
}
Text("World")
Button("Toggle hello", action: {
isEditing.toggle()
})
}
.environment(\.editMode, $editMode)
.onChange(of: isEditing, perform: { isEditing in
editMode = isEditing ? .active : .inactive
})
.animation(.default, value: editMode)
}
}

SwiftUI macOS Commands (menu bar) and View

Hi I am starting to learn SwiftUI and macOS development. I am using the SwiftUI life cycle. How do I call a function from the focused window from the menu bar.
Besides Apple documentation, I found this reference and am able to create menu items using Commands but I have no idea how to call a function from my view.
For example:
Suppose this is my App struct:
import SwiftUI
#main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}.commands {
CommandMenu("First menu") {
Button("Action!") {
// How do I call the views action function?
}
}
}
}
and this is my View:
struct ContentView: View {
public func action() {
print("It works")
}
var body: some View {
Text("Example")
}
}
I just typed the example code sorry if there are any typos but I hope you can get the idea.
Because Views in SwiftUI are transient, you can't hold a reference to a specific instance of ContentView to call a function on it. What you can do, though, is change part of your state that gets passed down to the content view.
For example:
#main
struct ExampleApp: App {
#StateObject var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView(appState: appState)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}.commands {
CommandMenu("First menu") {
Button("Action!") {
appState.textToDisplay = "\(Date())"
}
}
}
}
}
class AppState : ObservableObject {
#Published var textToDisplay = "(not clicked yet)"
}
struct ContentView: View {
#ObservedObject var appState : AppState
var body: some View {
Text(appState.textToDisplay)
}
}
Note that the .commands modifier goes on WindowGroup { }
In this example, AppState is an ObservableObject that holds some state of the app. It's passed through to ContentView using a parameter. You could also pass it via an Environment Object (https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-environmentobject-to-share-data-between-views)
When the menu item is clicked, it sets textToDisplay which is a #Published property on AppState. ContentView will get updated any time a #Published property of AppState gets updated.
This is the general idea of the pattern you'd use. If you have a use case that isn't covered by this pattern, let me know in the comments.
Updates, based on your comments:
import SwiftUI
import Combine
#main
struct ExampleApp: App {
#StateObject var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView(appState: appState)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}.commands {
CommandMenu("First menu") {
Button("Action!") {
appState.textToDisplay = "\(Date())"
}
Button("Change background color") {
appState.contentBackgroundColor = Color.green
}
Button("Toggle view") {
appState.viewShown.toggle()
}
Button("CustomCopy") {
appState.customCopy.send()
}
}
}
}
}
class AppState : ObservableObject {
#Published var textToDisplay = "(not clicked yet)"
#Published var contentBackgroundColor = Color.clear
#Published var viewShown = true
var customCopy = PassthroughSubject<Void,Never>()
}
class ViewModel : ObservableObject {
#Published var text = "The text I have here"
var cancellable : AnyCancellable?
func connect(withAppState appState: AppState) {
cancellable = appState.customCopy.sink(receiveValue: { _ in
print("Do custom copy based on my state: \(self.text) or call a function")
})
}
}
struct ContentView: View {
#ObservedObject var appState : AppState
#State var text = "The text I have here"
#StateObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text(appState.textToDisplay)
.background(appState.contentBackgroundColor)
if appState.viewShown {
Text("Shown?")
}
}
.onReceive(appState.$textToDisplay) { (newText) in
print("Got new text: \(newText)")
}
.onAppear {
viewModel.connect(withAppState: appState)
}
}
}
In my updates, you can see that I've addressed the question of the background color, showing hiding a view, and even getting a notification (via onReceive) when one of the #Published properties changes.
You can also see how I use a custom publisher (customCopy) to pass along an action to ContentView's ViewModel

#AppStorage property wrapper not able to be toggled with VoiceOver

I am trying to use a new #property wrapper in iOS 14 #AppStorage. It how ever doesn't seem to be able to be toggled with VoiceOver if I use it in a toggle. A normal #State private var property works fine. I can confirm that these work with VoiceOver not on.
Is this an accessibility issue that I can solve myself?
Is this a iOS14 bug that Apple needs to fix?
Code below:
import SwiftUI
import Foundation
final class Settings: ObservableObject {
#AppStorage("sounds") var sounds: Bool = true
init() {}
}
struct SettingsView: View {
#ObservedObject var settings: Settings
#State private var sounds: Bool = true
var body: some View {
NavigationView {
List {
VStack {
Toggle("AppStore toggle", isOn: $settings.sounds)
Toggle("State toggle", isOn: $sounds)
}
} .navigationBarTitle("Settings", displayMode: .inline)
// end list
} // end NavigationView
} // end body
} // end SettingsView
It must be in view
struct SettingsView: View {
#AppStorage("sounds") var sounds: Bool = true // << here !!
var body: some View {
NavigationView {
List {
VStack {
Toggle("AppStore toggle", isOn: $sounds)
// ...

Is there a way to change views based off of Environment Variables in SwiftUI?

I want to be able to change a view in SwiftUI with the tap of a button. I have buttons setup to toggle the environmental variables as follows
struct SettingsButton: View {
#EnvironmentObject var settings: UserSettings
var body: some View {
VStack {
Button(action: { self.settings.settingsView.toggle() }) {
Image(systemName: "gear")
.font(Font.system(size: 25))
.frame(width: 25, height: 25)
.foregroundColor(.primary)
}
}
.offset(x: 180, y: -372)}
}
I've also declared the Observable object here
import Foundation
import GoogleSignIn
class UserSettings: ObservableObject {
#Published var studentID = ""
#Published var givenName = ""
#Published var settingsView = false
#Published var profileView = false
#Published var isLogged = GIDSignIn.sharedInstance()?.currentUser
}
And finally I have a ViewBuilder setup in the view that is loaded on start to listen for a change in the variable and to switch views accordingly, however when the app is loaded and the button is tapped the app freezes and remains unresponsive.
struct Login: View {
#EnvironmentObject var settings: UserSettings
#ViewBuilder var body : some View {
if settings.isLogged != nil {
MainView()
}
else {
LoginPage()
}
if settings.settingsView {
SettingsView()
}
}
}
I would like to know if there is any known way to attempt this without the use of .sheet or Navigation Links any help with be very much appreciated!
Without seeing your MainView(), LoginPage() and SettingsView() I think you should be doing something like this in your Login() view:
I added VStack around your views:
struct Login: View {
#EnvironmentObject var settings: UserSettings
#ViewBuilder var body: some View {
VStack {
if settings.isLogged != nil {
MainView()
} else {
LoginPage()
}
if settings.settingsView {
SettingsView()
}
}
}
}
Also ensure that you have the following in your SceneDelegate since your UserSettings() is defined as an EnvironmentObject:
// Create the SwiftUI view that provides the window contents.
let contentView = Login()
.environmentObject(UserSettings())

Modifying a #State var from a #Binding var isn't refreshing the view in SwiftUI

So I have a ParentView which contains a FilterBar and a List. It looks something like this:
struct ParentView: View {
#State var listCellModels: [ListCellModel]
// Both these vars are passed to the FilterBar and adjusted by the FilterBar
#State var isEditing: Bool = false
#State var selectedType: FilterType = .none
// When selected type is changed, I need to reload the models in the list with the new filter
private var filteredModels: [ListCellModel] {
return listCellModels.filter{
(selectedType.rawValue == 0 || $0.approved.rawValue == selectedType.rawValue)
}
}
var body: some View {
VStack {
FilterBar(isEditing: $isEditing, selectedType: $selectedType)
// All the items in the list are buttons that display a custom view I had
// this works fine, and when isEditing is changed the view DOES update
List(filteredModels) { model in
Button(action: {
// Does a thing
}, label: {
ListViewCell(model: model, isEditing: self.$isEditing)
})
}
}
}
}
My Filter bar is just a simple HStack with a couple buttons that modify the variables
struct FilterBar: View {
#Binding var isEditing: Bool
#Binding var selectedType: FilterType
var body: some View {
HStack(alignment: .center) {
Button(action: {
self.selectedType = FilterType.init(rawValue: (self.selectedType.rawValue + 1) % 4)!
}, label: {
Text("Filter: \(selectedType.name)")
}).padding(.top).padding(.leading)
Spacer()
Button(action: {
self.isEditing = !self.isEditing
}, label: {
Text(!isEditing ? "Edit" : "Done")
}).padding(.top).padding(.trailing)
}
}
}
When I tap the button that changes isEditing, all of the cells in the list update to show their "Editing" states, but when i tap the button to change selectedType, the variable in the parent view does get updated, as I've observed in the debugger - however the view does not reload. So it appears as if the old filter is still being applied.
Is there any reason why updating this #State var is not reloading the view?
Are there any workarounds?
Well, it is like... workaround... but for testing, try
FilterBar(isEditing: $isEditing, selectedType: $selectedType)
if selectedType != .none {
EmptyView()
}
In general, it would be correct to introduce view model as ObservableObject and have filteredModels in it as #Published, so your FilterBar changed that property, which will automatically refreshed the ParentView.