Search code examples
swiftswiftuiintersectiondrag

swiftUI: detect intersection of two views embedded inside an array and swap their positions


1) A number of instances of the same view are embedded inside an array. A Subview of a main ContentView displays them via ForEach in a HStack. Each instance has a geometry reader to detect frame intersections during drag. The goal is to swap views' positions on the main content view when intersection is detected. With the code below it's simply not detecting an intersection.

  • Why?
  • Is there a better/more simple way (than geometry reader) to detect collisions/intersections of views?
  • Is there a better way (than array+foreach) to group & display a dynamic number of instances of some view?

    var rects: [rect] = [rect(num:"0"),rect(num:"1"),rect(num:"2")]
    
    struct rect: View {
        let id = UUID()
        let num: String
        @State var viewFrame: CGRect = .zero
        @State var viewOffset: CGSize = .zero
        var body: some View {
            Rectangle()
            .stroke(Color.blue, lineWidth: 4)
            .frame(width: 50, height: 100)
            .offset(viewOffset)
            .background(Text("\(num)"))
            .overlay(GeometryReader { r in
                Color.red
                    .opacity(0.5)
                    .onAppear {
                        self.viewFrame = r.frame(in: .global)
                }
            })
            .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
                .onChanged { value in
                    self.viewOffset = value.translation
            }
                .onEnded { value in
                    let newFrame = self.viewFrame.offsetBy(dx: self.viewOffset.width, dy: self.viewOffset.height)
                    if let swap_rect = rects.first(where: {$0.id != self.id && $0.viewFrame.intersects(newFrame)}) {
                        print("intersects")
                        rects.swapAt(rects.firstIndex(of: self), rects.firstIndex(of: swap_rect))
                    } else {
                        print("nope")
                    }
                    self.viewOffset = .zero
            }
            )
        }
    }
    
    struct field: View {
        var body: some View {
            HStack {
                ForEach(self.field_env.rects, id:\.id) { rect in
                        rect
                }
            }
        }
    }
    
    struct ContentView: View {
        var body: some View {
            VStack {
                field()
            }
        }
    }
    

2) The only way I was somewhat able to achieve this is by storing additional GR collision frame of each view inside a published array var of environmentObject/ObservableObject. However, once two instances of my view swap their positions on the main view, it seems GR collision frame CGRects are not getting updated (as GR only has onappear method) and next time I perform view drag & intersect wrong views are swapped (not the ones visually collided) e.g. the code below displays 3 rectangles labeled 0,1,2 (as their indexes in the array). First time you drag "2" onto "1" they swap correctly, but then if you drag "1" onto "2" the "1" gets swapped with "0".

```swift
class FieldEnv: ObservableObject {
    @Published var rect_frames: [UUID:CGRect] = [:]
    @Published var rects: [rect] = [rect(num:"0"),rect(num:"1"),rect(num:"2")]
}

struct rect: View {
    @EnvironmentObject var field_env: FieldEnv
    let id = UUID()
    let num: String
    @State var viewFrame: CGRect = .zero
    @State var viewOffset: CGSize = .zero
    var body: some View {
        Rectangle()
        .stroke(Color.blue, lineWidth: 4)
        .frame(width: 50, height: 100)
        .offset(viewOffset)
        .background(Text("\(num)"))
        .overlay(GeometryReader { r in
            Color.red
                .opacity(0.5)
                .onAppear {
                    self.viewFrame = r.frame(in: .global)
                    self.field_env.rect_frames[self.id] = r.frame(in: .global)
            }
        })
        .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
            .onChanged { value in
                self.viewOffset = value.translation
        }
            .onEnded { value in
                let newFrame = self.viewFrame.offsetBy(dx: self.viewOffset.width, dy: self.viewOffset.height)
                if let swap_rect = self.field_env.rect_frames.first(where: {$0.key != self.id && $0.value.intersects(newFrame)}) {
                    print("intersects")
                    withAnimation {
                        self.field_env.rects.swapAt(self.field_env.rects.firstIndex(of: self)!, self.field_env.rects.firstIndex(where: {$0.id == swap_rect.key})!)
                    }
                } else {
                    print("nope")
                }
                self.viewOffset = .zero
        }
        )
    }
}

struct field: View {
    let NC = NotificationCenter.default
    @EnvironmentObject var field_env: FieldEnv
    var body: some View {
        HStack {
            ForEach(self.field_env.rects, id:\.id) { rect in
                    rect
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            field()
        }
    }
}
```

UPDATE:

I've tried manually updating GR view frames after the swap is performed via notification center as I thought that GR was using initial frame positions (obtained during .onAppear) every time and things got confused after the swap, but it seems the problem is somewhere else - still wrong array indexes are getting swapped (not the ones that belong to the intersected views):

```swift
struct rect: View {
    @EnvironmentObject var field_env: FieldEnv
    let NC = NotificationCenter.default
    let id = UUID()
    let num: String
    @State var viewFrame: CGRect = .zero
    @State var viewOffset: CGSize = .zero
    var body: some View {
        Rectangle()
        .stroke(Color.blue, lineWidth: 4)
        .frame(width: 50, height: 100)
        .offset(viewOffset)
        .background(Text("\(num)"))
        .overlay(GeometryReader { r in
            Color.red
                .opacity(0.5)
                .onAppear {
                    self.viewFrame = r.frame(in: .global)
                    self.field_env.rect_frames[self.id] = r.frame(in: .global)
                    self.NC.addObserver(forName: .cardsSwapped, object: nil, queue: nil, using: {_ in self.field_env.rect_frames[self.id] = r.frame(in: .global)})
                }
        })
        .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
            .onChanged { value in
                self.viewOffset = value.translation
        }
            .onEnded { value in
                let newFrame = self.viewFrame.offsetBy(dx: self.viewOffset.width, dy: self.viewOffset.height)
                if let swap_rect = self.field_env.rect_frames.first(where: {$0.value.intersects(newFrame)}) {
                    print("intersects")
                    withAnimation {
                        self.field_env.rects.move(fromOffsets: [self.field_env.rects.firstIndex(of: self)!], toOffset: self.field_env.rects.firstIndex(where: {$0.id == swap_rect.key})!)
                    }
                } else {
                    print("nope")
                }
                self.viewOffset = .zero
                self.NC.post(name: .cardsSwapped, object: nil)
        }
        )
    }
}
```

Solution

  • looks like this time it's working properly...however the solution itself and amount of overhead code far from being perfect, not sure maybe there's more easy way - please take a look, all ideas welcome

    import SwiftUI
    
    extension Notification.Name {
        static let viewModified = Notification.Name("view_modified")
    
    }
    
    class FieldEnv: ObservableObject {
        @Published var rect_frames: [CGRect] = []
        @Published var rects: [Int] = [0,1,2]
    }
    
    
    struct rect: View {
        @EnvironmentObject var field_env: FieldEnv
        let num: Int
        @State var viewOffset: CGSize = .zero
        var body: some View {
            Rectangle()
            .stroke(Color.blue, lineWidth: 4)
            .frame(width: 50, height: 100)
            .overlay(Text("\(num)"))
            .offset(viewOffset)
            .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
                .onChanged { drag in
                    self.viewOffset = drag.translation
            }
                .onEnded { drag in
                    if let swap_rect_i = self.field_env.rect_frames.firstIndex(where: {$0.contains(drag.location)}) {
                        print("intersects")
                        withAnimation {
                            self.field_env.rects.swapAt(self.field_env.rects.firstIndex(of: self.num)!, swap_rect_i)
                            self.viewOffset = .zero
                        }
    
                    } else {
                        print("nope")
                        self.viewOffset = .zero
                    }
    
            }
            )
        }
    }
    
    
    struct field: View {
        @EnvironmentObject var field_env: FieldEnv
        let NC = NotificationCenter.default
        var body: some View {
            HStack {
                ForEach(self.field_env.rects, id:\.self) { num in
                    rect(num: num)
                    .background(GeometryReader { r in
                        Color.clear
                            .onAppear {
                                self.NC.post(name: .viewModified, object: nil)
                            }
                            .onDisappear {
                                self.NC.post(name: .viewModified, object: nil)
                            }
                        .onReceive(self.NC.publisher(for: .viewModified, object: nil), perform: {_ in
                            self.field_env.rect_frames = [CGRect](repeating: .zero, count: self.field_env.rects.count)
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                                self.field_env.rect_frames[self.field_env.rects.firstIndex(of: num)!] = r.frame(in: .global)
                            }
                        })
                    })
                }
            }.onAppear {
                self.field_env.rect_frames = [CGRect](repeating: .zero, count: self.field_env.rects.count)
            }
        }
    }
    
    struct ContentView: View {
        @EnvironmentObject var field_env: FieldEnv
        var body: some View {
            VStack {
                Spacer()
                field()
                Spacer()
                HStack {
                    Button(action: {self.field_env.rects.append((self.field_env.rects.max() ?? 0)+1)}) {
                        Text("Add 1")
                    }
                    Button(action: {self.field_env.rects.removeLast()}) {
                        Text("Del 1")
                    }
                }
                Spacer()
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }