SwiftUI: Fire an event when a toggle is switched - swift

I need to post notification when the state of a toggle changes. I couldn't find a way to specify an action for a toggle. Any idea how I can do that?
var body: some View {
List {
ForEach(items.indices) { index in
Section(header: Text(self.items[index].label)) {
Toggle(isOn: self.$items[index].isOn) {
Text("Enabled")
}
}
}
}
.listStyle(GroupedListStyle())
}
But then what??

In general, you don't want your view to be in charge of executing code when it changes, because your view is not the source of truth - it merely responds to changes in your source of truth.
In this case, what you want is a view model that is in charge of keeping your view's state. When it changes, your view reacts. Then you can have that view model execute code when one of its properties changes (using didSet(), for example).
struct ContentView: View {
#ObservedObject var model = ListModel()
var body: some View {
List {
ForEach(0..<model.sections.count, id: \.self) { index in
Section(header: Text(self.model.sections[index].label as String)) {
Toggle(isOn: self.$model.sections[index].enabled) {
Text("Enabled")
}
}
}
}
.listStyle(GroupedListStyle())
}
}
class ListModel: ObservableObject {
#Published var sections: [ListSection] = [
ListSection(label: "Section One"),
ListSection(label: "Section Two"),
ListSection(label: "Section Three")
]
}
struct ListSection {
var label: String
var enabled: Bool = false {
didSet {
// Here's where any code goes that needs to run when a switch is toggled
print("\(label) is \(enabled ? "enabled" : "disabled")")
}
}
}

Related

TextField in a list not working well in SwiftUI

This problem is with SwiftUI for a iPhone 12 app, Using xcode 13.1.
I build a List with TextField in each row, but every time i try to edit the contents, it is only allow me tap one time and enter only one character then can not keep enter characters anymore, unless i tap again then enter another one character.Did i write something code wrong with it?
class PieChartViewModel: ObservableObject, Identifiable {
#Published var options = ["How are you", "你好", "Let's go to zoo", "OKKKKK", "什麼情況??", "yesssss", "二百五", "明天見"]
}
struct OptionsView: View {
#ObservedObject var viewModel: PieChartViewModel
var body: some View {
NavigationView {
List {
ForEach($viewModel.options, id: \.self) { $option in
TextField(option, text: $option)
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
func addNewOption() {
viewModel.options.insert("", at: viewModel.options.count)
}
}
struct OptionsView_Previews: PreviewProvider {
static var previews: some View {
let pieChart = PieChartViewModel()
OptionsView(viewModel: pieChart)
}
}
Welcome to StackOverflow! Your issue is that you are directly updating an ObservableObject in the TextField. Every change you make to the model, causes a redraw of your view, which, of course, kicks your focus from the TextField. The easiest answer is to implement your own Binding on the TextField. That will cause the model to update, without constantly redrawing your view:
struct OptionsView: View {
// You should be using #StateObject instead of #ObservedObject, but either should work.
#StateObject var model = PieChartViewModel()
#State var newText = ""
var body: some View {
NavigationView {
VStack {
List {
ForEach(model.options, id: \.self) { option in
Text(option)
}
}
List {
//Using Array(zip()) allows you to sort by the element, but use the index.
//This matters if you are rearranging or deleting the elements in a list.
ForEach(Array(zip(model.options, model.options.indices)), id: \.0) { option, index in
// Binding implemented here.
TextField(option, text: Binding<String>(
get: {
model.options[index]
},
set: { newValue in
//You can't update the model here because you will get the same behavior
//that you were getting in the first place.
newText = newValue
}))
.onSubmit {
//The model is updated here.
model.options[index] = newText
newText = ""
}
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
}
func addNewOption() {
model.options.insert("", at: model.options.count)
}
}

NavigationLink with isActive creates unusual scrolling behavior in macOS SwiftUI app

I have a macOS app with a 3-column view. In the first column, there is a List of items that is lengthy -- perhaps a couple hundred items.
If my NavigationLink for each item contains a isActive parameter, when clicking on the NavigationLinks, I get unpredictable/unwanted scrolling behavior on the list.
For example, if I scroll down to Item 100 and click on it, the List may decide to scroll back up to Item 35 or so (where the active NavigationLink is out of the frame). The behavior seems somewhat non-deterministic -- it doesn't always scroll to the same place. It seems less likely to scroll to an odd location if I scroll through the list to my desired item and then wait for the system scroll bars to disappear before clicking on the NavigationLink, but it doesn't make the problem disappear completely.
If I remove the isActive parameter, the scroll position is maintained when clicking on the NavigationLinks.
struct ContentView : View {
var body: some View {
NavigationView {
SidebarList()
Text("No selection")
Text("No selection")
.frame(minWidth: 300)
}
}
}
struct Item : Identifiable, Hashable {
let id = UUID()
var name : String
}
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue { activeItem = item }
}
}
var body: some View {
List(items) { item in
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item) //<-- Remove this line and everything works as expected
) {
Text(item.name)
}
}.listStyle(SidebarListStyle())
}
}
struct InnerSidebar : View {
#State private var items = Array(0...100).map { Item(name: "Inner item \($0)") }
var body: some View {
List(items) { item in
NavigationLink(destination: Text("Detail")) {
Text(item.name)
}
}
}
}
I would like to keep isActive, as I have some programatic navigation that I'd like to be able to do that depends on it. For example:
Button("Go to item 10") {
activeItem = items[10]
}
Is there any way to use isActive on the NavigationLink and avoid the unpredictable scrolling behavior?
(Built and tested with macOS 11.3 and Xcode 13.0)
The observed effect is because body of your main view is refreshed and all internals rebuilt. To avoid this we can separate sensitive part of view hierarchy into standalone view, so SwiftUI engine see that dependency not changed and view should not be updated.
Here is a fixed parts:
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
var body: some View {
List(items) {
SidebarRowView(item: $0, activeItem: $activeItem) // << here !!
}.listStyle(SidebarListStyle())
}
}
struct SidebarRowView: View {
let item: Item
#Binding var activeItem: Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
var body: some View {
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item)) {
Text(item.name)
}
}
}
Tested with Xcode 13 / macOS 11.6

