How do I get a SwiftUI view to appear in front of other views while it's being dragged?

1.5k views Asked by At

I'm updating this question with new and more complete code, to show how I've attempted to implement the suggestion in the answer below from @HunterLion. Here's the original statement of the problem:

I am implementing a version of Pentominos using SwiftUI. When I drag a piece (view) onto the board, I'd like it to appear in front of other pieces (views) while being dragged, but it appears behind other pieces which were rendered later in the layout. When I drag the first piece (the U), it drags behind other pieces as well as the board:

Dragged piece

When dropped, the piece positions itself in front as desired:

Dropped piece

Per @HunterLion's suggestion, I have attempted to implement this using a @Published variable to set the zIndex in GameView, but it still doesn't work.

Regarding the following code, I haven't tried yet to create a minimum reproducible example -- not sure that's even possible, so this code is incomplete and not executable, but I think it shows the structure and relationships adequately.

GameView lays out the game space which contains the HomeViews and the board (image and BoardView). Each HomeView contains a PieceView which presents the individual pieces in their home positions. When a PieceView is dragged and dropped onto the board, it is redrawn within the BoardView (not shown).

The Pieces class contains a dictionary of the pieces, and this is where I put @Published var somethingsBeingDragged: Bool = false. somethingsBeingDragged is set in PieceView at the point where it is determined that a drag onto the board is occurring (as opposed to a shorter drag within PieceView that indicates a horizontal or vertical flip of the piece).

