Search code examples
swiftswiftuigesturehstackdraggesture

How to build a DragGesture with a "sticky" behavior in SwifUI?


I am building a simple drag gesture animation in a Rectangle() that, in the end of the gesture, stick the shape in the closest item of a group.

Check the example in the image bellow.

I was able to achieve a decent result with the following approach:

  • Manually computing the x absolute position of the black rectangles:

    @State private var xPos : [Int] = [40,110,180,250,320]

  • Placing the rectangles in the screen with a absolute position property:

    Rectangle()
        .position(x: CGFloat(xPos[i]), y: 0)
    
  • Finally, in the blue Rectangle, add the gesture property, with a fancy func on .onEnded to find the closest element, and apply the x position accordingly.

     Rectangle()
              .fill(.blue)
              .position(location)
              .gesture(
                  DragGesture()
                      .onChanged { gesture in
                          location.x = gesture.location.x
                      }
                      .onEnded { value in
                          withAnimation(.spring()) {
                              var goal = Int(value.location.x)
                              var closest = xPos[0]
                              var index = 0
    
                              for item in xPos {
                                  var distanceToActual = abs(closest - goal)
                                  if (xPos.count - 1 > index) { // prevent out of the bound
                                      var distanceToNext = abs(xPos[index + 1] - goal)
                                      if(distanceToNext < distanceToActual) {
                                          closest = xPos[index + 1]
                                      }
                                  }
                                  index += 1
                              }
    
                              location.x = CGFloat(closest)
                          }
                      }
    

My question is, is there a more elegant way of doing it? It's very annoying to manually calc the position of the Black rectangles... Is there any way of doing this with the Black Rectangles placed in a HStack instead of manually position then?

A iphone screen if some Rectangles


Solution

  • The technique described in the answer to Is it possible to detect which View currently falls under the location of a DragGesture? can be used to detect, which of the squares is closest to the drag position (it was my answer).

    matchedGeometryEffect then provides a convenient way to match the position of the blue rectangle to the identified square.

    Like this:

    @State private var dragLocation = CGPoint.zero
    @State private var indexForDragLocation = 0
    @Namespace private var ns
    
    private func dragDetector(for index: Int) -> some View {
        GeometryReader { proxy in
            let width = proxy.size.width
            let midX = proxy.frame(in: .global).midX
            let dx = abs(midX - dragLocation.x)
            let isClosest = dx < (width / 2)
            Color.clear
                // pre iOS 17: .onChange(of: isClosest) { newVal in
                .onChange(of: isClosest) { oldVal, newVal in
                    if newVal {
                        indexForDragLocation = index
                    }
                }
        }
    }
    
    var body: some View {
        HStack(spacing: 0) {
            ForEach(0...4, id: \.self) { index in
                Color(white: 0.2)
                    .frame(width: 20, height: 20)
                    .padding(.horizontal, 25)
                    .matchedGeometryEffect(
                        id: index,
                        in: ns,
                        isSource: index == indexForDragLocation
                    )
                    .background {
                        dragDetector(for: index)
                    }
            }
        }
        .background {
            RoundedRectangle(cornerRadius: 4)
                .fill(.blue)
                .frame(width: 40, height: 80)
                .matchedGeometryEffect(
                    id: indexForDragLocation,
                    in: ns,
                    properties: .position,
                    isSource: false
                )
                .animation(.spring, value: indexForDragLocation)
                .gesture(
                    DragGesture(coordinateSpace: .global)
                        .onChanged { val in
                            dragLocation = val.location
                        }
                )
        }
    }
    

    Animation