SwiftUI how to perform action when EditMode changes? - list

I'd like to perform an action when the EditMode changes.
Specifically, in edit mode, the user can select some items to delete. He normally presses the trash button afterwards. But he may also press Done. When he later presses Edit again, the items that were selected previously are still selected. I would like all items to be cleared.
struct ContentView: View {
#State var isEditMode: EditMode = .inactive
#State var selection = Set<UUID>()
var items = [Item(), Item(), Item(), Item(), Item()]
var body: some View {
NavigationView {
List(selection: $selection) {
ForEach(items) { item in
Text(item.title)
}
}
.navigationBarTitle(Text("Demo"))
.navigationBarItems(
leading: EditButton(),
trailing: addDelButton
)
.environment(\.editMode, self.$isEditMode)
}
}
private var addDelButton: some View {
if isEditMode == .inactive {
return Button(action: reset) {
Image(systemName: "plus")
}
} else {
return Button(action: reset) {
Image(systemName: "trash")
}
}
}
private func reset() {
selection = Set<UUID>()
}
}
Definition of Item:
struct Item: Identifiable {
let id = UUID()
let title: String
static var i = 0
init() {
self.title = "\(Item.i)"
Item.i += 1
}
}

UPDATED for iOS 15.
This solution catches 2 birds with one stone:
The entire view redraws itself when editMode is toggle
A specific action can be performed upon activation/inactivation of editMode
Hopes this helps someone else.
struct ContentView: View {
#State var editMode: EditMode = .inactive
#State var selection = Set<UUID>()
#State var items = [Item(), Item(), Item()]
var body: some View {
NavigationView {
List(selection: $selection) {
ForEach(items) { item in
Text(item.title)
}
}
.navigationTitle(Text("Demo"))
.environment(\.editMode, self.$editMode)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
editButton
}
ToolbarItem(placement: .navigationBarTrailing) {
addDelButton
}
}
}
}
private var editButton: some View {
Button(action: {
self.editMode.toggle()
self.selection = Set<UUID>()
}) {
Text(self.editMode.title)
}
}
private var addDelButton: some View {
if editMode == .inactive {
return Button(action: addItem) {
Image(systemName: "plus")
}
} else {
return Button(action: deleteItems) {
Image(systemName: "trash")
}
}
}
private func addItem() {
items.append(Item())
}
private func deleteItems() {
for id in selection {
if let index = items.lastIndex(where: { $0.id == id }) {
items.remove(at: index)
}
}
selection = Set<UUID>()
}
}
extension EditMode {
var title: String {
self == .active ? "Done" : "Edit"
}
mutating func toggle() {
self = self == .active ? .inactive : .active
}
}