// GameView places the pieces and the board in the game space.
//
struct GameView: View {
    var dropTarget = Target()
    var map = Map(rows: constants.boardRows, cols: constants.boardCols)
    @ObservedObject var homes: Homes
    @ObservedObject var pieces: Pieces
    var body: some View {
        HStack
        {
            VStack {
                homes.home["U"].modifier(smallPieceFrame())
                homes.home["W"].modifier(smallPieceFrame())
                homes.home["X"].modifier(smallPieceFrame())
                homes.home["Y"].modifier(bigPieceFrame())
                homes.home["I"].modifier(bigPieceFrame())
            }
            VStack {
                homes.home["Z"].modifier(smallPieceFrame())
                ZStack {
                    Image("board")
                    BoardView(rows: constants.boardRows, cols: constants.boardCols)
                }
                    .zIndex(pieces.somethingsBeingDragged ? -1 : 1)
                homes.home["V"].modifier(bigPieceFrame())
            }
            VStack {
                homes.home["F"].modifier(smallPieceFrame())
                homes.home["P"].modifier(smallPieceFrame())
                homes.home["T"].modifier(smallPieceFrame())
                homes.home["L"].modifier(bigPieceFrame())
                homes.home["N"].modifier(bigPieceFrame())
            }
        }
        ...
----------------------------
// HomeView is the starting location of each piece, the location
// to which it returns if dropped illegally or removed from the board,
// and the location of the anchor image that remains after a
// piece is placed on the board.
//
struct HomeView: View {
    var id: String                      // piece being displayed
    var onBoard: Bool
    @EnvironmentObject var pieces: Pieces
    var body: some View {
        ZStack {
            PieceView(id: id, orientation: 8)   // 8 => anchor image
            if !onBoard {
                PieceView(id: id, orientation: pieces.piece[id]!.orientation)
            }
        }
    }
}
----------------------------
// PieceView tracks the individual game pieces, enables their
// reorientation by rotation (right and left) and reflection
// (horizontal and vertical) by gestures, enables their placement
// on the board by dragging.
//
struct PieceView: View {
    var id: String                          // Identifies the piece
    @State var dragOffset = CGSize.zero     // Offset of piece while dragging
    @State var dragging = false             // T => piece is being dragged
    @State var orientation: Int             // orientation of image
    @EnvironmentObject var dropTarget: Target
    @EnvironmentObject var map: Map
    @EnvironmentObject var pieces: Pieces

    ...

    var body: some View {
        Image(id + "\(orientation)")
            .padding(0)
        //                .border(Color.gray)
            .gesture(tapSingle)
            .highPriorityGesture(tapDouble)
            .offset(dragOffset)
            .gesture(
                DragGesture(coordinateSpace: .named("gameSpace"))
                    .onChanged { gesture in
                        
                        dragging = false
                        pieces.somethingsBeingDragged = false

                        // Currently checking for drag by distance, but intend to change this.
                        //
                        if abs(Int(gesture.translation.width)) > Int(constants.dragTolerance) ||
                            abs(Int(gesture.translation.height)) > Int(constants.dragTolerance) {
                            dragOffset = gesture.translation
                            dragging = true
                            pieces.somethingsBeingDragged = true
                        }
                    }
                    .onEnded { gesture in
                        if dragging {
                            if onBoard(location: gesture.location) {
                                
                                // piece has been legally dropped on board
                                //
                                dropTarget.pieceId = id
                                orientation = pieces.piece[id]!.orientation
                            } else {
                                
                                // piece was dropped but not in a legal position, so goes home
                                //
                                dragOffset = CGSize(width: 0.0, height: 0.0)
                            }
                        } else {
                            
                            // If not dragging, check for reflection.
                            //
                            ...
                            }
                        }
                    }
                
            )
            .zIndex(dragging ? 1 : 0)
    }
----------------------------
// Piece contains the state information about each piece: its size (in squares)
// and its current orientation.
//
class Piece: ObservableObject {
    var orientation: Int = 0
    let size: Int
    init(size: Int) {
        self.size = size
    }
}

// Pieces contains the dictionary of Pieces.
//
class Pieces: ObservableObject {
    @Published var somethingsBeingDragged: Bool = false
    var piece: [String: Piece] = [:]
    init() {
        for name in smallPieceNames {
            piece[name] = Piece(size: constants.smallPieceSquares)
        }
        for name in bigPieceNames {
            piece[name] = Piece(size: constants.bigPieceSquares)
        }
    }
}

I'll appreciate any help on this.

PS @HunterLion, in answer to your "By the way" comment, I set dragging to true within the if statement because only drags of a certain minimal distance are interpreted as moves toward the game board. Shorter drags are interpreted to flip a piece vertically or horizontally. I intend to change how different drags are recognized, but this is it for now.

1

There are 1 answers

2
HunterLion On BEST ANSWER

I have almost exactly the same code and it works perfectly with .zIndex() (I assume dragging is a @State variable in your view).

But that's not enough: you need to move the board to the background when a piece is being dragged.

So, the solution is to have a @Published variable in your view model that changes together with (or instead of) dragging. If we cal that variable isSomethingBeingDragged, you can add another .zIndex() to the board, like this:

ZStack {
   Image("board")
   BoardView(rows: constants.boardRows, cols: constants.boardCols)
}
.zIndex(viewModel.isSomethingBeingDragged ? -1 : 1)

If you prefer, instead of a variable in the view model, you can also use a @Binding between the two views.

By the way: why don't you just move dragging = true out of the if{} condition? It should be the first line inside the .onChanged.

Edit

After you have changed your question, I created the minimal reproducible example here below.

It was not working in your case because the pieces were still embedded in their VStacks: while the .zIndex() of the piece is 1, the .zIndex() of the VStack is still 0. So, the piece goes to the front inside the stack, but the stack is still in the back.

I just added more .zIndex() modifiers and, as you can see from the code below, it works: the green letters get in the front while moving, the grid is in the front otherwise. Downside: all letters of the VStack get in the front at the same time.

Try it as it is:

  1. In the GameView, place the .zIndex() on the stacks:
struct GameView: View {
    @StateObject private var pieces = Pieces()

    var body: some View {
        HStack {
            VStack {
                PieceView(id: "A", orientation: 0, pieces: pieces)
                PieceView(id: "B", orientation: 0, pieces: pieces)
                PieceView(id: "C", orientation: 0, pieces: pieces)
            }
            .zIndex(pieces.somethingsBeingDragged ? 1 : 0)
            
            VStack {
                ZStack {
                    Image(systemName: "square.grid.3x3")
                        .font(.system(size: 200))
                }
            }
            .zIndex(pieces.somethingsBeingDragged ? -1 : 1)
            
            VStack {
                PieceView(id: "X", orientation: 0, pieces: pieces)
                PieceView(id: "Y", orientation: 0, pieces: pieces)
                PieceView(id: "Z", orientation: 0, pieces: pieces)
            }
            .zIndex(pieces.somethingsBeingDragged ? 1 : 0)
        }
    }
}
  1. In PieceView, remember to bring the dragging variable back to false when the gesture ends.
struct PieceView: View {
    var id: String                          // Identifies the piece
    @State var dragOffset = CGSize.zero     // Offset of piece while dragging
    @State var dragging = false {           // T => piece is being dragged
        didSet {
            pieces.somethingsBeingDragged = dragging
        }
    }
    @State var orientation: Int             // orientation of image
    @ObservedObject var pieces: Pieces


    var body: some View {
        Text(id)
            .font(.system(size: 100))
            .fontWeight(.black)
            .foregroundColor(.green)
            .zIndex(dragging ? 1 : 0)
            .padding(0)
            .gesture(TapGesture())
            .highPriorityGesture(TapGesture(count: 2))
            .offset(dragOffset)
            .gesture(
                DragGesture(coordinateSpace: .named("gameSpace"))
                    .onChanged { gesture in
                        
                        dragging = false

                        // Currently checking for drag by distance, but intend to change this.
                        //
                        if abs(Int(gesture.translation.width)) > Int(10) ||
                            abs(Int(gesture.translation.height)) > Int(10) {
                            dragOffset = gesture.translation
                            dragging = true
                        }
                    }
                    .onEnded { gesture in
                        
                        if dragging {
                            if gesture.location.y < 300.0 {
                                
                                // piece has been legally dropped on board
                                //
                                orientation = pieces.piece[id]!.orientation
                            } else {
                                
                                // piece was dropped but not in a legal position, so goes home
                                //
                                dragOffset = CGSize(width: 0.0, height: 0.0)
                            }
                        }
                        
                        // On ended: bring the variables back to false
                        dragging = false
                    }
                
            )
    }
}
  1. The following stubs are part of the minimal reproducible example, to make it work.
struct Piece {
    var orientation: Int = 0
    let size: Int
    init(size: Int) {
        self.size = size
    }
}

class Pieces: ObservableObject {
    @Published var somethingsBeingDragged: Bool = false
    var piece: [String: Piece] = [:]
    let smallPieceNames = ["A", "B", "C"]
    let bigPieceNames = ["X", "Y", "Z"]
    init() {
        for name in smallPieceNames {
            piece[name] = Piece(size: 20)
        }
        for name in bigPieceNames {
            piece[name] = Piece(size: 20)
        }
    }
}