swiftlistswiftuireorderlist

How to disable editing mode by tapping on a list row


In my app, I have many lists where I want the user to be able to reorder the rows via swipeActions. I also want them to be able to disable editingMode by tapping anywhere on a row in a List (it rarely ever makes sense for me to have an EditingButton() on a Toolbar).

I've created a class called TitleList with the described functionality. It takes in an array of strings, and through a ViewBuilder closure, I can format the array data however I choose. I've mostly accomplished what I'm trying to in the below code:

import SwiftUI

struct HomeView: View
{
    @Environment(\.editMode) private var editMode
    
    @State private var titles: [String] = ["Title 1", "Title 2", "Title 3"]
    
    var body: some View
    {
        NavigationStack
        {
            VStack
            {
                TitleList(titles: self.$titles)
                {
                    title in
                    
                    NavigationLink(destination: Text("Test"))
                    {
                        Text(title)
                    }
                    
                    /*title in
                    
                    (Button(action:
                    {
                        print("Button Pushed")
                    })
                    {
                        Text(title)
                    }
                    .buttonStyle(PlainButtonStyle())
                    .disabled(self.editMode?.wrappedValue == .active)*/
                }
            }
        }
    }
}

struct TitleList<RowContent: View>: View
{
    @Environment(\.editMode) private var editMode
    
    @Binding var titles: [String]
    
    @State private var isReordering: Bool = false
    
    private let rowContent: (String) -> RowContent
    
    init(titles: Binding<[String]>, @ViewBuilder rowContent: @escaping (String) -> RowContent = { (_: String) in EmptyView() })
    {
        self._titles = titles
        self.rowContent = rowContent
    }
    
    var body: some View
    {
        List
        {
            ForEach(self.titles, id: \.self)
            {
                title in
                
                self.rowContent(title).moveDisabled(!self.isReordering).swipeActions(allowsFullSwipe: false)
                {
                    Button
                    {
                        self.editMode?.wrappedValue = .active
                        self.isReordering = true
                    }
                    label:
                    {
                        Label("Reorder", systemImage: "list.bullet")
                    }
                }
            }
            .onMove
            {
                source, destination in self.titles.move(fromOffsets: source, toOffset: destination)
            }
            .contentShape(Rectangle())
            .allowsHitTesting(self.isReordering)
            .onTapGesture
            {
                if (self.isReordering)
                {
                    withAnimation
                    {
                        self.isReordering = false
                        self.editMode?.wrappedValue = .inactive
                    }
                }
            }
        }
    }
}

If I have a row with a NavigationLink or Text it works fine. But when I have a row with a Button, .allowsHitTesting(self.isReordering) seems to be blocking the touches to it. If I comment that line out, it works fine, but then lists with NavigationLink don't work. Easy solution is to send in a Bool to TitleList to let it know whether a button will be used or not, but I'm wondering if there is a better way to do this, because there's also a few other side effects of the way I'm doing it:

-To exit editingMode, the user can only tap the component in the row (i.e. the button or whatever) instead of being able to tap anywhere on the row (unless I wrap rowContent in an HStack with a Spacer()).

-Even when I have .allowsHitTesting set to false for a Button, I still have to disable it when in editingMode or else when the user taps the Button, it will do its action instead of shutting off editingMode.

So was wondering if anyone had a cleaner way to shut off editingMode that accounts for all possible UI components that can be in a row.


Solution

  • You don't need to use .allowsHitTesting like that. To deal with the NavigationLink acting up, you need to remove the button styling that it adds by default, by using .buttonStyle(.plain), although I am not sure it really applies in this case.

    To prevent the Button from triggering the action when in edit mode and to fix the NavigationLink, the solution is a .highPriorityGesture, that you enable conditionally based on the EditMode state:

    .highPriorityGesture(
        TapGesture()
            .onEnded {
                if (self.isReordering) {
                    withAnimation {
                        self.isReordering = false
                        self.editMode?.wrappedValue = .inactive
                    }
                }
            }
        , isEnabled: isReordering
    )
    

    Here's the entire code:

    import SwiftUI
    
    struct ListHomeView: View {
        
        @Environment(\.editMode) private var editMode
        
        @State private var titles: [String] = ["Title 1", "Title 2", "Title 3"]
        
        @ViewBuilder
        var body: some View {
            
            NavigationStack {
                VStack {
                    Section("Buttons") {
                        TitleList(titles: $titles) { title in
                            Button {
                                print("Button Pushed")
                            } label : {
                                Text(title)
                            }
                            .buttonStyle(PlainButtonStyle())
                        }
                    }
                    
                    Section("Navigation Links") {
                        TitleList(titles: self.$titles) { title in
                            NavigationLink(destination: Text("Test")) {
                                Text(title)
                            }
                        }
                    }
                }
            }
        }
    }
    
    struct TitleList<RowContent: View>: View {
        
        //Parameters
        @Binding var titles: [String]
        
        //Environment values
        @Environment(\.editMode) private var editMode
        
        @State private var isReordering: Bool = false
       
        private let rowContent: (String) -> RowContent
        
        //Initializer
        init(titles: Binding<[String]>, @ViewBuilder rowContent: @escaping (String) -> RowContent = { (_: String) in EmptyView() })
        {
            self._titles = titles
            self.rowContent = rowContent
        }
        
        //Body
        var body: some View {
            List {
                ForEach(self.titles, id: \.self) { title in
                    
                    rowContent(title)
                        .moveDisabled(!self.isReordering)
                        .swipeActions(allowsFullSwipe: false) {
                            Button {
                                self.editMode?.wrappedValue = .active
                                self.isReordering = true
                            } label: {
                                Label("Reorder", systemImage: "list.bullet")
                            }
                        }
                }
                .onMove {
                    source, destination in self.titles.move(fromOffsets: source, toOffset: destination)
                }
                .highPriorityGesture(
                    TapGesture()
                        .onEnded {
                            if (self.isReordering) {
                                withAnimation {
                                    self.isReordering = false
                                    self.editMode?.wrappedValue = .inactive
                                }
                            }
                        }
                    , isEnabled: isReordering
                )
            }
        }
    }
    
    #Preview {
        ListHomeView()
    }
    

    Note: By the way, not a fan of that coding structure. Please stick to conventional formatting when sharing or asking questions to make everyone lives easier.