I was trying forever, to clear List selections when the user exited editMode. For me, the cleanest way I've found to react to a change of editMode:
Make sure to reference the #Environment variable:
#Environment(\.editMode) var editMode
Add a computed property in the view to monitor the state:
private var isEditing: Bool {
if editMode?.wrappedValue.isEditing == true {
return true
}
return false
}
Then use the .onChange(of:perform:) method:
.onChange(of: self.isEditing) { value in
if value == false {
// do something
} else {
// something else
}
}
All together:
struct ContentView: View {
#Environment(\.editMode) var editMode
#State private var selections: [String] = []
#State private var colors: ["Red", "Yellow", "Blue"]
private var isEditing: Bool {
if editMode?.wrappedValue.isEditing == true {
return true
}
return false
}
var body: some View {
List(selection: $selections) {
ForEach(colors, id: \.self) { color in
Text("Color")
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
.onChange(of: isEditing) { value in
if value == false {
selection.removeAll()
}
}
}
}

In case someone want to use SwiftUI's EditButton() instead of custom a Button and still want to perform action when isEditing status changes
You can use View extension
extension View {
func onChangeEditMode(editMode: EditMode?, perform: #escaping (EditMode?)->()) -> some View {
ZStack {
Text(String(describing: editMode))
.opacity(0)
.onChange(of: editMode, perform: perform)
self
}
}
}
Then you can use it like this
struct TestEditModeView: View {
#Environment(\.editMode) var editMode
#State private var editModeDescription: String = "nil"
var body: some View {
VStack {
Text(editModeDescription)
EditButton()
}
.onChangeEditMode(editMode: editMode?.wrappedValue) {
editModeDescription = String(describing: $0)
}
}
}

Related

Showing ProgressView during a search

NOTE: this question is not about how to use .searchable or how to filter a List.
I am using the following view to search an external database:
struct SearchDatabaseView: View {
#Environment(\.dismiss) private var dismiss
#Environment(\.isSearching) private var isSearching: Bool
#State private var searchText: String = ""
#State private var searchResults: [Record] = []
var body: some View {
NavigationStack {
List(searchResults, id: \.self) { record in
/// display results here
}
.navigationTitle("Search Database")
.toolbar {
Button(action: {
dismiss()
}) {
Text("Done")
}
}
.overlay {
if isSearching {
ProgressView("Searching Database...")
}
}
}
.searchable(text: $searchText)
.disableAutocorrection(true)
.onSubmit(of: .search) {
searchDatabase()
}
}
}
Everything works, except the progress view is not showing. I tried putting the .overlay modifier after .onSubmit, but still it doesn't show.
What am I missing, is that not the proper use of isSearching ?
Try this approach, where two views are used (like the docs examples) to perform
the search and dismissal using dismissSearch and display the ProgressView.
This is just an example code, see the docs at: https://developer.apple.com/documentation/swiftui/managing-search-interface-activation
for more comprehensive info and examples.
struct ContentView: View {
var body: some View {
SearchDatabaseView()
}
}
struct SearchDatabaseView: View {
#State private var searchText: String = ""
var body: some View {
NavigationStack {
ListView()
.searchable(text: $searchText)
.disableAutocorrection(true)
.navigationTitle("Search Database")
.onSubmit(of: .search) {
// searchDatabase()
print("----> onSubmit: \(searchText)")
}
}
}
}
struct ListView: View {
#Environment(\.dismissSearch) private var dismissSearch
#Environment(\.isSearching) private var isSearching
#State private var searchResults: [String] = ["a-record", "b-record", "c-record", "d-record"]
var body: some View {
List(searchResults, id: \.self) { record in
Text(record)
}
.toolbar {
Button("Done") {
dismissSearch()
}
.overlay {
if isSearching {
ProgressView("Searching Database...")
}
}
}
}
}
EDIT-1:
To cater for your new question, I would do away with the isSearching thing.
Use a "normal" variable and implement a simple but effective code structure, such as in this example code:
struct SearchDatabaseView: View {
#State private var searchText: String = ""
#State private var showSearching = false
#State private var searchResults: [String] = ["a-record", "b-record", "c-record", "d-record"]
var body: some View {
NavigationStack {
List(searchResults, id: \.self) { record in
Text(record)
}
.toolbar {
Button("Done") {
showSearching = false
}
.overlay {
if showSearching {
ProgressView("Searching Database...")
}
}
.searchable(text: $searchText)
.disableAutocorrection(true)
.navigationTitle("Search Database")
.onSubmit(of: .search) {
showSearching = true
// searchDatabase()
// simulation of searchDatabase(), could also pass showSearching to it
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// .....
showSearching = false // when finished searchDatabase()
}
}
}
}
}
}

SwiftUI onReceive is it possible to get oldValue?

I have a custom TabView and I want to Bind to a State to change tabs. I also want to detect if the user has tapped the same tab again in order to scroll to the top of that view.
didSet isn't called when I use a binding. onChange isn't called because the value hasn't changed, and onReceive doesn't give me the old value to compare.
Any ideas? (Trying to avoid using a published property)
struct ContentView: View {
#State private var scrollToTop1: Bool = false
#State private var scrollToTop2: Bool = false
#State private var selectedTab: Int = 1
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
NavigationView {
View1(scrollToTop: $scrollToTop1)
}
.tag(1)
NavigationView {
View2(scrollToTop: $scrollToTop2)
}
.tag(2)
}
.onReceive(Just(selectedTab)) { [oldValue = selectedTab] newValue in
print("Old: \(oldValue)") //Shows newValue
print("New: \(newValue)")
if oldValue == newValue {
switch selectedTab {
case 1:
scrollToTop1.toggle()
case 2:
scrollToTop2.toggle()
default:
break
}
}
}
TabBar(selectedTab: $selectedTab)
}
}
}
struct TabBar: View {
#Binding var selectedTab: Int
var body: some View {
HStack {
TabItem(selectedTab: $selectedTab, text: "View 1", tab: 1)
TabItem(selectedTab: $selectedTab, text: "View 2", tab: 2)
}
.background(Color.green)
}
}
struct TabItem: View {
#Binding var selectedTab: Int
let text: String
let tab: Int
var body: some View {
Button {
selectedTab = tab
} label: {
Text(text)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
}
}
I think this is a great scenario for a custom Binding, where you can intercept the value before its set and compare it:
struct ContentView: View {
#State private var scrollToTop1: Bool = false
#State private var scrollToTop2: Bool = false
#State private var selectedTab: Int = 1
var customBinding: Binding<Int> {
.init {
selectedTab
} set: { newValue in
print("New value: ", newValue)
if newValue == selectedTab {
print("Scroll to top")
}
selectedTab = newValue
}
}
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: customBinding) {
NavigationView {
Text("1")
}
.tag(1)
NavigationView {
Text("2")
}
.tag(2)
}
TabBar(selectedTab: customBinding)
}
}
}
struct TabBar: View {
#Binding var selectedTab: Int
var body: some View {
HStack {
TabItem(selectedTab: $selectedTab, text: "View 1", tab: 1)
TabItem(selectedTab: $selectedTab, text: "View 2", tab: 2)
}
.background(Color.green)
}
}

