5

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.

Deddiekoel
  • 1,939
  • 3
  • 17
  • 25
  • @Asperi gave a great workaround. However because it doesn't match the documentation I've filed a bug report to Apple: FB10429307 – Patrick Jun 23 '22 at 12:25

4 Answers4

3

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

demo

Test module on GitHub

Asperi
  • 228,894
  • 20
  • 464
  • 690
1

@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:

Example

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)
            }
    }
}
Patrick
  • 2,035
  • 17
  • 27
1

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

}

zouritre
  • 231
  • 3
  • 6
0

Another option is to decompose the main view into views containing each list to be edited, then provide an editMode environment variable to each decomposed view. Here's some code:

struct ListContainer: View {
    @State private var foods = ["apples", "pizza", "okra"]
    @State private var activities = ["cricket", "skiing", "pickleball"]
    @State private var showFoodEditor: EditMode = .inactive
    @State private var showActivitiesEditor: EditMode = .inactive
    var body: some View {
        VStack {
            FoodList(foods: $foods)
                .environment(\.editMode, $showFoodEditor)
            ActivitiesList(activities: $activities)
                .environment(\.editMode, $showActivitiesEditor)
        }
    }
}
struct FoodList: View {
    @Binding var foods: Array<String>
    var body: some View {
        List {
            Section {
                HStack {
                    Text("Choose favorites")
                    Spacer()
                    EditButton()
                }
            }
            ForEach (foods, id:\.self) { food in
                Text(food)
            }
            .onMove(perform: {from, to in
                //Perform move work here
            })
        }
    }
}
struct ActivitiesList: View {
    @Binding var activities: Array<String>
    var body: some View {
        List {
            Section {
                HStack {
                    Text("Delete activities")
                    Spacer()
                    EditButton()
                }
            }
            ForEach (activities, id:\.self) { activity in
                Text(activity)
            }
            .onDelete(perform: { indexDeleted in
                //do your delete work here
            })
        }
    }
}
slucas
  • 51
  • 1
  • 5