swiftuidrag-and-dropgesture

How do I pass gesture data to parent view and why do all my images drag together?


I am just a hobbyist trying to learn swiftui so I may not be even asking the right questions but here is the view I created to build a basic square grid that could be used for checkers. It works exactly as I would like but now I'm trying to add gestures.

Issue 1: On the main VStack under the .onTapGesture the "if x != nil { print(x!) } " correctly prints out the square that was tapped on. How do I get that info back to the view that called it or get it to change data in my Model to register the selection and/or trigger the event to highlight the square? This struct is in its own file. Do I have to put this entire struct inside my main struct for this to work? I feel like I'm missing something conceptually. I can trigger the highlight from subviews that are part of the bigger view with a button but can't figure out what the approach is for "generic views" outside the main struct.

Issue 2: it seems to lump all of the imageItems together for the .gesture(dragGesture). I can only drag by clicking the last item in the grid and as I move it around, all imageItems move as one. I feel like I read a stack overflow that addressed this before but can't find it now.

PS if you just feel like teaching a little, lol, any changes you would recommend?

import SwiftUI

struct FixedSquareGrid<Item: Identifiable, ItemView: View>: View {
    let lightSquare: String
    let darkSquare: String
    
    @State private var translation = CGSize.zero
    @State private var lastTranslation = CGSize.zero

    var items: [Item]
    @ViewBuilder var content: (Item) -> ItemView
    
    init(_ items: [Item], lightSquare: String, darkSquare: String, @ViewBuilder content: @escaping (Item) -> ItemView) {
        self.items = items
        self.content = content
        self.lightSquare = lightSquare
        self.darkSquare = darkSquare
    }
    
    var body: some View {
        GeometryReader { geo in
            let numberOfRows: Int = Int(sqrt(Double(items.count)))
            let gridItemSize = gridItemWidthThatFits(
                count: numberOfRows,
                size: geo.size)
            let boardSize = maxBoardSize(
                boardSquaresSize: gridItemSize * CGFloat(numberOfRows),
                size: geo.size)
            
            VStack(spacing: 0) {
                ForEach (0..<numberOfRows, id: \.self) { row in
                    HStack(spacing: 0) {
                        ForEach (0..<numberOfRows, id: \.self) { col in
                            let itemImage = content(items[((row * numberOfRows) + col)])
                            ZStack {
                                Image(((row % 2) + col) % 2 == 0 ? lightSquare : darkSquare)
                                    .padding(0)
                                    .frame(width: gridItemSize , height: gridItemSize)
                                    .clipped()
                                itemImage
                                    .padding(.bottom, 1)
                                    .frame(width: gridItemSize, height: gridItemSize)
                                    .clipped()
                                    .gesture(dragGesture)
                                    .offset(
                                        x: lastTranslation.width + translation.width,
                                        y: lastTranslation.height + translation.height
                                    )
                           }
                            .border(Color.black, width: 1)
                        }
                    }
                }
            }
            .frame(width: boardSize + 10, height: boardSize + 10 )
            .background(Color.black)
            .onTapGesture( perform: { location in
                let x = squareTapped(clickedAt: location, itemSize: gridItemSize, rowCount: numberOfRows)
                if x != nil { print(x!) }
            })
        }.aspectRatio(1, contentMode: .fit)
    }
    
    func maxBoardSize(boardSquaresSize: CGFloat, size: CGSize) -> CGFloat {
        let maxSquareSpace = (size.width <= size.height ? size.width : size.height)
        return (maxSquareSpace <= boardSquaresSize ? maxSquareSpace : boardSquaresSize)
    }
                
    func gridItemWidthThatFits(
        count: Int,
        size: CGSize
    ) -> CGFloat {
        let rowCount = CGFloat(count)
        let width = (size.width / rowCount)
        let height = (size.height / rowCount)
        return (width <= height ? width : height).rounded(.down)
    }
    
    var dragGesture: some Gesture {
        DragGesture()
            .onChanged { value in
                translation = value.translation
            }
            .onEnded { value in
                lastTranslation.width += value.translation.width
                lastTranslation.height += value.translation.height
                translation = .zero
            }
    }

    func squareTapped(clickedAt: CGPoint, itemSize: CGFloat, rowCount: Int) -> Int? {
        let col = Int((clickedAt.x / itemSize).rounded(.down))
        let row = Int((clickedAt.y / itemSize).rounded(.down))
        let sqr = col + (rowCount * row)
        let inBoundsSquare: Int? = (sqr > 0 && sqr <= (rowCount * rowCount) - 1 ) ? sqr : nil
        return inBoundsSquare
    }

}

Solution

  • The FixedSquareGrid can just take an extra (Item) -> Void function so that super views can detect that an item is tapped.

    let items: [Item]
    @ViewBuilder var content: (Item) -> ItemView
    let itemTapped: (Item) -> Void // extra property here
    
    init(
        _ items: [Item],
        lightSquare: String,
        darkSquare: String,
        @ViewBuilder content: @escaping (Item) -> ItemView,
        itemTapped: @escaping (Item) -> Void // extra argument here
    ) {
        self.items = items
        self.content = content
        self.itemTapped = itemTapped
        self.lightSquare = lightSquare
        self.darkSquare = darkSquare
    }
    

    Then you can call this in onTapGesture:

    .onTapGesture() { location in
        if let index = squareTapped(clickedAt: location, itemSize: gridItemSize, rowCount: numberOfRows) {
            itemTapped(items[index])
        }
    }
    

    Usage would look something like this:

    struct ContentView: View {
        var body: some View {
            FixedSquareGrid(
                [ /* insert your items here */ ],
                lightSquare: "...", darkSquare: "..."
            ) { item in
                // ...
            } itemTapped: { item in
                // handle tap here...
            }
        }
    }
    

    Also consider putting the tap gesture on each of the ZStacks, so that you don't have to calculate which square is tapped using the squareTapped method.


    As for the drag gesture, you only declared one set of states (translation and lastTranslation) for all the grid items, so of course they all move together.

    Each draggable thing should have its own pair of translation and lastTranslation states. You can make a new View or ViewModifier that contains these states.

    // the way you implemented the drag gesture is kind of weird
    // I changed it to an implementation similar to the one here:
    // https://sarunw.com/posts/move-view-around-with-drag-gesture-in-swiftui/
    struct DraggableModifier: ViewModifier {
        
        @State private var offset = CGSize.zero
        @GestureState private var startOffset: CGSize? = nil
        
        var drag: some Gesture {
            DragGesture()
                .onChanged { value in
                    var newOffset = startOffset ?? offset
                    newOffset.width += value.translation.width
                    newOffset.height += value.translation.height
                    self.offset = newOffset
                }.updating($startOffset) { (value, startOffset, transaction) in
                    startOffset = startOffset ?? offset
                }
        }
        func body(content: Content) -> some View {
            content
                .offset(offset)
                .gesture(drag)
        }
    }
    
    itemImage
        .padding(.bottom, 1)
        .frame(width: gridItemSize, height: gridItemSize)
        .clipped()
        .modifier(DraggableModifier())