How to pop to specific view in the TabView Application in swiftui. I used StackNavigation also but not working in swiftui

I am facing an issue while popping to a specific view. Let me explain the hierarchy.
ContentView -> 2 tabs, TabAView and TabBView
Inside TabBView. There is 1 view used ConnectView: Where is a Button to connect. After tapping on the button of Connect, the user move to another View which is called as UserAppView. From Here User can check his profile and update also. After the Update API call, need to pop to UserAppView from UserFirstFormView.
Here is the code to understand better my problem.
ContentView.swift
struct ContentView: View {
enum AppPage: Int {
case TabA=0, TabB=1
}
#StateObject var settings = Settings()
#ObservedObject var userViewModel: UserViewModel
var body: some View {
NavigationView {
TabView(selection: $settings.tabItem) {
TabAView(userViewModel: userViewModel)
.tabItem {
Text("TabA")
}
.tag(AppPage.TabA)
TabBView(userViewModel: userViewModel)
.tabItem {
Text("Apps")
}
.tag(AppPage.TabB)
}
.accentColor(.white)
.edgesIgnoringSafeArea(.top)
.onAppear(perform: {
settings.tabItem = .TabA
})
.navigationBarTitleDisplayMode(.inline)
}
.environmentObject(settings)
}
}
This is TabAView:
struct TabAView: View {
#ObservedObject var userViewModel: UserViewModel
#EnvironmentObject var settings: Settings
init(userViewModel: UserViewModel) {
self.userViewModel = userViewModel
}
var body: some View {
Vstack {
/// code
}
.onAppear(perform: {
/// code
})
.environmentObject(settings)
}
}
This is another TabBView:
struct TabBView: View {
#ObservedObject var userViewModel: UserViewModel
init(userViewModel: UserViewModel) {
self.userViewModel = userViewModel
}
var body: some View {
VStack (spacing: 10) {
NavigationLink(destination: ConnectView(viewModel: ConnectViewModel(id: id!), userViewModel: userViewModel)) {
UserCardWidget()
}
}
}
}
There is 1 connectView used on the TabBView through which the user will connect. ConnectViewModel is used here to call connect API.
class ConnectViewModel: ObservableObject {
var id: String?
init(id: String) {
self.id = id
}
func connect(completion: #escaping () -> Void) {
APIService.shared.connectApp(id: self.id!) { connected in
DispatchQueue.main.async {
self.isConnected = connected ?? false
completion()
}
}
}
}
This is connect view
struct ConnectView: View {
#ObservedObject var connectViewModel: ConnectViewModel
#ObservedObject var userViewModel: UserViewModel
#State var buttonTitle = "CONNECT WITH THIS"
#State var isShowingDetailView = false
var body: some View {
VStack {
Spacer()
if let id = connectViewModel.id {
NavigationLink(destination: UserAppView(id: id, userViewModel: userViewModel), isActive: $isShowingDetailView) {
Button(buttonTitle, action: {
connectViewModel.connect {
buttonTitle = "CONNECTED"
isShowingDetailView = true
}
})
}
}
}
}
}
This is the UserAppViewModel where API is hit to fetch some user-related details:
class UserAppViewModel: ObservableObject {
var id = ""
func getdetails() {
APIService.shared.getDetails() { userDetails in
DispatchQueue.main.async {
/// code
}
}
}
}
This is UserAppView class
struct UserAppView: View {
#ObservedObject var userViewModel: UserViewModel
#State private var signUpInButtonClicked: Bool = false
#StateObject private var userAppViewModel = UserAppViewModel()
init(id: String, userViewModel: UserViewModel) {
self.id = id
self.userViewModel = userViewModel
}
var body: some View {
VStack {
Text(userAppViewModel.status)
VStack {
Spacer()
NavigationLink(
destination: ProfileView(userAppViewModel: userAppViewModel, isActive: $signUpInButtonClicked)) { EmptyView() }
if /// Condition {
Button(action: {
signUpInButtonClicked = true
}, label: {
ZStack {
/// code
}
.frame(maxWidth: 77, maxHeight: 25)
})
}
}.onAppear(perform: {
**userAppViewModel.getDetails**(id: id)
})
}
}
From Here, the User Can Navigate to ProfileView.
struct ProfileUpdateView: View {
#State private var navigationSelectionFirstFormView = false
#State private var navigationSelectionLastFormView = false
public var body: some View {
VStack {
NavigationLink(destination: UserFirstFormView(userAppViewModel: userAppViewModel), isActive: $navigationSelectionFirstFormView) {
EmptyView()
}
NavigationLink(destination: UserLastFormView(userAppViewModel: userAppViewModel), isActive: $navigationSelectionLastFormView) {
EmptyView()
}
}
.navigationBarItems(trailing: Button(action: {
if Condition {
navigationSelectionFirstFormView = true
} else {
navigationSelectionLastFormView = true
}
}, label: {
HStack {
Text("Action")
.foregroundColor(Color.blue)
}
})
)
}
}
Further, their user will move to the next screen to update the profile.
struct UserFirstFormView: View {
var body: some View {
VStack {
/// code
///
Button("buttonTitle", action: {
API Call completion: { status in
if status {
self.rootPresentationMode.wrappedValue.dismiss()
}
})
})
.frame(maxHeight: 45)
}
}
}
I am trying to pop from this view once the API response is received but nothing is working.
I have removed most of the code from a confidential point of view but this code will explain the reason and error. Please look into the code and help me.
You could use the navigation link with, tag: and selection: overload and let the viewmodel control what link is open, here a example
enum profileViews {
case view1
case view2}
in your viewModel add an published var that will hold the active view
#Published var activeView: profileViews?
then in your navigation link you can do it like this
NavigationLink(
destination: secondView(profileViewModel: ProfileViewModel ),
tag: profileViews.view1,
selection: self.$profileViewModel.activeView
){}
Then you could pop any view just updating the variable inside the view model
self.profileViewModel.activeView = nil

