2
votes

I have LazyVStack view that contains a list of views. Each one of the views has a different color and there is 8 points space between them. Threrefore, I can not use List.

So I am trying to build a custom trailing swipe that functions similar to the onDelete method of List. This is my code and it is not perfect, but I am on the right directin, I think.

Test Data - List of countries

class Data: ObservableObject {
    @Published var countries: [String]
    init() {
        self.countries = NSLocale.isoCountryCodes.map { (code:String) -> String in
            let id = NSLocale.localeIdentifier(fromComponents: [NSLocale.Key.countryCode.rawValue: code])
            return NSLocale(localeIdentifier: "en_US").displayName(forKey: NSLocale.Key.identifier, value: id) ?? "Country not found for code: \(code)"
        }
    }
}

ContentView

struct ContentView: View {
    @ObservedObject var data: Data = Data()
    
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(data.countries, id: \.self) { country in
                    VStack {
                        SwipeView(content: {
                            VStack(spacing: 0) {
                                Spacer()
                                Text(country)
                                    .frame(minWidth: 0, maxWidth: .infinity)
                                Spacer()
                            }
                            .background(Color.yellow)
                        }, trailingActionView: {
                            Image(systemName: "trash")
                                .foregroundColor(.white)
                        }) {
                            self.data.countries.removeAll {$0 == country}
                        }
                    }
                    .clipShape(Rectangle())
                }
            }
        }
        .padding(.vertical, 16)
    }
}

Custom SwipeView

struct SwipeView<Content: View, TrailingActionView: View>: View {
    let width = UIScreen.main.bounds.width - 32
    
    @State private var height: CGFloat = .zero
    
    @State var offset: CGFloat = 0
    
    let content: Content
    
    let trailingActionView: TrailingActionView
    
    var onDelete: () -> ()
    
    
    init(@ViewBuilder content: () -> Content,
                      @ViewBuilder trailingActionView: () -> TrailingActionView,
                      onDelete: @escaping () -> Void) {
        self.content = content()
        self.trailingActionView = trailingActionView()
        self.onDelete = onDelete
    }
    
    var body: some View {
        ZStack {
            HStack(spacing: 0) {
                Button(action: {
                    withAnimation {
                        self.onDelete()
                    }
                }) {
                    trailingActionView
                }
                .frame(minHeight: 0, maxHeight: .infinity)
                .frame(width: 60)
                Spacer()
            }
            .background(Color.red)
            .frame(width: width)
            .offset(x: width + self.offset)

            content
                .frame(width: width)
                .contentShape(Rectangle())
                .offset(x: self.offset)
                .gesture(DragGesture().onChanged(onChanged).onEnded { value in
                    onEnded(value: value, width: width)
                })
        }
        .background(Color.white)
    }
    
    private func onChanged(value: DragGesture.Value) {
        let translation =  value.translation.width
        
        if translation < 0  {
            self.offset = translation
        } else {
            
        }
    }
    
    private func onEnded(value: DragGesture.Value,width: CGFloat) {
        withAnimation(.easeInOut) {
            let translation = -value.translation.width
            if translation > width - 16 {
                self.onDelete()
                self.offset = -(width * 2)
            }
            
            else if translation > 50 {
                self.offset = -50
            }
            else {
                self.offset = 0
            }
        }
    }
}

It has one annoying problem: If you swipe a row and do not delete it. And if you swipe another views, they don not reset. All the trailing Delete Views are visible. But I want to reset/ swipe back if you tap anywhere outside the Delete View.


I want to swipe back if you tap anywhere outside the Delete View. So how to do it?

1
I would adapted tag/selection concept into SwipeView, because it is needed internally react on external conditions, so state holding exclusiveness of activity should be located externally and injected inside via binding. It requires a lot of changes in your code, so try it at first by yourself, if such idea appropriate for you. - Asperi

1 Answers

1
votes

First off, to know which cell is swiped the SwipeViews needs an id. If you don't want to set them from external I guess this will do:

struct SwipeView<Content: View, TrailingActionView: View>: View {
    ...
    @State var id = UUID()
    ...
}

Then you need to track which cell is swiped, the SwiftUI way of relaying data to siblings is by a Binding that is saved in it's parent. Read up on how to pass data around SwiftUI Views. If you want to be lazy you can also just have a static object that saves the selected cell:

class SwipeViewHelper: ObservableObject {
    @Published var swipedCell: UUID?
    private init() {}
    static var shared = SwipeViewHelper()
}

struct SwipeView<Content: View, TrailingActionView: View>: View {
    ...
    @ObservedObject var helper = SwipeViewHelper.shared
    ...
}

Then you have to update the swipedCell. We want the cell to close when we START swiping on a different cell:

private func onChanged(value: DragGesture.Value) {
    ...
    if helper.swipedCell != nil {
        helper.swipedCell = nil
    }
    ...
}

And when a cell is open we save it:

private func onEnded(value: DragGesture.Value,width: CGFloat) {
    withAnimation(.easeInOut) {
        ...
        else if translation > 50 {
            self.offset = -50
            helper.swipedCell = id
        }
        ...
    }
}

Then we have to respond to changes of the swipedCell. We can do that by adding an onChange inside the body of SwipeView:

.onChange(of: helper.swipedCell, perform: { newCell in
    if newCell != id {
        withAnimation(.easeInOut) {
            self.offset = 0
        }
    }
})

Working gist: https://gist.github.com/Amzd/61a957a1c5558487f6cc5d3ce29cf508