Clear SwiftUI list in NavigationView does not properly go back to default

The simple navigation demo below demonstrate an example of my issue:
A SwiftUI list inside a NavigationView is filled up with data from the data model Model.
The list items can be selected and the NavigationView is linking another view on the right (demonstrated here with destination)
There is the possibility to clear the data from the model - the SwiftUI list gets empty
The model data can be filled with new data at a later point in time
import SwiftUI
// Data model
class Model: ObservableObject {
// Example data
#Published var example: [String] = ["Data 1", "Data 2", "Data 3"]
#Published var selected: String?
}
// View
struct ContentView: View {
#ObservedObject var data: Model = Model()
var body: some View {
VStack {
// button to empty data set
Button(action: {
data.selected = nil
data.example.removeAll()
}) {
Text("Empty Example data")
}
NavigationView {
// data list
List {
ForEach(data.example, id: \.self) { element in
// navigation to "destination"
NavigationLink(destination: destination(element: element), tag: element, selection: $data.selected) {
Text(element)
}
}
}
// default view when nothing is selected
Text("Nothing selected")
}
}
}
func destination(element: String) -> some View {
return Text("\(element) selected")
}
}
What happens when I click the "Empty Example data" button is that the list will be properly cleared up. However, the selection is somehow persistent and the NavigationView will not jump back to the default view when nothing is selected:
I would expect the view Text("Nothing selected") being loaded.
Do I overlook something important?
When you change the data.example
in the button, the left panel changes because the List has changed.
However the right side do not, because no change has occurred there, only the List has changed.
The ForEach does not re-paint the "destination" views.
You have an "ObservedObject" and its purpose is to keep track of the changes, so using that
you can achieve what you want, like this:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class Model: ObservableObject {
#Published var example: [String] = ["Data 1", "Data 2", "Data 3"]
#Published var selected: String?
}
struct ContentView: View {
#StateObject var data: Model = Model()
var body: some View {
VStack {
Button(action: {
data.selected = nil
data.example.removeAll()
}) {
Text("Empty Example data")
}
NavigationView {
List {
ForEach(data.example, id: \.self) { element in
NavigationLink(destination: DestinationView(),
tag: element,
selection: $data.selected) {
Text(element)
}
}
}
Text("Nothing selected")
}.environmentObject(data)
}
}
}
struct DestinationView: View {
#EnvironmentObject var data: Model
var body: some View {
Text("\(data.selected ?? "")")
}
}

Is it possible to perform an action on NavigationLink tap?

I have a simple View showing a list of 3 items. When the user taps on an item, it navigates to the next view. This works fine. However, I would like to also perform an action (set a variable in a View Model) when a list item is tapped.
Is this possible? Here's the code:
import SwiftUI
struct SportSelectionView: View {
#EnvironmentObject var workoutSession: WorkoutManager
let sports = ["Swim", "Bike", "Run"]
var body: some View {
List(sports, id: \.self) { sport in
NavigationLink(destination: ContentView().environmentObject(workoutSession)) {
Text(sport)
}
}.onAppear() {
// Request HealthKit store authorization.
self.workoutSession.requestAuthorization()
}
}
}
struct DisciplineSelectionView_Previews: PreviewProvider {
static var previews: some View {
SportSelectionView().environmentObject(WorkoutManager())
}
}
The easiest way I've found to get around this issue is to add an .onAppear call to the destination view of the NavigationLink. Technically, the action will happen when the ContentView() appears and not when the NavigationLink is clicked.. but the difference will be milliseconds and probably irrelevant.
NavigationLink(destination:
ContentView()
.environmentObject(workoutSession)
.onAppear {
// add action here
}
)
Here's a solution that is a little different than the onAppear approach. By creating your own Binding for isActive in the NavigationLink, you can introduce a side effect when it's set. I've implemented it here all within the view, but I would probably do this in an ObservableObject if I were really putting it into practice:
struct ContentView: View {
#State var _navLinkActive = false
var navLinkBinding : Binding<Bool> {
Binding<Bool> { () -> Bool in
return _navLinkActive
} set: { (newValue) in
if newValue {
print("Side effect")
}
_navLinkActive = newValue
}
}
var body: some View {
NavigationView {
NavigationLink(
destination: Text("Dest"),
isActive: navLinkBinding,
label: {
Text("Navigate")
})
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

Value from #State variable does not change

I have created a View that provides a convinient save button and a save method. Both can then be used inside a parent view.
The idea is to provide these so that the navigation bar items can be customized, but keep the original implementation.
Inside the view there is one Textfield which is bound to a #State variable. If the save method is called from within the same view everthing works as expected. If the parent view calls the save method on the child view, the changes to the #State variable are not applied.
Is this a bug in SwiftUI, or am I am missing something? I've created a simple playbook implementation that demonstrates the issue.
Thank you for your help.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
// Create the child view to make the save button available inside this view
var child = Child()
var body: some View {
NavigationView {
NavigationLink(
destination: child.navigationBarItems(
// Set the trailing button to the one from the child view.
// This is required as this view might be inside a modal
// sheet, and we need to add the cancel button as a leading
// button:
// leading: self.cancelButton
trailing: child.saveButton
)
) {
Text("Open")
}
}
}
}
struct Child: View {
// Store the value from the textfield
#State private var value = "default"
// Make this button available inside this view, and inside the parent view.
// This makes sure the visibility of this button is always the same.
var saveButton: some View {
Button(action: save) {
Text("Save")
}
}
var body: some View {
VStack {
// Simple textfield to allow a string to change.
TextField("Value", text: $value)
// Just for the playground to change the value easily.
// Usually it would be chnaged through the keyboard input.
Button(action: {
self.value = "new value"
}) {
Text("Update")
}
}
}
func save() {
// This always displays the default value of the state variable.
// Even after the Update button was used and the value did change inside
// the textfield.
print("\(value)")
}
}
PlaygroundPage.current.setLiveView(ContentView())
I think a more SwiftUi way of doing it:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
var body: some View {
return NavigationView {
// tell the child view where to render it's navigation item
// Instead of configuring navigation items.
NavigationLink(destination: Child(navigationSide: .left)) {
Text("Open")
}
}
}
}
struct Child: View {
enum NavigationSide { case left, right }
// If you really want to encapsulate all state in this view then #State
// is a good choice.
// If the parent view needs to read it, too, #Binding would be your friend here
#State private var value: String = "default"
// no need for #State as it's never changed from here.
var navigationSide = NavigationSide.right
// wrap in AnyView here to make ternary in ui code easier readable.
var saveButton: AnyView {
AnyView(Button(action: save) {
Text("Save")
})
}
var emptyAnyView: AnyView { AnyView(EmptyView()) }
var body: some View {
VStack {
TextField("Value", text: $value)
Button(action: {
self.value = "new value"
}) {
Text("Update")
}
}
.navigationBarItems(leading: navigationSide == .left ? saveButton : emptyAnyView,
trailing: navigationSide == .right ? saveButton : emptyAnyView)
}
func save() {
print("\(value)")
}
}
TextField will only update your value binding when the return button is pressed. To get text changes that occur during editing, set up an observed object on Child with didSet. This was the playground I altered used from your example.
struct ContentView: View {
var child = Child()
var body: some View {
NavigationView {
NavigationLink(
destination: child.navigationBarItems(
trailing: child.saveButton
)
) {
Text("Open")
}
}
}
}
class TextChanges: ObservableObject {
var completion: (() -> ())?
#Published var text = "default" {
didSet {
print(text)
}
}
}
struct Child: View {
#ObservedObject var textChanges = TextChanges()
var saveButton: some View {
Button(action: save) {
Text("Save")
}
}
var body: some View {
VStack {
TextField("Value", text: $textChanges.text).multilineTextAlignment(.center)
Button(action: {
print(self.textChanges.text)
}) {
Text("Update")
}
}
}
func save() {
print("\(textChanges.text)")
}
}
PlaygroundPage.current.setLiveView(ContentView())
Inside Child: value is mutable because it's wrapped with #State.
Inside ContentView: child is immutable because it's not wrapped with #State.
Your issue can be fixed with this line: #State var child = Child()
Good luck.
Child view needs to keep its state as a #Binding. This works:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var v = "default"
var body: some View {
let child = Child(value: $v)
return NavigationView {
NavigationLink(
destination: child.navigationBarItems(trailing: child.saveButton)
) {
Text("Open")
}
}
}
}
struct Child: View {
#Binding var value: String
var saveButton: some View {
Button(action: save) {
Text("Save")
}
}
var body: some View {
VStack {
TextField("Value", text: $value)
Button(action: {
self.value = "new value"
}) {
Text("Update")
}
}
}
func save() {
print("\(value)")
}
}
PlaygroundPage.current.setLiveView(ContentView())
Based on this commend from #nine-stones (thank you!) I implemented a more SwiftUI way so solve my problem. It does not allow the customization of the navigation items as I planned, but that was not the problem that needed to be solved. I wanted to use the Child view in a navigation link, as well as inside a modal sheet. The problem was how to perform custom cancel actions. This is why I removed the button implementation and replaced it with a cancelAction closure. Now I can display the child view wherever and however I want.
One thing I still do not know why SwiftUI is not applying the child context to the button inside the saveButton method.
Still, here is the code, maybe it helps someone in the future.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
NavigationLink(
destination: Child(
// Instead of defining the buttons here, I send an optional
// cancel action to the child. This will make it possible
// to use the child view on navigation links, as well as in
// modal dialogs.
cancelAction: {
self.presentationMode.wrappedValue.dismiss()
}
)
) {
Text("Open")
}
}
}
}
struct Child: View {
// Store the value from the textfield
#State private var value = "default"
#Environment(\.presentationMode) var presentationMode
var cancelAction: (() -> Void)?
// Make this button available inside this view, and inside the parent view.
// This makes sure the visibility of this button is always the same.
var saveButton: some View {
Button(action: save) {
Text("Save")
}
}
var body: some View {
VStack {
// Simple textfield to allow a string to change.
TextField("Value", text: $value)
// Just for the playground to change the value easily.
// Usually it would be chnaged through the keyboard input.
Button(action: {
self.value = "new value"
}) {
Text("Update")
}
}
.navigationBarItems(
leading: self.cancelAction != nil ? Button(action: self.cancelAction!, label: {
Text("Cancel")
}) : nil,
trailing: self.saveButton
)
}
func save() {
// This always displays the default value of the state variable.
// Even after the Update button was used and the value did change inside
// the textfield.
print("\(value)")
}
}
PlaygroundPage.current.setLiveView(ContentView())