How to delete multiple rows from List in SwiftUI?

I took an example from this question: How does one enable selections in SwiftUI's List and edited the code to be able to delete rows one by one. But I don't know how to delete multiple rows from list.
Could you help me, please?
var demoData = ["Phil Swanson", "Karen Gibbons", "Grant Kilman", "Wanda Green"]
struct ContentView : View {
#State var selectKeeper = Set<String>()
var body: some View {
NavigationView {
List(selection: $selectKeeper){
ForEach(demoData, id: \.self) { name in
Text(name)
}
.onDelete(perform: delete)
}
.navigationBarItems(trailing: EditButton())
.navigationBarTitle(Text("Selection Demo \(selectKeeper.count)"))
}
}
func delete(at offsets: IndexSet) {
demoData.remove(atOffsets: offsets)
}
}
solution from SwiftUI how to perform action when EditMode changes?
struct Item: Identifiable {
let id = UUID()
let title: String
static var i = 0
init() {
self.title = "\(Item.i)"
Item.i += 1
}
}
struct ContentView: View {
#State var editMode: EditMode = .inactive
#State var selection = Set<UUID>()
#State var items = [Item(), Item(), Item()]
var body: some View {
NavigationView {
List(selection: $selection) {
ForEach(items) { item in
Text(item.title)
}
}
.navigationBarTitle(Text("Demo"))
.navigationBarItems(
leading: editButton,
trailing: addDelButton
)
.environment(\.editMode, self.$editMode)
}
}
private var editButton: some View {
Button(action: {
self.editMode.toggle()
self.selection = Set<UUID>()
}) {
Text(self.editMode.title)
}
}
private var addDelButton: some View {
if editMode == .inactive {
return Button(action: addItem) {
Image(systemName: "plus")
}
} else {
return Button(action: deleteItems) {
Image(systemName: "trash")
}
}
}
private func addItem() {
items.append(Item())
}
private func deleteItems() {
for id in selection {
if let index = items.lastIndex(where: { $0.id == id }) {
items.remove(at: index)
}
}
selection = Set<UUID>()
}
}
extension EditMode {
var title: String {
self == .active ? "Done" : "Edit"
}
mutating func toggle() {
self = self == .active ? .inactive : .active
}
}

Dismiss sheet SwiftUI

I'm trying to implement a dismiss button for my modal sheet as follows:
struct TestView: View {
#Environment(\.isPresented) var present
var body: some View {
Button("return") {
self.present?.value = false
}
}
}
struct DataTest : View {
#State var showModal: Bool = false
var modal: some View {
TestView()
}
var body: some View {
Button("Present") {
self.showModal = true
}.sheet(isPresented: $showModal) {
self.modal
}
}
}
But the return button when tapped does nothing. When the modal is displayed the following appears in the console:
[WindowServer] display_timer_callback: unexpected state (now:5fbd2efe5da4 < expected:5fbd2ff58e89)
If you force unwrap present you find that it is nil
How can I dismiss .sheet programmatically?
iOS 15+
Starting from iOS 15 we can use DismissAction that can be accessed as #Environment(\.dismiss).
There's no more need to use presentationMode.wrappedValue.dismiss().
struct SheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
Text("Sheet")
.toolbar {
Button("Done") {
dismiss()
}
}
}
}
}
Use presentationMode from the #Environment.
Beta 6
struct SomeView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Text("Ohay!")
Button("Close") {
self.presentationMode.wrappedValue.dismiss()
}
}
}
}
For me, beta 4 broke this method - using the Environment variable isPresented - of using a dismiss button. Here's what I do nowadays:
struct ContentView: View {
#State var showingModal = false
var body: some View {
Button(action: {
self.showingModal.toggle()
}) {
Text("Show Modal")
}
.sheet(
isPresented: $showingModal,
content: { ModalPopup(showingModal: self.$showingModal) }
)
}
}
And in your modal view:
struct ModalPopup : View {
#Binding var showingModal:Bool
var body: some View {
Button(action: {
self.showingModal = false
}) {
Text("Dismiss").frame(height: 60)
}
}
}
Apple recommend (in WWDC 2020 Data Essentials in SwiftUI) using #State and #Binding for this. They also place the isEditorPresented boolean and the sheet's data in the same EditorConfig struct that is declared using #State so it can be mutated, as follows:
import SwiftUI
struct Item: Identifiable {
let id = UUID()
let title: String
}
struct EditorConfig {
var isEditorPresented = false
var title = ""
var needsSave = false
mutating func present() {
isEditorPresented = true
title = ""
needsSave = false
}
mutating func dismiss(save: Bool = false) {
isEditorPresented = false
needsSave = save
}
}
struct ContentView: View {
#State var items = [Item]()
#State private var editorConfig = EditorConfig()
var body: some View {
NavigationView {
Form {
ForEach(items) { item in
Text(item.title)
}
}
.navigationTitle("Items")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: presentEditor) {
Label("Add Item", systemImage: "plus")
}
}
}
.sheet(isPresented: $editorConfig.isEditorPresented, onDismiss: {
if(editorConfig.needsSave) {
items.append(Item(title: editorConfig.title))
}
}) {
EditorView(editorConfig: $editorConfig)
}
}
}
func presentEditor() {
editorConfig.present()
}
}
struct EditorView: View {
#Binding var editorConfig: EditorConfig
var body: some View {
NavigationView {
Form {
TextField("Title", text:$editorConfig.title)
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(action: save) {
Text("Save")
}
.disabled(editorConfig.title.count == 0)
}
ToolbarItem(placement: .cancellationAction) {
Button(action: dismiss) {
Text("Dismiss")
}
}
}
}
}
func save() {
editorConfig.dismiss(save: true)
}
func dismiss() {
editorConfig.dismiss()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(items: [Item(title: "Banana"), Item(title: "Orange")])
}
}